blue is the uv/Python implementation of the green workflow model: a library
for building idempotent devops CLIs — desired state in YAML, workflows as step
graphs threaded by a plain dict, template-scaffolded config files, OpenTofu
and Ansible as the muscle.
The port is behavioral: green is the canonical model, and this package locks its Python behavior into pytest while preserving red-compatible APIs. Conventions stay structurally identical — a red project ports to blue by mechanical rename.
Everything called green (Clojure/Babashka) is called red in the
TypeScript/Bun implementation and blue here:
| green | red | blue |
|---|---|---|
:green/exit etc. |
"red/exit" etc. |
"blue/exit" etc. |
:green.scaffold/written |
"red.scaffold/written" |
"blue.scaffold/written" |
green.edn |
red.yml |
blue.yml |
./green (babashka) |
./red (Bun) |
./blue (uv script) |
green.workflow ns |
red/workflow module |
blue.workflow module |
Namespaced keywords stay namespaced string keys: "zk/servers",
"tofu/outputs". The engine reserves blue/* and blue.*/* for its control
keys ("blue/exit", "blue/err", "blue/trace", "blue/event",
"blue/dry-run", "blue/step", "blue/branches", plus private keys such as
"blue.workflow/inherited"); everything project-specific uses its own
namespace.
uv sync # install dependencies
uv run pytest # run the test suiteThe ZooKeeper end-to-end tests drive real tofu over HCL containing only
locals/output blocks and skip when tofu is not on PATH.
Try examples end-to-end:
cd examples/zookeeper
./blue create --dry-run
./blue create
./blue delete
cd ../multi-zookeeper
./blue create --dry-run
./blue create
./blue delete
cd ../once
./blue create --dry-run
./blue create
./blue delete
cd ../multi-once
./blue create --dry-run # offline path; real create needs a real S3 bucket
./blue create
./blue delete
cd ../floci-zookeeper
./blue create --dry-run # offline path; real create needs Linux + Docker + floci
./blue create
./blue deleteexamples/zookeeper— fake 3-node cluster: dynamic fan-out/join, scaffold + tofu, backend-as-advice, dry-run.examples/multi-zookeeper— two fake clusters from one embedded workflow; inherited parent advice reaches child step names.examples/once— ONCE-style single VPS with provider-swap advice,compute ∥ smtp → dns → smtp-post → (ansible-local ∥ ansible-remote), and scaffold-only Ansible config.examples/multi-once— many ONCE boxes from one workflow; parent swaps provider/backend advice by inherited id and uses S3 backend keys per deployment + step. Real create needs a real S3 bucket; dry-run is offline.examples/floci-zookeeper— real local 3-node ZooKeeper on floci. Linux-only for non-dry-run because it connects directly to Docker-bridge IPs. Requires a floci container namedfloci,tofu,ansible-playbook,docker, andaws. Dry-run works offline and should touch nothing. The opt-in e2e test isBLUE_FLOCI_E2E=1 uv run pytest tests/test_floci_zookeeper.py.
Modules under src/blue/, mirroring red's src/*.ts:
-
workflow.py— workflow engine. A step is(opts) -> opts(sync or async), named by a namespaced string.wire_fn(step, run_opts)defines the static graph for the run; optionalnext_fn(step, default_next, opts)handles fan-out, conditional routing, retries, and cleanup paths. Independent successors run in parallel asyncio tasks. Branches that converge join once with results under"blue/branches"; failed forks collapse and propagate the worst exit. The step boundary deep-freezes input (FrozenDict/FrozenListraise on mutation), recursively freezes tuple and frozenset members, catches raised exceptions, rejects non-dict returns, stamps"blue/step", and removes private inherited-advice state from results.step(wf, in_fn=..., out_fn=...)embeds workflows as ordinary steps. Unsupported mutable values and custom instances fail before the step runs. Callables remain usable; their captured state is outside the input freezing contract. Joins use the deepest shared fork and preserve enclosing forks. Failed joins select the highest exit code and its branch diagnostics, choosing the first incoming branch when codes tie. -
advice.py— Emacsnadvice-style combinators:around,before,after,override,before-while,before-until,after-while,after-until,filter-args, andfilter-return. Registries are immutable workflow values.advice_addtargets one step,advice_add_allevery step. Depth controls stack order (-100..100, lower = more outward); equal depth uses newest-outermost ordering. The while/until combinators use Clojure truthiness (onlyNone/Falseare falsy). Advice is inherited through embedded workflows by flat step name; same-id ancestor advice replaces child advice.advice_planshows the composed stack. -
renderer.py— small internal Selmer-compatible renderer used by scaffolding. Supports variables, dotted paths, missing values as empty, HTML escaping by default,safe,not-empty,sort(attribute='...'),forloops,ifblocks, and delimiter overrides (tag_open/tag_close/filter_open/filter_close) for templates that must preserve Jinja2{{ }}/{% %}. -
scaffold.py— flat file-spec DSL. Template specs render content owned by the package;content_specwrites computed content exactly. Delete removes the same targets and prunes empty parent directories.PRESERVE_JINJA_DELIMITERSis the shared alternate-delimiter preset. -
tofu.py— event-aware OpenTofu steps. Non-"delete"runsinit+applyand mergestofu output -jsonunder"tofu/outputs"by default."delete"runsinit+destroy. Backends arebeforeadvice (local_backend_advice,s3_backend_advice,gcs_backend_advice,r2_backend_advice,backend_advice).conventional_backend_advicesupplies package local/S3/R2 policy with explicit directory and state-key functions. Commands accept per-run environments;tofu_with_specsupports build, and deterministic HCL/JSON helpers match Green's bytes. -
ansible.py— event-aware Ansible steps. Non-"delete"runs the create playbook,"delete"the delete playbook. Parses PLAY RECAP under"ansible/recap"by default.inventory_advicewrites deterministic INI inventories.ansible_with_specscaffolds then runs (create) or runs then removes (delete). -
dry_run.py— dry-run advice.dry_run.advise(wf, steps)adds around advice that logs and skips listed steps when"blue/dry-run"is true. -
progress.py— all-step progress advice logging entry/exit timings. -
cli.py—run_cli/exec_cli: parses./blue <event> [-f blue.yml] [--start step] [--end step] [--dry-run], loads YAML with 1.2 core-schema scalars (no, dates and sexagesimal values stay strings; scientific notation, octal and hexadecimal values are numeric), overlaysCOLORS_PAR_*, stamps"blue/event", and runs the workflow. Exit 2 is reserved for usage/config errors. Explicit file options override configured defaults; parent-directory search applies only when the file option is omitted. -
lifecycle.py— configurable package preflight with ordered validation and success callbacks. -
providers.py— operations over caller-owned provider registries. -
process.py— inherited-terminal execution, POSIX quoting, and configurable sequential command plans. -
gates.py— pydantic schema gates forbefore-while(the port of red's Zod gates). Gates validate and return the original opts; they must never replace opts with parsed output, which would strip unrelated namespace keys. Functional-syntaxTypedDicts carry namespaced string keys. -
runtime.pyis the mutable test seam for subprocesses and log lines. Captured commands use asynchronousruntime.execwith per-command environments and timeouts.run_inherituses synchronousruntime.exec_inheritand inherits terminal input and output. Tests can stub either method. Results use zero for success and positive codes for failure: signals become128 + signal, timeouts return 124, and spawn failures return 127. On POSIX, timeout and cancellation of captured commands kill the command's process group. Cleanup waits at most one second for output pipes; descendants that start their own session can escape the group. Environment overrides set toNoneremove inherited variables.
A project's ./blue is a self-contained uv script: a
#!/usr/bin/env -S uv run --script shebang plus PEP 723 inline metadata —
no virtualenv to manage, no requirements file. It holds the workflow
definition, advice wiring, and the exec_cli call, plus reads of its local
templates. The in-repo example depends on blue by relative path via
[tool.uv.sources]; external consumers pin a version
(dependencies = ["blue==x.y.z"]) or a pinned git reference — never
unpinned.
Same as red's behavioral contract, with blue/* in place of red/*; see this
repository's index.html specification:
- All cross-step state is one open dict. Engine keys are
blue/*and privateblue.*/*; project/library keys are namespace-like strings such as"zk/servers","tofu/outputs". - Public constructors and advice-transforming functions are pure: they return new workflow values and never mutate existing workflows.
- Steps return new dicts (
{**opts, ...}); the engine freezes step input so a raising step cannot leave partial mutations behind. - Errors are opts values, not exceptions escaping the engine. Raise
StepErroronly when a step/advice wants the step boundary to convert it to"blue/exit"/"blue/err"/"blue/trace". - Keep output keys namespaced (
"my.ns/result"); never merge provider outputs into top-level opts by default. - YAML caveat to document in every project: version-like values (
3.10) must be quoted or YAML reads them as numbers.
blue.kubernetes and blue.kubernetes_client provide bounded polling workers, state-identity locks,
optimistic status writes, finalization, and explicit-context kubectl transport.
See the controller contract for callbacks and lifecycle rules.
blue.scope.with_scope owns ordered process/resource finalizers around a workflow.
See resource scopes.