sasajuric
CI/CD as a library
I’d like to announce a new library I’ve just released, called ci, which aims to provide CI/CD features as a library. It’s currently pretty basic, but I have plans on expanding it further with support for docker, integration with GitHub, etc. The general roadmap is included in the readme. Any feedback is welcome 
Most Liked
sasajuric
The library intends to complement, or in same cases completely replace, the existing CI systems (GH actions, Travis, Circle, Jenkins, …). In its current version, it only does the complementing part, which admittedly isn’t much, but I think it’s still something worth having.
You can see the concrete example in the way the library itself does CI:
The immediate benefits compared to eg GH actions:
- Implement most of the flow in Elixir
- Easily run all the CI steps locally
- Write test the CI flow (example)
- Due to support for parallel actions, you can get all the errors in a single CI roundtrip. In this example, if the code is not properly formatted, and some test is failing, and there are dialyzer warnings, all these errors will be reported in a single CI run.
The roadmap vaguely hints at future goals: the support for managing docker containers (so we can run dockerized builds), and integration with SCMs (e.g. GitHub, GitLab, …), which would allow us to easily create our own CI/CD platform in Elixir.
Finally, an internal, but a very important goal is to find properly focused abstractions. For example, in the current version we have OsCmd, Job, and Job.Pipeline, all of which can be used beyond just the CI domain. I’d like to keep this approach with the upcoming features too. Ideally, this library would be a set of small helpers, and it just so happens that you can combine them to implement your own CI 
sasajuric
But IMO CI flow is not configuration. It’s an imperative program. Of course, you can always treat a program as a collection of facts (it’s all 0s and 1s after all), but IMO most of the time that’s not very intuitive, and it’s also not flexible. As I’ve said previously in this tread, using a declarative approach to represent an imperative flow may sometimes be useful, but IMO it’s not a sensible default approach.
Yeah, the problem here is that config scripts are too “free-form”, whereas dhall seems to be more constrained. But ultimately, I think that in the context of CI, one major drawback of dhall is that it’s evaluated at “compile-time”, and so, despite it’s very interesting design and Turing completeness, it’s just more of the same from the “modeling imperative as declarative” school of thought.
What if I need to feed the output of one statement to others? Or what if I need to conditionally execute some step, depending on various circumstances (e.g. the outcome of the previous statement, or the branch being tested, or on whether the PR has been approved)? From what I can tell, you can’t make such decisions in dhall (because it transforms imperative to declarative). Of course, one can always do some trickery to make that happen, like pushing imperative logic into the command itself, or using :if properties of the CI engine, piping cmd output to file, …, but this all seems quite clumsy to me.
All this being said, I agree with the following observation:
As usual, we don’t get something for nothing. By using a “full-blown” language (i.e. a Turing complete language with a rich std library & ecosystem) at runtime, we’ve obtained some possibly dangerous power. We can do all sort of things like format the disk, try to steal secrets, issue a DoS attack, etc. Now, in prety much every real-life case I’ve experienced, such problems were more theoretical than practical, since the team was always very small and every team member wore all the hats (frontend, backend, devops, …). Obviously this won’t scale with respect to the CI user base, so doing this approach in e.g. CI as a service, or in larger companies is not something I’d recommend.
But there are many teams which are fairly small and I believe that in such cases, a full-blown imperative using the same language that is used for the implementation of the main product makes more sense. It’s an option which is simpler and more powerful, at the expense of being less secure.
In the cases where Elixir is not a viable option, you can consider other alternatives. One option is to go full declarative, maybe using dhall to generate the final specification. Note that you can still do this with the ci library. You basically need to transform the program into a declarative collection of facts, and then feed that to the engine that runs OS commands inside a Job. All of this can be done as a part of the mix my_app.ci task, invoked directly on the CI machine. The generated config file never needs to be stored on disk, or committed to the repo.
Another option is to use a sandboxed embeddabled language, e.g. Lua. This would allow you to use a TC language at runtime, while still being able to control what can be invoked. Again, this can be combined with the ci library, by importing a set of custom Lua functions which are under the hood using abstractions from the ci to run particular actions.
The ci library is marketed as a CI toolkit, which means that it’s not particularly “opinionated”, or the way I prefer to think about it, it’s not rigid. The library ships with various independent abstractions which you can use however you please. Take the parts that fit you, ignore the things that don’t, wrap any abstraction as you please. Again, you can most certainly build a declarative engine on top of this runtime TC imperative core, but it usually doesn’t work the other way around. If the core is declarative, adding runtime Turing completeness is going to be either impossible, or at the very list difficult and clumsy.
tcoopman
A bit of topic, but it seems that phoenix builds switched to https://earthly.dev/ which also has a way of running CI locally (https://twitter.com/josevalim/status/1346404430275612683). So I agree, running your CI locally is a feature that is so useful!
sasajuric
Yeah, some do, some don’t, but my impression is that even those that do require more clumsy/complex yaml to describe the desired pipeline. See e.g. build stages in Travis.
Except it’s not configuration, it’s literally imperative code. In the simplest case we want to execute some steps in sequence. In more complex cases we want to parallelize some steps. Sometimes we want to have a conditional around some steps (e.g. send an e-mail on errors, deploy the system if there are no errors). Other times we might want to run a loop. Modern CIs mostly support such scenarios somehow, but the solutions seem somehow clumsy to me. For example, with Travis build stages, you end up including if as data.
To be clear, the ability to represent the pipeline as data can sometimes be very useful, allowing generic pipeline actions such as: restart from step x, execute only step y, generate visual flow diagram, better progress report, etc. This can all be very handy in more complex pipelines, or in larger companies that want to standardize their flow across projects.
That said, a straightforward imperative approach would be a better fit in pretty much all the cases I’ve personally experienced. Furthermore, it’s worth pointing out that you can always build declarative on top of imperative, while the opposite requires some complex/clumsy improvisations and sometimes might not even be possible.
Finally, testability is IMO always a good benefit. It’s worth testing a more complex pipeline regardless of whether its described declaratively or imperatively. Otherwise, how can we be sure that e.g. code is deployed only after all the checks have passed and the PR is approved? Without tests, if we mess something up, this will fail only in production, and it will fail silently.
tcoopman
Hi @sasajuric
Thanks for this library. Could you give an example of an intended use case of this library. It looks cool to me, but I don’t see immediately why I would want to use it (maybe because I don’t have a need for it).







