December 16, 2024

porter - config files generation

config files

Whichever programming language we use, eventually we need to put what we have written in front of an audience. So we produce an artifact of some sort, which might have a load balancer in front of it, which might reference some other services it is dependant on. Each of those dependencies might in turn depend on other things.

A simple example might be a webservice of some sort. It might depend on the following:

  • A load balancer in front, which needs to know what to load balance to

  • A RDBMS that has an IP, a port, a specific authentication mechanism, a database, etc

  • A specific ip and port combo which the service has to bind to

  • A third party asset manager of some sort (CDN, S3, etc)

In addition, we might want to set this up in more than one environment. dev (local), staging and production are typical targets. For each environment we will have, at a bare minimum, some different values.

So much complexity

This leads to an abundance in complexity, tightly packed in to a small space. It’s sort of manageable, as the values rarely change. And there is a solution to not messing this up. Never, ever change anything. Problem solved.

Also…​ write this down in files that make it easy to reproduce the files. Put that in a repo somewhere so that you can track the changes.

I have historically used Makefile (which I still think is a good choice) and bash scripts or some python file. bash has the advantage that it’s everywhere and the disadvantage that it is bash. A joke lurks there in about wanting to bash your skull in.

porter - an exercise in metaprogramming

Having foraged into babashka, I decided to take a stab at this with a small library/CLI tool. It takes full advantage of babashka and being a lisp. This was a really fun mini project, solving a real problem I had and only possible thanks to Clojure and its community.

EDN

EDN is the chocie for holding the variables we want to use.

Advantages include

  • Mergeable - We can separate the data across files and still merge everything into one context map

  • Injection - With EDN being mergeable, we can inject data as well

  • Navigation - We can transverse the data with normal Clojure functions

  • Extendable - Should the need ever arise, we can extend the data

  • Data - EDN is pure data. It cannot represent anything but itself.

  • More default data types - Crucially we have a difference between lists and vectors, allowing for a neat trick with SCI (detailed below)

functional zipper

With functional zippers the context map can be navigated and further augmented. For those unfamiliar with functional zippers, they allow you to navigate and manipulate an arbitrary data structure, as long as you provide the means of navigation. This allows us to introduce things like lookups via vectors, function executions and injects of data structures.

Example context map
{:client/name "client-vip-name"
 :image.name/platform "platform"
 :version "1.0"
 :docker {:production {:platform {:image-namespace [:client/name]
                                  :image-name [:image.name/platform]
                                  :tag [:version]}}}}

In the above context map we have some data we wish to re-use in our configuration files. There is some data that is always present, such as the client name (:client/name), or what the service is named (:image.name/platform). There is a docker map, which holds a :production key, allowing for a differentation between a future :staging entry.

Since this is EDN and we have access to functional zippers, we can start treating vectors with keywords as lookups in the context map itself. By having multiple passes, we can use this to build up the final context map piece by piece until it’s complete.

[:client/name] becomes "client-vip-name".

Example context map after a couple of passes
{:client/name "client-vip-name"
 :image.name/platform "platform"
 :version "1.0"
 :docker {:production {:platform {:image-namespace "client-vip-name"
                                  :image-name "platform"
                                  :tag "1.0"}}}}

SCI (Small Clojure Interpreter)

Having read about SCI before, I knew it was powerful and allows for powerful metaprogramming. SCI is used for inserting arbitrary code into the context map, and have it executed.

Expanded example context map
{:client/name "client-vip-name"
 :image.name/platform "platform"
 :version "1.0"
 :docker {:production {:platform {:image-namespace [:client/name]
                                  :image-name [:image.name/platform]
                                  :tag [:version]}}}
 :docker.image/platform+tag (example-ns/get-namespace+tag
                              [:docker [:env] :platform :image-namespace]
                              [:docker [:env] :platform :image-name]
                              [:docker [:env] :platform :tag])}

A little bit more goes on here.

  • :env comes from an injection into the context map and is a required part of porter. In this example we will set it to :production.

  • example-ns is an imported namespace for SCI. This requires both the classpath and the namespaces we wish to import there.

  • get-namespace+tag corresponds to a function which takes an image namespace, an image name and which tag to use.

example-ns/get-namespace+tag
(defn get-namespace+tag [ns n tag]
  (format "%s.%s:%s" ns n tag))

All this combined would produce the following context map, to be referred when porter produces an output from the src file/input.

Final context map
{:client/name "client-vip-name"
 :image.name/platform "platform"
 :version "1.0"
 :docker {:production {:platform {:image-namespace "client-vip-name"
                                  :image-name "platform"
                                  :tag "1.0"}}}
 :docker.image/platform+tag "client-vip-name.platform:1.0"}

The final meta

With porter being a babashka/Clojure library, we can now utilize it directly via babashka. So natuarally there is a CLI tool which reference the github repo it sits in. We have come full circle with the snake biting its own tail.

porter

-- Options --

  --namespaces Namespaces to add
  --injections Map of injections to the context map
  --exec       Execute the src as a shell command
  --src        Source of input. String or file
  --env        Which environment are we using
  --classpaths Classpaths to add
  --print      Print the output to stdout
  --ctx-paths  vector of strings to context edn files
  --dest       Which file to write the output to

Yes…​ you can use the output from src as a shell command. Some day, when I have time, I might just write porter in itself. SOMETA.

SOMETA

hofstadter

PS.
I love Clojure :).

Tags: clojure