Chef Cookbook Badges

Chef, Badges Posted on

As you may have seen across the various repositories on GitHub, README badges are all the rage these days. Whether it is Travis CI, Code Climate, or GitTip, it seems like all developers ever do is badge!

Screenshot of a repository with a lot of badges

But badges have this problem you see - they existed before retina displays. So a lot of the badges are fuzzy and blurry when viewed on a high-quality display or projector. What is even worse (for someone who has obsessive compulsive disorder like myself), is that the padding, font, and color pallet varies from badge-to-badge. The padding on a Travis CI badge is just slightly larger than the padding on a Gemnasium badge, for example. It drives me fucking insane...

So I had this really grand idea! I was going to write a tiny little sinatra app that used a Ruby gem to generate SVG images for badges. You could tune the color, text, and information all via the URL. It was genius! And then I found out that someone already did it. I was both incredibly happy that such a service already existed and very sad that someone beat me to it. Nonetheless, I dove into the Shields codebase.

Shields supports a large number of services. Essentially, point shields at an API endpoint, tell it home to parse the information, and then call a function that creates the badge. The server will handle the rest of the caching and SVG generation. It's a really cool service that's offered completely free of charge. However, there was one problem...

There was no support for cookbooks! Oh no! The travesty! I had to fix it! As you may or may not be aware, the Chef cookbook community site exposes a rather verbose API. It lists all cookbooks, their versions, and some important metadata information as well. If you examine the response for https://cookbooks.opscode.com/api/v1/cookbooks, you would see something like:

{
  "total": 1,
  "items": [
    {
      "cookbook_name":"1password",
      "cookbook":"https://cookbooks.opscode.com/api/v1/cookbooks/1password",
      "cookbook_maintainer":"jtimberman",
      "cookbook_description":"Installs 1password"
    }
    // ...
  ]
}

But the API itself is RESTful. You can access the information page of any cookbook by visiting it's specific URL like https://cookbooks.opscode.com/api/v1/cookbooks/1password. The response from that request looks something like:

{
  "name": "1password",
  "category": "Applications",
  "maintainer": "jtimberman",
  "average_rating": null,
  "description": "Installs 1password",
  "external_url": "https://github.com/jtimberman/1password-cookbook",
  "latest_version": "https://cookbooks.opscode.com/api/v1/cookbooks/1password/versions/1_0_6",
  "created_at": "2011-02-26T05:16:12Z",
  "updated_at": "2012-12-29T22:40:19Z",
  "versions": [
    "https://cookbooks.opscode.com/api/v1/cookbooks/1password/versions/1_0_6",
    "https://cookbooks.opscode.com/api/v1/cookbooks/1password/versions/1_0_4",
    "https://cookbooks.opscode.com/api/v1/cookbooks/1password/versions/1_0_2",
    "https://cookbooks.opscode.com/api/v1/cookbooks/1password/versions/1_0_0"
  ]
}

My original thought was to make two API requests - first to /api/v1/cookbooks/NAME and then parse that JSON and issue a second request to get the version from the latest_version attribute. That seemed stupid. I should not make two API requests for a badge! That's really a waste of resources... My next genius idea was to make a single API request, but to parse the latest_version attribute, and substitute the underscores for dots. So 1_0_6 in the example above would become 1.0.6. That seemed hacky, but performant. I was digging through the Community Site codebase (which is not public) and I found there is an undocumented endpoint /latest for all cookbooks! In systems terms, the /latest endpoint is like a symlink to the highest uploaded version. It is not a RESTful endpoint (since it is not a unique identifier for a single resource). However, it is incredibly helpful in this scenario.

I took off my Ruby hat, washed my hands (thoroughly), and dove into some NodeJS. Code first, then explanation:

camp.route(/^\/cookbook\/v\/(.*)\.(svg|png|gif|jpg)$/,
cache(function(data, match, sendBadge) {
  var cookbook = match[1]; // eg, chef-sugar
  var format = match[2];
  var apiUrl = 'https://cookbooks.opscode.com/api/v1/cookbooks/' + cookbook + '/versions/latest';
  var badgeData = getBadgeData('cookbook', data);

  request(apiUrl, function(err, res, buffer) {
    if (err != null) {
      badgeData.text[1] = 'inaccessible';
      sendBadge(format, badgeData);
    }

    try {
      var data = JSON.parse(buffer);
      var latest = data.version;
      badgeData.text[1] = latest;
      badgeData.colorscheme = 'blue';
      sendBadge(format, badgeData);
    } catch(e) {
      badgeData.text[1] = 'invalid';
      sendBadge(format, badgeData);
    }
  });
}));

Loosely translated for the non-NodeJS folks: Sinatra-like Ruby code:

app.get(/^\/cookbook\/v\/(.*)\.(svg|png|gif|jpg)$/) do |cookbook, format|
  response = HTTP.get("https://cookbooks.opscode.com/api/v1/cookbooks/#{cookbook}/versions/latest")

  if response.ok?
    data = JSON.parse(response.body)
    version = data.version
    send_badge('cookbook', text: version, color: 'blue', format: format)
  else
    send_badge('invalid', format: format)
  end
end

Again, this is psuedo-code and is a very loose translation. The send_badge function accepts a data hash with a format and other important information. That code is actually irrelevant when making a provider. It is magic! You just call sendBadge from the NodeJS code and BOOM - you have a badge. So what is actually happening here?

  1. Match on some predefined "route" based on a regular expression. In the case for Chef Cookbooks, we are matching on /cookbook/v/NAME.FORMAT. Both the NAME and FORMAT attributes become part of the data given to the function.
  2. Extract the information out of the URL. In the Ruby example, Sinatra actually does this for us. In the NodeJS example, it's ust a simple regular expression matching and indexing. We save these results to variables to be more semantic.
  3. Execute a request against the remote API (Chef's in this case) and download the data.
  4. Handle any errors the server may throw.
  5. Parse the response as JSON (this obviously varies with the API - it could be XML or plain text for example)
  6. Send the badge to the user.

All the heavy-lifting is handled by the sendBadge method. Additionally, there is some response caching spinkled into speed up subsequent requests.

You can now add a nice badge to your README that queries Chef's Community Site API and generates a badge just for you! Add this to your README.md:

[![Cookbook Version](https://img.shields.io/cookbook/v/NAME.svg)](https://supermarket.chef.io/cookbooks/NAME)

For example, here is a snippet from the README of the apt cookbook:

[![Cookbook Version](https://img.shields.io/cookbook/v/apt.svg)](https://supermarket.chef.io/cookbooks/apt)

Which generates:

Cookbook Version

I'd like to commend the Shields team for being incredibly responsive. Not only was my Pull Request acknowledged in less than two days, but we had very productive conversations and it was merged shortly thereafter. Futhermore, my change was deployed to production just 5 days after merge. So HT to the Shields team for being awesome.

Happy cooking Chefs!

About Seth

Seth Vargo is an engineer at Google Cloud. Previously he worked at HashiCorp, Chef Software, CustomInk, and some Pittsburgh-based startups. He is the author of Learning Chef and is passionate about reducing inequality in technology. When he is not writing, working on open source, teaching, or speaking at conferences, Seth advises non-profits.