Sketches of syntax, a pipeline for Otto

Defining a good continuous integration and delivery pipeline syntax for Otto is one of the most important challenges in the entire project. It is one which I struggled with early in the project almost a year and a half ago. It is a challenge I continue to struggle with today, even as the puzzles pieces start to interlock for the multi-service system I originally imagined Otto to be. Now that I have started writing the parser, the pressure to make some design decisions and play them out to their logical ends is growing. The following snippet compiles to the current Otto intermediate representation and will execute on the current prototype agent implementation:

pipeline {
  stages {
    stage {
      name = 'Build'
      steps {
        echo '>> Building project'
        sh 'make all'
      }
    }
  }
}

“It works!”

The reaction upon sharing this with friends and colleagues on Twitter was largely: “looks like Declarative [Pipeline].” The syntax above is just a simple shim to get the basics working, but the similarities are no accident. Jenkins Pipeline is the best publicly available syntax for describing a CI/CD pipeline (in my not-so-humble opinion). If I didn’t believe that I wouldn’t continue to advocate for its use at every possible turn.

For Otto however, the goal is not to create a Jenkins Pipeline knock-off. In this post I wanted to share some sketches of what I think Otto Pipelines should look like and why. For starters, the README has an incomplete list covering some high-level goals that I have for modeling continuous delivery. The examples/ directory also has a few sample .otto files which I will use for reference throughout this post.

It may also be worth reading my previous post describing Step Libraries if you haven’t already, since they play an integral part in making this syntax “go.”

With the pre-requisites out of the way, let’s walk through some syntax!

Defining the available tools

use {
  stdlib
  './common/slack'
}

Sprinkled at the top of Otto Pipelines is the use block. A common problem with Jenkins Pipeline is that the steps available in the Jenkinsfile is completely dependent on what plugins have been installed on the controller or what Shared Libraries have been configured. The use block effectively brings Otto Step Libraries into scope for the given pipeline. Because Step Libraries require no “controller-side” execution in Otto, each Otto Pipeline can use a completely different sets of steps for users to leverage in their workflow.

Open questions:

  • Versioning for step libraries seems like it is worth doing, but what’s the right syntax for expressing it?
  • Referring to step libraries by URL could be incredibly useful, but is it worth the complexity?

Defining the execution environment

Next is declaring the execution environment for a stage/stages:

/* snip */
stage {
  name = 'Build'

  runtime {
    docker {
      image = 'ruby:2.6'
    }
  }

  steps {
  }
}

Resource allocation is one of the areas I am most excited to explore with Otto, but from a modeling standpoint and an execution standpoint. Jenkins was build before “cloud” was a thing, and arguably before “containers”, depending on whether or not any rabid Solaris users are within earshot. As such it has some pitfalls when mapping pipeline execution to these much more dynamic environments. On the flip side, newer CI/CD systems seem to have all gravitated towards container-all-the-things and typically don’t consider non-container workloads in any form, and will also usually require a Kubernetes clusters just to get started.

runtime {
  arch = 'amd64'
  linux {
    pkgconfig = ['openssl', 'libxml-2.0']
  }
  python {
    version = '~> 3.8.5'
    virtualenv = true
  }
}

For Otto I want to do better and started thinking about capabilities rather than fixed labels or names. In many cases, I don’t particularly care where my Rust project builds, just so long as it has cargo and an up-to-date stable rustc. Similarly for Python projects, I might need an execution environment with Python 3.x, virtualenv, and libxml2 installed. In most systems that precede Otto, administrators end up defining complex labels which users must know. Another way to paper over this complexity is to say “just bring your own container!” which pushes a lot of work back onto developers, typically leading to one-off Dockerfiles which just take an upstream image and add one or two dependencies.

With a capabilities-oriented model, the pipeline orchestration layer is no longer looking for machines labeled “linux-python” or and then hoping one is available. Instead the orchestrator can be smarter and find any available capacity to meet the capabilities request. I believe this approach can improve on overall system performance and scheduling. An idea which I have floating around as a draft RFC right now is basically to “auction” pipeline tasks to the lowest bidder. When I first started considering this idea, I found this paper titled Efficient Nash Equilibrium Resource Allocation Based on Game Theory Mechanism in Cloud Computing by Using Auction, which will likely guide the implementation of auctioneer quite a bit.

What remains to be seen is whether users are actually interested in expressing the capabilities that would be necessary to make a highly efficient resource auction practical.

Open questions:

  • Do most developers think about what their pipeline needs in the same way I think about capabilities?
  • How would an administrator define capabilities of a cloud-based VM template?

Caching

From an operational standpoint, I think the most common problem of any CI system is overuse of remote resources by pipelines. This is not a niche problem, but rather something that affects practically everybody. Some will say “you should be caching and proxying all your remote resources!” which is simply not a practical solution for the vast majority of the users in the ecosystem. Many at users won’t be at organizations large enough to deploy such caching proxies.

stage {
  name = 'Build'

  cache {
    // Create a cache named "gems", immutable for the rest of the Pipeline
    gems = ['vendor/']
    assets = ['dist/css/', 'dist/js/']
  }

  /* snip */
}
stage {
  name = 'Test'
  cache {
    use gems
  }
}

The cache block is intended to provide pipeline authors with a way to cache arbitrary sections of the workspace for later re-use in the pipeline across multiple agents. This is a pretty simple syntax addition, but something built into the Otto infrastructure from the beginning.

On the implementation side, this requires that archiving and retrieving these artifacts is relatively quick, which I don’t believe will be a major challenge.

Open questions:

  • Is it sufficient to cache a file subtree and simply restore it into the same location in another agent’s workspace?
  • Would this syntax accommodate the caching of Docker image layers?

Composition and Re-use

Inevitably developers try to abstract common functionality and behaviors into re-usable components. Step Libraries can provide one flavor of this re-usability, but I don’t believe that it is sufficient. The ubiqitous adoption of YAML by newer CI/CD tools lead me to joke about the five stages of YAML wherein developers end up turning a declarative syntax into templates and then into just another turing-complete language.

In Jenkins we have seen numerous tools for templatizing jobs, pipelines, or other aspects of Jenkins configuration. Suffice it to say, there is a need to compose and re-use various aspects of pipelines.

For Otto, I have been playing around with a context-aware from keyword, such as below:

stage {
  name = 'Test'
  runtime {
    from 'Build'
  }
  steps {
    sh 'bundle exec rake spec'
  }
}

In the above example, from instructs the pipeline to re-use the contents of the runtime block from the Build stage. My current thinking is that this simple use of from allows for pipeline-internal re-use of pieces without the need to set variables or turn this into a scripting language.

That said, re-usability within the pipeline isn’t where the main interest in “templates” lies.

I have been exploring the concept of a “blueprint” which can act as an re-usable unit of Otto Pipeline. I am imagining that these would be published and managed similarly to Step Libraries. In order to provide maximum flexibility, I think blueprints should be able to capture just about any snippet of the Otto Pipeline syntax for re-use, consider the following example to help make common Ruby gem build/test/publish pipelines cleaner:.

rubygem.blueprint

use {
  stdlib
}

blueprint {
  parameters {
    rubyVersion {
      description = 'Specify the Ruby container'
      default = 'ruby'
      type = string
    }

    deploy {
      description = 'Push to rubygems.oorg'
      default = true
      type = boolean
    }
  }

  plan {
    stages {
      stage('Build') {
        runtime {
          docker {
            image = vars.rubyVersion
          }
        }

        steps {
          sh 'bundle install'
          sh 'bundle exec rake build'
        }
      }

      stage('Test') {
        runtime { from 'Build' }
        steps {
          sh 'bundle exec rake test'
        }
      }

      stage('Deploy') {
        gates { enter { vars.deploy } }
        runtime { from 'Build' }
        steps {
          sh 'bundle exec rake push'
        }
      }
    }
  }
}

This would then be later re-used within an Otto Pipeline by using the same from syntax as before:

pipeline {
  from 'blueprints/rubygem'

  /*
   * Optionally I could add additional post-deployment configuration here,
   * which would be ordered after the blueprint's stages have completed
   */
}

Since from would be somewhat context-aware and would be able to pull all the right stages “into place” within the pipeline. I’m optimistic that this approach would allow the definition that includes just one stage for example, or other blocks which can be defined within the pipeline { }.

I am not yet sure what the right mechanism for passing parameters into the blueprint should be. Right now I am leaning towards keyword arguments on the from directive: from blueprint: 'blueprints/rubygem', rubyVersion: '2.6', deploy: false.I am not really sure what the implementation complexity of this approach will bring however.

Open Questions:

  • Will treating from almost like a preprocessor directive allow the parser to successfully handle blueprints for arbitrary blocks of pipeline?
  • Does this amount of composition alleviate the pressure that templates tend to solve for other systmes?

Gates

The final bit of syntax I wish to discuss at the moment are “gates.” One of the least appreciated parts of just about every CD pipeline, gates define how the pipeline should behave differently under certain conditions, including pausing for user input or an external event.

From one of the modeling goals I had set:

External interactions must be model-able. Deferring control to an external system must be accounted for in a user-defined model. For example, submitting a deployment request, and then waiting for some external condition to be made to indicate that the deployment has completed and the service is now online. This should support both an evented model, wherein the external service “calls back” and a polling model, where the process waits until some external condition can be verified.

A contrived example of what this might look like for a pipeline which prepares a deployment whenever changes land in the main branch:

gates {
  enter { branch == 'main' }

  /*
  * The exit block is where external stimuli back into the system
  * should be modeled, providing some means of holding back the pipeline
  * until the condition has been met
  */
  exit {
    input 'Does staging look good to you?'
  }
}

Of anything discussed thus far, gates have the most runtime implementation requirements. In the primitive example above we have:

  • A Git branch being referenced, which needs to be pulled into scope somehow/somewhere.
  • An expression that needs to be evaluated in the service mesh before this stage of the pipeline is dispatched.
  • An input step which should allow the agent which executed the stage to deallocate and pause further execution of the pipeline until some external event is provided.

The last item is the most challenging for me to think about from an implementation and modeling standpoint. Somewhere within Otto a state machine for each pipeline must be maintained, and once an input, webhook, or some other step is encountered, the state machine must pause for external actions. How those external actions should be wired in? Not sure! How those steps should be defined? Not sure!

There are so many open questions at this point.

Gates leave me with the most discomfort of any of my ideas for Otto. Done well, gates could provide a key component missing from many existing tools. The challenge is going to be finding the space between the pipeline modeling language and the execution engine which will accommodate them.


I still probably have more questions than answers at this point about how the pipeline modeling syntax should be defined and how it should execute. The one major lesson which I have learned from my time in the Jenkins project is that the pipeline syntax cannot be improved in isolation from the execution environment. There are many key design decisions which need to be made in both domains which will have major repercussions in the other.

I think back to the word used by a developer who read my thoughts on what I want to do with Otto:

Ambitious.”


As always, if you’re curious to learn more, you’re welcome to join #otto on the Freenode IRC network, or follow along on GitHub