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: red's TypeScript test suite defines the contract, and this package ports it to pytest. 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), catches raised exceptions, rejects non-dict returns, stamps"blue/step", and removes private inherited-advice state from results.step(wf, in_=..., out=...)embeds workflows as ordinary steps. -
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,sort(attribute='...'),forloops, and delimiter overrides (tag_open/tag_close/filter_open/filter_close) for templates that must preserve Jinja2{{ }}/{% %}. -
scaffold.py— flat file-spec DSL. Specs are{"template": {"name", "content"}, "target", "data", "opts"?}where template content is read by the owning package (next to the module or viaimportlib.resources) — there is no runtime template lookup. Create renders files;"blue/event": "delete"removes the same rendered targets and prunes empty parent directories. -
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,backend_advice). -
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 booleans (nois a string;zk/…keys parse unquoted), stamps"blue/event", and runs the workflow. Exit 2 is reserved for usage/config errors. -
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.py— mutable test seam for subprocesses and log lines. All command execution goes throughruntime.exec(asyncio subprocesses); tests stub it instead of shelling out.
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 (see the repository SPEC.md), with blue/* in place of
red/*:
- 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.