green specification · 2026-07-06
A babashka-compatible Clojure library for building idempotent devops CLIs: desired state in YAML or EDN, workflows as data-threading step graphs, Selmer-scaffolded configuration, OpenTofu as the muscle, and Ansible for SSH provisioning.
Read the source tour (Docco-style).
1. Philosophy
- green is a library. Users define steps, wiring, and workflows in their own project; green supplies the engine, infrastructure helpers, and CLI plumbing.
- Everything is a map. The desired state is an EDN map loaded into
opts. Every step reads and returns that map, possibly enriched with observed reality such as OpenTofu outputs or Ansible recaps. - Idempotent apply. There is no plan phase in green itself. Re-running the same event should converge.
createanddeleteare conventions; the event set is open. - Unix exit-code semantics. Steps report success/failure with
:green/exit; the workflow's final value becomes the process exit code. - babashka-compatible. The library and tests run under babashka and the JVM.
2. Desired state & the opts contract
The CLI reads green.edn by default (override with -f/--file), stamps the lifecycle event, and passes the map to wf/run. green reserves :green/* keys:
| Key | Meaning |
|---|---|
:green/event | Keywordized first positional CLI argument: :create, :delete, :rotate, etc. |
:green/dry-run | Stamped by --dry-run; dry-run advice uses it to skip named steps. |
:green/exit | Integer outcome. Missing means success and is normalized to 0 after a step returns. |
:green/err | Error message when :green/exit is positive. |
:green/trace | Stack trace string when a thrown exception is converted to a green failure. |
:green/step | Current step keyword, stamped before each step runs. Advice such as progress reads it. |
:green/branches | Vector of per-branch result maps at a join or collapsed failed fork. Sort by your own key if order matters. |
3. Steps
A step is a plain function opts → opts, named by a qualified keyword such as :zk/node. The name anchors wiring, logging, CLI slices, and advice.
- A returned map without
:green/exitis treated as success. - Returning a non-map is an error.
- Throwing is allowed: the runner catches the exception and returns
:green/exit1(or:green/exitfrom exception data), plus:green/errand:green/trace. - Steps usually branch on
:green/event. Library steps use the convention “any non-:deleteevent creates/applies;:deletedestroys/deprovisions.”
4. Workflows: wire-fn, next-fn, and composition
(require '[green.workflow :as wf])
(def workflow
(wf/workflow {:start :zk/start ;; required
:end nil ;; optional inclusive slice boundary
:wire-fn wire-fn ;; required static graph
:next-fn next-fn})) ;; optional dynamic router
(wf/run workflow opts) ;; => final opts
wire-fn — the static graph
(wire-fn step run-opts) returns a vector containing the step function followed by its default successors for this run. run-opts is the initial opts passed to wf/run, so the graph may depend on stable run-level inputs such as :green/event.
(defn wire-fn [step run-opts]
(case step
:zk/start [start-step :zk/node]
:zk/node [node-step :zk/zoo-cfg]
:zk/zoo-cfg [zoo-cfg-step]
:ci/fork [fork-step :ci/a :ci/b]))
Multiple successors mean a static fork. The same graph is used for join scheduling, so keep the happy path visible even when next-fn adds dynamic behavior. Do not base wire-fn on values produced after the run starts; route those cases in next-fn.
next-fn — the dynamic router
When present, next-fn decides successors after each non-end step:
(next-fn step default-next opts) ;; => seq of [next-step opts] pairs
- Return no pairs to terminate a branch, one pair to continue, or many pairs to fan out in parallel.
- Because it sees the step result, including
:green/exit,next-fncan route failures to cleanup, retry, or halt. - Without
next-fn, positive:green/exitstops that path; otherwise the default successors run.
Composition — a workflow is a step
(wf/step sub-wf)
(wf/step sub-wf
{:in-fn (fn [opts] sub-opts)
:out-fn (fn [opts sub-result] parent-result)})
- The sub-workflow's result is just a step result, so failures propagate through parent routing naturally.
- With default
:in-fn, ambient keys such as:green/eventand:green/dry-runflow into the child. If custom:in-fnbuilds opts from scratch, carry the ambient keys the child needs. - Advice inheritance is preserved even when
:in-fnrebuilds opts from scratch. This is what lets a parent impose dry-run, backend, audit, or provider advice on embedded workflows.
Slices
The optional :end step is inclusive: it runs, then the workflow stops. The CLI's --start and --end override workflow boundaries for safe idempotent slice runs.
5. Parallelism, fan-out & joins
- Static fork:
wire-fnlists several successors. - Dynamic fan-out:
next-fnreturns several[step opts]pairs. - Join: branches from different origins that converge on the same step run that step once, using the deepest shared fork's opts plus
:green/branches. - Failure: a failed branch inside a fork lets siblings finish their current step, collapses the fork, skips the join, and propagates the worst exit with all branch results under
:green/branches.
wire-fn graph; dynamic detours not visible in that graph may require explicit routing care.Scheduler algorithm in plain English
- Start with one live task: run
:startwith the initial opts. - Loop while work remains, tracking
liveandfinishedbranch entries. - Group live entries by step.
- Run only ready steps: if a different live branch can still reach a step, wait so it can join.
- Run ready units concurrently with
future. - Expand each result into zero, one, or many next entries.
- When different origins arrive at one step, run a join unit once.
- Collapse failed forks before scheduling more work.
- Finalize to one opts map: first failure wins, otherwise the last success.
Independent pipelines inside a fan-out
When next-fn fans out N branches and each branch traverses multiple steps in sequence (e.g. :validate → :transform → :load), the engine joins at the second step. The N :validate entries share the same fan-out parent, so they run independently; but the N :transform entries arrive from N different :validate units, so same-origin? is false and the scheduler treats them as convergence — running :transform once as a join instead of N times independently.
The fix is wf/step: wrap the multi-step pipeline in a sub-workflow and wire the wf/step as a single step in the parent. The N branches all land on that one step name, sharing the same fan-out parent, so same-origin? is true and they run independently. Inside each branch the sub-workflow runs its own scheduler with no other branches to converge with. The parent's advice on the sub-workflow's step names is inherited by name — dry-run, backends, gates, progress all carry over without re-registration.
;; The per-item pipeline as a sub-workflow
(def pipeline-wf
(wf/workflow {:start :validate
:wire-fn (fn [step _]
(case step
:validate [validate-step :transform]
:transform [transform-step :load]
:load [load-step]))}))
;; Wire it as a single step in the parent
(defn wire-fn [step _run-opts]
(case step
:start [start-step :process]
:process [(wf/step pipeline-wf)]))
;; Fan out one :process per item
(defn next-fn [step _default-next opts]
(when (= step :start)
(mapv (fn [item] [:process (assoc opts :item item)])
(:items opts))))
wf/step. If the number of branches is fixed and known at design time, distinct step names work: :deploy-staging and :deploy-production never converge, so they run independently without a sub-workflow. wf/step is needed when the count is dynamic — N items, N nodes, N tenants.A join consumes the deepest fork shared by all incoming branches and preserves its enclosing forks. Failed joins use the highest exit code and that branch's error and trace. Equal codes select the first incoming branch.
YAML plain scientific notation, octal and hexadecimal values are numeric. Date-like and sexagesimal values remain strings. Quoted scalars remain strings. Unknown CLI options return exit 2 before workflow execution.
Process helpers return 127 when a command cannot start, 124 on timeout and 130 on interruption. The timeout covers process exit and output capture. Cleanup waits up to one additional second for pipes, plus a bounded group-kill command. Captured commands use POSIX process groups when setsid and kill are available. Timed commands also check for surviving session members; ps distinguishes running children from unreaped zombies when available. Other platforms and inherited-terminal execution use Java descendant handles, which cannot recover children already reparented after the leader exits. A child that starts its own session can escape group cleanup.
6. Advice
Advice wraps step functions without changing graph wiring. Registries are immutable workflow data; adding/removing advice returns a new workflow.
(-> workflow
(wf/advice-add :zk/node :before ::backend write-backend!)
(wf/advice-add-all :around ::progress progress)
(wf/advice-remove :zk/node ::backend)
(wf/advice-remove-all ::progress))
| How | Effect | Use cases |
|---|---|---|
:around | (advice base opts) | Dry-run, retry, timing, locks; may call the base zero, one, or many times. |
:override | (advice opts), base never runs | Replace/stub a step. |
:before | Advice side effect, then base; advice return ignored | Setup: backend files, inventories, directories, locks. |
:after | Base, then advice side effect; base return flows out | Audit, metrics, notifications, cleanup. |
:before-while | Continue inward only while advice returns truthy | Validation and policy gates. |
:before-until | Use advice result when it is truthy; otherwise base | Fast path / already converged no-op. |
:after-while | Run advice only when the inward result is green-true | Success-only verification or registration. |
:after-until | Use inward result if green-true; otherwise advice fallback | Recovery or alternate result. |
:filter-args | Transform opts before base | Normalize/scope inputs. |
:filter-return | Transform base result | Normalize/enrich outputs. |
For :after-while and :after-until, a map is true when :green/exit is missing or zero and false when it is positive; non-map values keep Clojure truthiness for direct green.advice/compose use.
Stacking is Emacs-like: at equal :depth, newest advice is outermost. :depth (-100..100, default 0) overrides add order: lower depths run farther outside, higher depths farther inside. Re-adding the same id replaces the old entry and moves it according to the new sequence/depth.
Advice inheritance through wf/step. The enclosing run stamps its effective advice into opts under a private key, and the nested run merges that advice over the child's own registries. Ancestor entries are outermost at equal depth; same-id ancestor entries replace child entries. Use wf/advice-plan with a workflow or chain such as [parent child] to inspect the composed stack.
green.dry-run/adviseattaches:aroundadvice id:green.dry-run/skipto named steps. With:green/dry-run, it printsdry-run: would run :step (event)and returns exit 0 without calling the step.green.progress/adviseattaches all-step:aroundadvice id:green.progress/progress. It prints>>> :step (event)before and<<< :step (Nms)after, reading:green/step.
7. The scaffolding DSL
green.scaffold/scaffold materializes or removes a flat sequence of file specs:
(sc/scaffold opts
[{:template :zk/main.tf
:target "{{workdir}}/nodes/{{node.id}}/main.tf"
:data {:workdir (:zk/workdir opts) :node node}}])
| Key | Meaning |
|---|---|
:template | Qualified keyword resolved to a classpath resource: :zk/main.tf → zk/main.tf, :my.app/zoo.cfg → my/app/zoo.cfg. |
:target | Output path, itself rendered with Selmer against :data. |
:data | Selmer data for both body and target path. |
On :green/event :delete, the same specs name targets to remove. The implementation prunes the immediate parent directory if it becomes empty. Return keys are :green.scaffold/written or :green.scaffold/deleted.
8. OpenTofu integration
(require '[green.tofu :as tofu])
(tofu/tofu-step opts {:dir "work/nodes/1"})
(tofu/tofu-step opts {:dir "work/nodes/1" :output-key :zk/node-outputs})
- Any non-
:deleteevent:tofu init -input=false -no-color, thentofu apply -auto-approve -input=false -no-color, thentofu output -json. :delete:tofu init, thentofu destroy -auto-approve.- Apply outputs are parsed to
{keyword value}and assoc'd under:tofu/outputsby default. If you override:output-key, keep it namespaced. - Non-zero process exits become
:green/exitand:green/err. - A launch failure — no such
:dir, or notofubinary onPATH— is a failed exit, never a raw exception: the runner reports exit127with the start error as:err, the same shape as red's and blue's runtimes and asgreen.process/run.tofu-stepthen returns the ordinarytofu init failed:outcome, andoutputsthrows itstofu output failed:ex-infocarrying{:dir dir}— the one shape a caller reading tofu state may rely on. - Backends are not hardwired.
backend-advicewritesbackend.tf.jsonfor any backend type and config map (or function of opts), preserving native JSON values and nested collections. Helpers ship for local, S3, and GCS.
9. Ansible integration
(require '[green.ansible :as ansible])
(ansible/ansible-step opts
{:dir "ansible"
:inventory "inventory.ini"
:private-key "work/ssh/id_ed25519"
:host-key-checking false})
- Any non-
:deleteevent runs the create playbook (create.ymlby default);:deleteruns the delete playbook (delete.ymlby default). Override with:playbooks. - Options map to
ansible-playbook::inventory,:private-key,:user,:extra-vars(JSON-e), and:host-key-checking false(exportsANSIBLE_HOST_KEY_CHECKING=False). - On success, PLAY RECAP is parsed into per-host counters under
:ansible/recapby default; override with namespaced:recap-key. inventory-inirenders deterministic INI from{group {:hosts [{:name .. :vars {..}}] :vars {..}}}. Strings preserve whitespace, quotes, backslashes and literal-looking values such as"123". Host values include shell quoting around their Python literal; group values use the literal directly.inventory-adviceis a:beforeadvice that writes an inventory file from data or a function of opts, mirroring OpenTofu backend advice.
10. The CLI
./green <event> [-f|--file green.edn] [--start step] [--end step] [--dry-run]
./green create
./green delete -f prod.edn
./green create --start zk/node --end zk/node
./green create --dry-run
- The first positional argument is keywordized into
:green/event. --startand--endcoerce to keywords and override workflow boundaries.--dry-runstamps:green/dry-run true; it has an effect only on steps advised withgreen.dry-run.- Missing event, missing state file, or parse/runtime CLI errors return exit 2 from
run-cli.execprints:green/err/:green/traceto stderr and exits.
A consumer launcher should use a git dependency with an explicit SHA until green is published to Clojars:
#!/usr/bin/env bb
(babashka.deps/add-deps
'{:deps {io.github.getcolors/green {:git/sha "REPLACE_WITH_COMMIT_SHA"}}})
(babashka.classpath/add-classpath "resources")
(require '[green.cli :as cli] '[green.workflow :as wf])
(defn wire-fn [step _run-opts]
(case step
:app/start [identity]))
(def workflow (wf/workflow {:start :app/start :wire-fn wire-fn}))
(cli/exec workflow)
11. Examples
Every example is a self-contained babashka CLI that depends on this checkout with :local/root "../..". Four examples are mock-only OpenTofu demos; floci-zookeeper creates real local containers through floci.
| Path | What it teaches |
|---|---|
examples/zookeeper | Dynamic fan-out from desired state, a join at :zk/zoo-cfg, Selmer scaffolding plus OpenTofu, backend-as-advice, and dry-run. |
examples/multi-zookeeper | Workflow composition: one cluster workflow embedded with wf/step, fanned out once per cluster, with inherited parent advice. |
examples/once | ONCE-style single VPS. Demonstrates provider-swap advice, compute ∥ smtp → dns → smtp-post → (ansible-local ∥ ansible-remote), per-step tofu state, and scaffold-only Ansible files. See examples/once/SPEC.md. |
examples/multi-once | Many ONCE boxes from one once-wf. The parent replaces inherited ::provider and ::backend advice; S3 state keys are isolated by deployment and step. Dry-run is declared last so it skips the re-added :before advice too. |
examples/floci-zookeeper | Real local ZooKeeper: OpenTofu's AWS provider targets floci, Ansible provisions over SSH, an event-specific static graph routes delete through Ansible before destroys, validation gates use :before-while, :filter-args normalizes observed nodes, retry advice polls quorum health, and progress output is enabled. |
cd examples/zookeeper
./green create --dry-run
./green create
./green delete
cd ../multi-zookeeper
./green create --dry-run
./green create
./green delete
cd ../once
./green create --dry-run
./green create
./green delete
cd ../multi-once
./green create --dry-run # offline path; real create needs an S3 bucket
./green create
./green delete
cd ../floci-zookeeper
./green create --dry-run # validates and prints; touches nothing
./green create # real cluster on floci + Ansible
./green delete # delete.yml first, then tofu destroys
12. Testing strategy
The suite runs under babashka (bb test) and the JVM (clojure -X:test). Under babashka, bb.edn explicitly requires and runs each test namespace; under the JVM, deps.edn's :test alias uses cognitect.test-runner discovery.
test/green/workflow_test.cljcovers linear runs, errors, slices, event-specific static graphs, dynamic routing, fork/join, failure collapse, concurrency, and composition.test/green/advice_test.cljcovers every advice combinator, ordering/depth, all-step advice, removal/replacement, and inheritance throughwf/step.test/green/scaffold_test.cljcovers template resolution, create/delete symmetry, and idempotent rendering.test/green/tofu_test.cljcovers backend advice rendering without invokingtofu.test/green/ansible_test.cljcovers playbook selection, PLAY RECAP parsing, inventory rendering, and inventory advice without invokingansible-playbook.test/green/zookeeper_test.cljis the e2e suite: iftofuis onPATH, it runs real render/apply/output/destroy cycles over HCL containing onlylocalsandoutputblocks.
The real floci example is intentionally not in the automated suite because it requires Docker/floci, Ansible, provider downloads, and minutes of runtime; verify it manually with its ./green create/delete.
13. Project layout & API index
green/
├── deps.edn JVM deps: selmer, babashka/cli, cheshire (+ :test, :build)
├── bb.edn paths + `bb test` task
├── build.clj tools.build: jar / install / deploy
├── index.html this specification
├── docco.html generated source tour
├── src/green/
│ ├── advice.clj nadvice combinators and pure registry ops
│ ├── workflow.clj workflow, run, step, advice add/remove/all, advice-plan
│ ├── scaffold.clj flat Selmer file-spec DSL
│ ├── tofu.clj tofu-step, outputs, backend advices (local/s3/gcs)
│ ├── ansible.clj ansible-step, recap parsing, inventory advice
│ ├── dry_run.clj dry-run advice + advise
│ ├── progress.clj progress advice + advise
│ └── cli.clj run-cli, exec, arg parsing
├── test/green/ advice, workflow, scaffold, cli, dry-run, progress, tofu, ansible, zookeeper
└── examples/ zookeeper, multi-zookeeper, once, multi-once, floci-zookeeper
Distribution
Library coordinate: io.github.getcolors/green. It has not been published to Clojars yet.
- Current rule: external consumers and launcher snippets must use a git dep with an explicit commit SHA:
io.github.getcolors/green {:git/sha "REPLACE_WITH_COMMIT_SHA"}. - Development: in-repo examples use
{:local/root "../.."}. - Future Clojars publishing:
clojure -T:build jar,install, anddeployare available; deploy readsCLOJARS_USERNAME/CLOJARS_PASSWORD.
| Function | Signature / purpose |
|---|---|
green.workflow/workflow | {:start :end :wire-fn :next-fn} → workflow; wire-fn is (fn [step run-opts] ...) |
green.workflow/run | workflow, opts → final opts |
green.workflow/step | workflow [, {:in-fn :out-fn}] → step fn |
green.workflow/advice-add | workflow, step, how, id, f [, props] → workflow |
green.workflow/advice-remove | workflow, step, id → workflow |
green.workflow/advice-add-all | workflow, how, id, f [, props] → workflow |
green.workflow/advice-remove-all | workflow, id → workflow |
green.workflow/advice-plan | workflow-or-chain, step → printable advice stack |
green.advice/hows, ordered, compose | Low-level advice combinator set, ordering, and wrapper composition. |
green.advice/add, remove-id, add-global, remove-global-id | Low-level pure registry operations used by green.workflow. |
green.advice/merge-entries, merge-registry | Low-level inheritance helpers for composing parent/child advice registries. |
green.scaffold/template-path | keyword → classpath resource path |
green.scaffold/render-template | keyword, data → rendered string |
green.scaffold/scaffold | opts, specs → opts |
green.tofu/outputs | dir [, env] → outputs map; on a non-zero exit — a launch failure (no such directory, no tofu binary) included, reported as exit 127 — throws ex-info "tofu output failed: …" with {:dir dir}, never a raw exception. |
green.tofu/tofu-step | opts, {:dir :output-key} → opts |
green.tofu/backend-advice | (opts → dir), type, config → before-advice |
green.tofu/local-backend-advice | (opts → dir) [, config] → before-advice |
green.tofu/s3-backend-advice | (opts → dir), config → before-advice |
green.tofu/gcs-backend-advice | (opts → dir), config → before-advice |
green.ansible/default-playbooks | Default event-to-playbook map: {:create "create.yml" :delete "delete.yml"}. |
green.ansible/playbook | opts [, playbooks] → playbook file |
green.ansible/parse-recap | ansible output → per-host counters |
green.ansible/ansible-step | opts, options → opts |
green.ansible/inventory-ini | groups → INI string |
green.ansible/inventory-advice | (opts → file), groups → before-advice |
green.dry-run/advice | step → around-advice |
green.dry-run/advise | workflow, steps → workflow |
green.progress/progress | around-advice |
green.progress/advise | workflow → workflow |
green.cli/usage | CLI usage string for errors. |
green.cli/run-cli | workflow [, args] → final opts |
green.cli/exec | workflow [, args] → exits process |