Tuesday, August 18, 2015

Creating the Packager DSL - The executor

  1. Why use a DSL?
  2. Why create your own DSL?
  3. What makes a good DSL?
  4. Creating your own DSL - Parsing
  5. Creating your own DSL - Parsing (with Ruby)
  6. Creating the Packager DSL - Initial steps
  7. Creating the Packager DSL - First feature
  8. Creating the Packager DSL - The executor
Our user story:
I want to run a script, passing in the name of my DSL file. This should create an empty package by specifying the name, version, and package format. If any of them are missing, print an error message and stop. Otherwise, an empty package of the requested format should be created in the directory I am in.
Our progress:

  • We can parse the DSL into a Struct. We can handle name, version, and package format. If any of them are missing, we raise an appropriate error message.
We still need to:
  • Create a package from the parsed DSL
  • Provide a script that executes everything
Since the script is the umbrella, creating the package is the next logical step. To create the package, we'll defer to FPM. FPM doesn't have a Ruby API - it is designed to be used by sysadmins and requires you to build a directory of what you want and invoke a script.

The first seemingly-obvious approach is to directly embed the things to do directly in the parser where we are currently creating the Package struct. That way, we do things right away instead of building some intermediate thing we're just going to throw away. Sounds like a great idea. And, it would be horrible.

The best programs are written in reusable chunks that each do one and only one thing and do it well. This is true for operating systems and especially true for programs. In software, we call it coupling, or the degree one unit is inextricably-linked to other units. And, we want our units to be coupled as little as possible.

Our one unit right now (the parser) handles understanding the DSL as a string. We have two other responsibilities - creating a package and handling the interaction with the command-line. Unless we have a good reason otherwise, let's treat each of those as a separate unit. (There are occasionally good reasons to couple things together, but it's best to know why the rule's there before you go about breaking it.)

Now, we have two different units that, when taken together in the right order, will work together to take a DSL string and create a package. They will need to communicate one to the next so that the package-creation unit creates the package described by the DSL-parsing unit. We could come up with some crazy communication scheme, but the parser already produces something (the Package struct). That should be sufficient for now. When that changes, we can refactor with confidence because we will keep our 100% test coverage.

Before anything else, we'll need to install FPM. So, add it to the gemspec and bundle install.

Next, we need to write a test (always write the test first). The first part of the test (the input) is pretty easy to see - we want to create a Packager::Struct::Package with a name, version, and format set. Figuring out what the output should be is . . . a little more complicated. We don't want to test how FPM works - we can assume (barring counterexample) that FPM works exactly as advertised. But, at some point, we need to make sure that our usage of FPM is what we want it to be. So, we will need to test that the output FPM creates from our setup is what we want.

The problem here is FPM delegates the actual construction of the package to the OS tools. So, it uses RedHat's tools to build RPMs, Debian's tools to be DEBs, etc. More importantly, the tools to parse those package formats only exist on those systems. Luckily for this test, we can ignore this problem. The command for creating an empty package is extremely simple - you can test it easily yourself on the commandline. But, we need to keep it in the back of our mind for later - sooner rather than later.

Since we're testing the executor (vs. the parser), we should put our test in a second spec file. The test would look something like:

describe Packager::Executor do
    it "creates an empty package"
        executor = Packager::Executor.new(:dryrun => true)
        input = Packager::DSL::Package.new('foo', '0.0.1', 'unknown')
        executor.create_package(input)
        expect(executor.command).to eq([
            'fpm',
            '--name', input.name,
            '--version', input.version,
            '-s', 'empty',
            '-t', input.package_format,
        ])
    end
end

A few things here:
  1. Unlike Packager::DSL where we run with class methods (because of how DSL::Maker works), we're creating an instance of the Packager::Executor class to work with. This allows us to set some configuration to control how the executor will function without affecting global state.
  2. FPM does not support the "unknown" package format. We're testing that we get out what we put in.
  3. The FPM command already looks hard to work with. Arrays are positional, but the options to FPM don't have to be. We will want to change that to be more testable.
  4. Creating that Packager::DSL::Package object is going to become very confusing very quickly for the same reasons as the FPM command - it's an Array. Positional arguments become hard to work with over time.
You should run the spec to make sure it fails. The Packager::Executor code in lib/packager/executor.rb would look like:

class Packager::DSL
    attr_accessor :dry_run, :command

    def initialize(opts)
        @dry_run = opts[:dry_run] ? true : false
        @command = [] # Always initialize your attributes
    end

    def create_package(item)
        command = [
            'fpm',
            '--name', item.name,
            '--version', item.version,
            '-s', 'empty',
            '-t', item.package_format,
        ]

        return true
    end
end

Make sure to add the appropriate require statement in either lib/packager.rb or spec/spec_helper.rb and rake spec should pass. Add and commit everything, then push. We're not done, but we're one big step closer.

prev