Part I covers the DSL and execution engine; Part II (sections
S1–S12) covers the embeddedageintegration (keys/secret), the@secretsdirective,--localmode, and the target-sidecred-envhelpers. The optional native transport (shellflow-muxy, themuxyfeature) is documented indocs/muxy-transport.md.
shellflow is a minimal, modern, single-binary Rust CLI for shell-native
deployment. It combines the best ideas of:
| Tool | Idea absorbed |
|---|---|
| Mina | A single SSH connection per remote block: the Bash script is piped over ssh host "bash -s" via stdin, eliminating per-command round trips. |
| Envoy | Pure-Bash developer experience: the "DSL" is just a Bash script with comment directives. No YAML, no new language, full editor highlighting. |
| Deployer | The canonical pipeline: local build -> copy (rsync) -> remote deploy, with multi-host fan-out. |
| pyinfra | Realtime debugging (-v/-vv/-vvv) and idempotent dry-run/diff previews — without Python or a fact-collector engine. |
Design constraints:
- Zero YAML, zero Python. The input is 100% valid Bash.
bash deploy.shruns the file as a plain script;shellflowinterprets# @comment directives to drive local/remote execution. - Zero embedded SSH protocol. We shell out to the system
sshandrsyncviatokio::process::Command. This inherits~/.ssh/config,ssh-agent, keys, jump hosts, and hardware tokens for free. - Leverage the shell, do not reimplement it.
set -exreplaces a debugger,rsync --dry-run -ireplaces a diff engine,bash -nreplaces remote syntax checking. - Production-safe by default. Timeouts, Ctrl-C/SIGTERM cleanup, preflight checks, and exit codes CI can distinguish — no orphan processes.
- A single static binary (few MB, LTO-fat release profile) that runs anywhere
ssh/rsync/bashexist — local, CI (GitHub Actions, GitLab CI), laptop. - Concurrent multi-host execution with live, host-prefixed, color-coded output.
-v / -vv / -vvvrealtime debugging with zero extra architecture.--dry-run/--diffidempotent previews before any change.- Lean, auditable codebase: the library crates
shellflow-text(shared shell-text rules, the zero-dependency leaf),shellflow-dsl,shellflow-core,shellflow-secrets,shellflow-muxy(the optional native transport, behind themuxyfeature) plus thebin/shellflowCLI, of which about a third is tests. The target is clarity and test coverage, not line-count gymnastics — "lightweight" means a single static binary with no runtime dependencies, not minimal source lines.- Production-safe lifecycle: global and per-step timeouts, Ctrl-C/SIGTERM handling that never leaves orphan processes, and a friendly preflight check. - TDD with unit tests +
proptestin the normalcargo testloop, pluscargo-mutantsfor mutation coverage (just mutation).
- No server-side agent, daemon, or push/pull protocol.
- No configuration-state database or fact-collector engine (pyinfra-style
"facts"). Idempotency is achieved through
@only_ifguards and transport semantics (rsyncdelta), not a state model. - No reimplementation of SSH, SCP, or rsync.
- No Windows support in v1 (Bash-dependent; WSL is an acceptable host).
- No interactive credential handling. Scripts run with the SSH user's privileges; interactive password prompts are explicitly unsupported (see §9).
Mina's core trick: concatenate the whole remote task into one script and feed it once via stdin. We do exactly this:
shellflow ssh -T user@host bash -l -s remote host
┌─────────────┐ stdin ┌──────────────────────────────┐ ┌─────────┐
│ payload │ ─────────> │ set -eu + env(PATH) + block │ ─>│ bash │
│ (memory) │ │ (env precedes set -x, §7.2) │ │ stdout │
└─────────────┘ └──────────────────────────────┘ │ stderr │
▲ │ │
└──────────────────── streamed lines ─────────────────┘ │
One TCP/SSH setup per host per remote block — no per-command overhead.
Envoy's insight is that the deployment description should be a script. Comment directives keep the file executable as plain Bash while giving the tool structured boundaries.
pyinfra's two most loved features are:
- Realtime debugging — see exactly what runs on which host, when.
- Idempotent dry-run/diff — prove the change before making it.
pyinfra implements these with a heavy Facts → State → Operations engine. We reproduce both features with three shell primitives (see §8), keeping the total complexity at a fraction of pyinfra's.
A directive is a Bash comment line whose first non-whitespace token is # @.
All other lines (including plain comments) belong to the current block.
| Directive | Syntax | Semantics |
|---|---|---|
@server |
# @server <name> <ssh-spec> |
Declare a server alias. <ssh-spec> = [user@]host[:port] |
@group |
# @group <name> <member>[,<member>…] |
Declare a group of server aliases |
@env |
# @env <KEY> / # @env <KEY>=<value> |
Inject an env var into subsequent blocks. No =value → copy from shellflow's own environment. Values never appear in argv and are masked in previews/traces (§7.2, §9) |
@local |
# @local |
Following lines run on the local machine (default at start) |
@remote |
# @remote <target> |
Following lines stream to <target> (alias, group, or raw ssh-spec; @-prefix optional) |
@copy |
# @copy <src> -> <dst> @<target> [--delete] [--exclude <glob>] |
Sync a local file/dir to <dst> on all hosts in <target>. --delete mirrors removal of remote extras; in --local mode it swaps a staged copy into place so a failed copy cannot empty the destination. --exclude (repeatable) is honored by the rsync path and warned as ignored on the scp fallback and in --local mode. src/dst support $VAR/${VAR} interpolation from @exported state (§4.4) |
@secrets |
# @secrets <file.env.age> [--identity <path>] |
Decrypt an age-encrypted KEY=VALUE file into the environment of all subsequent blocks (Part II §S5). --identity overrides the run-wide -i for this entry only. |
@export |
# @export <VAR>[,<VAR>…] [--public] |
After the preceding local block, capture the named variables into run state; later @copy paths interpolate them and later blocks receive them as env vars (§7.6). A captured value is masked by default — the value is unknown at parse time and a playbook routinely exports a credential it just fetched; --public is the explicit, reviewable opt-out for a value that is meant to be read (VERSION, COMMIT) |
@timeout |
# @timeout <seconds> |
Cap the next block per host; on expiry the child is SIGTERM'd, then SIGKILL'd after a grace period (§7.5) |
@name |
# @name <label> |
Label the next block (for --only/--skip filtering and reporting) |
@only_if |
# @only_if <command> |
Guard: run next block only where <command> succeeds (exit 0); otherwise report SKIPPED |
@wait_active |
# @wait_active <unit> @<target> [--timeout SECS] |
Poll systemctl is-active <unit> on every target host until it reports active or the budget expires (default 300s). Replaces the hand-rolled for/sleep/ss poll loops that differ in every playbook. A host that never reports active is FAILED (script class); an unreachable host is a transport failure (§6) |
@assert |
# @assert <unit> [@<target>] [<state>] |
Assert the unit's ActiveState on the target (default active). The target is inherited from the previous step when it is not given. A failed assertion fails the step (script class) |
@assert_health |
# @assert_health <url> [@<target>] [--expect CODES] [--method M] [--body D] [--header H] |
Assert that the URL answers with an accepted HTTP status (any 2xx/3xx by default; --expect takes exact codes and classes such as 2xx). --method/--body/--header cover endpoints that only answer POST. The probe waits (up to the step's timeout) on the target, so a service that was just started is not failed for the wrong reason. The target is inherited from the previous step |
@assert_port |
# @assert_port <port>[,<port>…] [@<target>] [--udp] |
Assert that the ports are listening, waiting up to the step's timeout (in one round trip, not one per poll). --udp checks the UDP sockets. The target is inherited from the previous step |
@assert_port_free |
# @assert_port_free <port>[,<port>…] [@<target>] |
Assert that the ports have no listener. A conflict names the occupant (the listening process, nft DNAT rules, container publish mappings) instead of just "address in use" |
@assert_enabled |
# @assert_enabled <unit> <enabled|disabled|static|masked> [@<target>] |
Assert the unit's boot state. The expected state is declared next to the unit, so enablement drift fails the deploy instead of waiting for a reboot surprise |
@assert_rendered |
# @assert_rendered <path> [@<target>] |
Assert that the file exists and holds no unexpanded ${…} placeholder. The target is inherited from the previous step |
@assert_no_leak |
# @assert_no_leak <unit> [@<target>] [--lines N] |
Assert that no credential value of the unit's store appears in its journal, its command line, or its environment. The check is value-based: the target-side helper decrypts the store and compares, so a playbook needs no per-service regular expression and the plaintext never reaches the script, the command line, or the log |
@assert_digest |
# @assert_digest <path> [@<target>] [--sha256 HEX] [--local PATH] |
Assert that the file matches a digest (--sha256) or the controller's copy of it (--local). A real drift gate: the hand-written md5sum printed a digest that nothing compared |
@assert_credstore |
# @assert_credstore <service> [@<target>] |
Assert that the service's credential store exists, is 0700/0600, and holds exactly the keys the unit declares. A key in one and not the other is a unit that fails to start with a cryptic systemd error; this makes it a readable failure |
@workdir |
# @workdir [--prefix NAME] |
Declare the run's remote staging directory, published to the run state as WORKDIR. The controller never creates it, so a run leaves nothing behind on the machine that drives the deployment; the target's copy is removed when the run ends |
@helper |
# @helper [@<target>] [--source DIR] [--triple T] [--install-path PATH] |
Build, cache, ship, and atomically install the target-side helper. The build is the expensive part of a deploy, so the artifact is cached by source revision and target triple: the second service reuses the first one's build |
@backup |
# @backup <path>… @<target> [--keep N] [--seal] [--dir PATH] [--service S] |
Snapshot the paths for rollback, keep the newest N, and shred the rest. --seal encrypts the snapshot with the host key, so no plaintext lands on disk. The "no plaintext left behind" guarantee is structural: the helper checks the files it wrote, not a directory re-globbed through a pipeline that cannot fail |
@install |
# @install <src> -> <dst> @<target> [--mode M] [--owner U:G] [--dir-mode M] |
Install a file on the target atomically: the data is written to a temporary name in the destination directory and renamed into place, so a reader (systemd, systemctl cat, a concurrent daemon-reload) never observes a half-written unit file, config, or binary |
@credstore |
# @credstore <service> @<target> [--require KEY,…] |
Seal the service's credential store and check it against the keys the unit declares |
@service |
# @service <unit> @<target> [--enable] [--restart] [--reload] [--wait] [--verify] [--force-start] |
Drive the unit's lifecycle: verify the installed unit, reload the manager, enable it, restart it, wait for it to be ready. --restart restarts a unit that was running and leaves a unit that was not running alone — restarting an inactive unit starts it, which is the opposite of what an operator who keeps a unit stopped wants. --force-start overrides that deliberately |
@reload |
# @reload <unit> @<target> [--signal SIG] [--wait] [--timeout SECS] |
Reload a unit through its own ExecReload= (or an explicit --signal); --wait waits for readiness. A reload that cannot be performed fails instead of degrading into a restart |
@unit_inventory |
# @unit_inventory [@<target>] |
List the units installed on the target. A report, not a gate: a pre-existing finding must never block an unrelated deploy |
@release |
# @release <owner/repo> @<target> --installed-cmd CMD --asset PATTERN --to DIR [--base-url URL] |
Track a GitHub release: resolve the newest tag (via the releases/latest redirect, falling back to the API), compare against the installed binary, and download the matching asset only on drift. {tag}/{version} expand to the v-prefixed/bare tag; the install is atomic; an unresolvable release skips instead of failing. --base-url overrides the download host for vendor CDNs |
@build |
# @build <repo-url> @<target> --ref REF --build CMD --artifacts A… --install-dirs D… [--prep CMD] [--workdir DIR] [--units U…] [--version-flag F] [--min-free-gb N] |
Build a pinned ref from source on the target: fetch and detached-checkout, native build, disk guard, fresh-binary preflight (declared flag first, then the flag chain), backup, atomic install, restart-only-running units, auto-rollback (old binary restored, unit re-started) on restart failure. Artifacts support path=name renames. --prep runs at the repo root after checkout (e.g. submodule init). --workdir builds inside an existing checkout instead of cloning (for cross-repo path deps); checkout there is never forced |
@toolchain |
# @toolchain <tool,…> @<target> |
Update language toolchains (os, aur, rustup, cargo, go, npm, pipx, uv, foundry, proto, gh). Each missing tool reports SKIP; any failed update fails the step. The per-tool quirks (AUR never under sudo, uv tool upgrade --all, foundry skipped while anvil runs) are owned by the runner |
@image |
# @image <image…> @<target> [--prune] |
Pull container images (podman, then docker), compare digests before/after, report MOVED/SAME/FAIL per image |
Assertion steps. Consecutive assertions that share a target are merged
into one step: the whole group travels in a single SSH round trip, and the
operator sees which condition failed. A named assertion is its own step, so
it can be selected on its own with --only. An assertion's path or URL may
name a captured variable (/run/${SVC}/config.toml); it is resolved from
the run state, like a @copy path. The target may be
omitted, in which case it is inherited from the previous step — the common
case is therefore a single short line. An assertion step replaces the
hand-written systemctl is-active && curl -f tail that every playbook used
to end with, and that every playbook spelled differently.
Rules:
- Every
# @directive flushes the pending block. Directives may appear in any order and may be interleaved with blocks. - A block that contains no executable statement (only comments/blank lines) is dropped.
- Blocks default to
localuntil an explicit@remote/@copy/@local.@copyis a self-contained step that carries its own target, and it returns the block mode tolocal— the lines that follow are not streamed to the copy's remote target. Other self-contained steps (@wait_active,@reload,@assert*,@service,@install,@backup,@credstore,@helper,@workdir,@unit_inventory,@release,@build,@toolchain,@image) leave the ambient mode unchanged: lines after# @service …still run where the previous@remote/@localput them. @env,@only_if,@timeout, and@nameapply to the next block that contains statements.@exportapplies to the preceding local block and is an error after a remote block (remote variables cannot be captured). Because every directive flushes the pending block, the preceding local block may already have been emitted when@exportis read;@exporttherefore binds to the last emitted local step, not only to the pending one.- The shebang
#!/usr/bin/env shellflowis a comment like any other: a comment-only block is dropped, but a block that also holds code keeps its lines verbatim (harmless underbash, which treats it as a comment). - CRLF line endings are tolerated (
\rstripped). - Lines are included verbatim in the payload, preserving fidelity.
- A here-document body is opaque:
<<TAG/<<-TAGopeners are tracked lexically and every line up to the exact terminator is script content, exempt from directive detection. Bodies are consumed FIFO because one line may open several here-documents. - A directive keyword must be a whole word:
# @server-web xis an unknown directive, not@serverwith the name-web.
#!/usr/bin/env shellflow
# === Servers and groups ===
# @server web1 deploy@10.0.0.1
# @server web2 deploy@10.0.0.2:2222
# @group web web1,web2
# === Build locally, capture the git revision ===
# @name build
# @local
set -eu
echo "==> building release binary"
cargo build --release --locked
VERSION=$(git rev-parse --short HEAD)
# === Capture VERSION into run state (see §4.4, §7.6) ===
# @export VERSION --public
# === Ship the artifact ($VERSION is interpolated by shellflow, not Bash) ===
# @name ship
# @copy target/release/myapp-$VERSION -> /tmp/shellflow/myapp-$VERSION @web --delete
# === Restart, but only where the service exists ===
# @name restart
# @remote web
# @only_if systemctl list-unit-files --type service | grep -q myapp
set -eu
echo "==> restarting myapp-$VERSION on $(hostname)"
sudo systemctl restart myappThe parser is a single-pass line scanner (no regex, no PEG engine — see
Decision Record D-3). It produces an ExecutionPlan:
/// A parsed `[user@]host[:port]` destination.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SshSpec {
pub user: Option<String>,
pub host: String,
pub port: Option<u16>,
}
/// Target of a remote/copy step: a server alias, a group, or a literal spec.
///
/// The raw text is kept verbatim and classified lazily in `resolve_hosts`, so
/// a step may target a group that is declared later in the script.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Target {
/// The raw target text from the script (`web`, `deploy@host`…).
pub text: String,
}
#[derive(Clone, Debug)]
pub struct LocalStep {
pub name: Option<String>,
pub script: String,
pub guard: Option<String>,
pub timeout: Option<u64>,
/// Variables captured into run state after this step (`@export`).
pub export: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct RemoteStep {
pub name: Option<String>,
pub target: Target,
pub script: String,
pub guard: Option<String>,
pub timeout: Option<u64>,
}
#[derive(Clone, Debug)]
pub struct CopyStep {
pub name: Option<String>,
/// Consecutive copies to the same target travel in one transfer.
pub entries: Vec<CopyEntry>,
pub target: Target,
pub delete: bool,
/// `--exclude <glob>` patterns (repeatable); honored by the rsync path,
/// warned as ignored on the scp fallback and in `--local` mode.
pub excludes: Vec<String>,
/// `@only_if` is **not supported on `@copy`** — the parser rejects it and
/// this field stays `None` in valid plans (retained for struct symmetry).
pub guard: Option<String>,
/// Per-step timeout in seconds (`@timeout`); bounds the rsync/scp transfer.
pub timeout: Option<u64>,
/// `@env` snapshot in effect when the copy was declared.
pub env: std::sync::Arc<Vec<EnvEntry>>,
}
#[derive(Clone, Debug)]
pub enum Step {
Local(LocalStep),
Remote(RemoteStep),
Copy(CopyStep),
Wait(WaitStep),
Reload(ReloadStep),
Assert(AssertStep),
Service(ServiceStep),
Install(InstallStep),
Backup(BackupStep),
Credstore(CredstoreStep),
UnitInventory(UnitInventoryStep),
Helper(HelperStep),
// Added after this section was written; see §4.1 for the directives.
Build(BuildStep),
Release(ReleaseStep),
Toolchain(ToolchainStep),
Image(ImageStep),
}
/// A wait-for-active step (`@wait_active`).
#[derive(Clone, Debug)]
pub struct WaitStep {
pub name: Option<String>,
/// Target server/group/spec.
pub target: Target,
/// systemd unit name (`foo` or `foo.service`).
pub unit: String,
/// Wait budget in seconds (`@timeout` or `--timeout N`, else 300).
pub timeout: Option<u64>,
/// Poll interval in seconds (fixed at 5: kind to systemd, not a knob).
pub interval_secs: u64,
}
#[derive(Clone, Debug, Default)]
pub struct ExecutionPlan {
/// Deterministic ordering (BTreeMap) for stable plan summaries.
pub servers: BTreeMap<String, SshSpec>,
pub groups: BTreeMap<String, Vec<String>>,
/// All `@env` entries, in declaration order.
///
/// Copy-on-write behind an `Arc`: every step holds an `Arc` clone of the
/// snapshot that was current when it was declared, so snapshotting the
/// environment into every step costs one pointer copy rather than a deep
/// copy of every key/value pair.
pub env: Arc<Vec<EnvEntry>>,
/// All `@secrets` file references, in declaration order.
pub secrets: Vec<SecretEntry>,
/// The run's remote staging directory (`@workdir`), if declared.
pub workdir: Option<Workdir>,
pub steps: Vec<Step>,
}
/// Captured cross-step state (`@export`): interpolated into later `@copy`
/// paths and injected as env vars into later local/remote blocks.
#[derive(Clone, Debug, Default)]
pub struct RunState {
pub vars: BTreeMap<String, String>,
}
/// Pure resolution: alias/group/raw -> concrete hosts, honoring a CLI
/// `--target` restriction. Kept in the core crate so it is unit-testable.
pub fn resolve_hosts(
plan: &ExecutionPlan,
target: &Target,
restrict: &[String],
) -> Result<Vec<ResolvedHost>, ParseError>;ResolvedHost { alias: String, spec: SshSpec } carries the display alias
(web1) for log prefixes and the concrete spec for ssh/rsync argv.
- Directive detection is lexical. A
# @fooline inside a here-document body is recognized as body data, not a directive, so generated content (systemd units, templates, YAML) can never rewrite block boundaries. A# @fooline inside a multi-line string that is not a here-document is still treated as a directive, because a lexical scanner cannot see multi-line string spans. Deploy scripts should keep directives at the top level. - A here-document whose delimiter the shell expands (
<<$TAG,<<`gen_tag`) cannot be tracked statically, so its body keeps the untracked behavior. Quoted (<<'TAG',<<"TAG") and backslash-escaped (<<\TAG) delimiters are literal and are tracked exactly. Seecrates/shellflow-dsl/src/heredoc.rs. - An unterminated here-document is a parse error naming the line that opened it. Bash only warns and treats end-of-file as the terminator; matching that would let a missing terminator silently swallow every later directive, producing a plan that looks valid and is missing steps. The strictness is deliberate and is the one place the grammar is stricter than Bash.
- Directive payloads are tokenized with the same quote-aware rule everywhere
(
'a b'is one value), so a quoted--body/--header/path behaves the same in all 33 directives. - No conditional/loop constructs in the DSL itself. Script authors use plain
Bash (
if,for,command -v,[ -f ] || …) for control flow, and@only_iffor host-level preconditions. @copypaths are parsed by shellflow, not Bash.src/dstare literal strings extracted from the comment, so a bare$VERSIONthere is not expanded by Bash and would silently reachrsyncverbatim. Use@exportto capture values and let shellflow interpolate$VAR/${VAR}from run state. An unresolved variable is an error at execution time, never a silent no-op.@env KEY= valuekeeps the space, like a shell assignment with a quoted value would need quoting to preserve.@copydelimiters are fixed:src/dstmust not contain the literal->, the target is the final whitespace-separated token (an@prefix is optional and stripped). Adstwith spaces is joined from the middle tokens; a leading@in a path is ambiguous with the target marker and unsupported.
Per AGENTS.md: bin/ holds CLI crates, crates/ holds reusable libraries.
shellflow/
├── Cargo.toml # workspace deps & lints
├── Justfile # format/lint/test/mutation/build
├── AGENTS.md # the non-negotiables an agent must follow
├── docs/
│ ├── design.md # this document (Part I: engine, Part II: secrets)
│ ├── muxy-transport.md # the optional native transport
│ ├── infra-integration-roadmap.md # the production-adoption roadmap
│ └── example-app.md # worked examples: secret-app + env-app
├── bin/
│ └── shellflow/ # CLI + execution engine (I/O boundary)
│ ├── Cargo.toml
│ ├── assets/ # embedded bash helper + scaffolding templates
│ │ ├── cred-env.sh # the bash fallback for `cred-env`
│ │ └── service-*.tmpl # `service init` / `unit render` templates
│ ├── src/
│ │ ├── main.rs # clap entry, run loop, signals, final exit code
│ │ ├── cli.rs # clap derive: every subcommand and flag
│ │ ├── exit.rs # the CLI and target-helper exit-code protocols
│ │ ├── ui.rs # the one output sink: streaming, masking, audit
│ │ ├── fsx.rs # atomic 0600/0755 writes (helper assets)
│ │ ├── executor/ # the execution engine
│ │ │ ├── mod.rs # shared types, fan-out slot, capture-file guard
│ │ │ ├── error.rs # RunError / RunOutcome (the crate taxonomy)
│ │ │ ├── remote.rs # step dispatch, ssh streaming, login-shell probe
│ │ │ ├── local.rs # bash -c, guard, @export capture
│ │ │ ├── copy.rs # rsync + scp fallback, staged --delete swap
│ │ │ ├── host.rs # @service/@install/@backup/@credstore/@helper
│ │ │ ├── helper.rs # the systemd helper protocol (wait/reload)
│ │ │ ├── assert_step.rs # @assert and the --check probe
│ │ │ ├── build.rs # @build
│ │ │ ├── release.rs # @release
│ │ │ ├── toolchain.rs # @toolchain
│ │ │ ├── image.rs # @image
│ │ │ ├── muxy.rs # the muxy adapter (same payload shape)
│ │ │ ├── transport.rs # the one ssh argv / rsync `-e` builder
│ │ │ ├── cancel.rs # cooperative cancellation for the interrupt path
│ │ │ ├── process.rs # process groups, terminate, GroupGuard
│ │ │ └── stream.rs # piped-output forwarding, timeout, drain
│ │ ├── secrets.rs # @secrets resolution -> run-state env + masks
│ │ ├── secret.rs # `secret encrypt|decrypt|edit|creds|keys-check|find-duplicates`
│ │ ├── keys.rs # `keys generate|public`
│ │ ├── template.rs # `template check|render`
│ │ ├── cred_env.rs # `cred-env` (target-side helpers)
│ │ ├── host.rs # `host` (target-side helpers)
│ │ ├── systemd.rs # `systemd` (target-side helpers)
│ │ ├── systemd_dbus.rs # the optional D-Bus unit-state path
│ │ ├── service.rs # `service init`
│ │ ├── service_check/ # `service check` (audit + remote batch)
│ │ ├── service_diff.rs # `service diff`
│ │ ├── service_manifest.rs # `service manifest`
│ │ ├── service_status.rs # `service status`
│ │ ├── manifest.rs # the `services/<svc>/<unit>.service.toml` model
│ │ ├── unit.rs # drop-in rendering
│ │ ├── unit_cmd.rs # `unit render|manifest|verify`
│ │ ├── unit_verify.rs # the repository consistency check
│ │ ├── rbatch.rs # one round trip, many questions
│ │ ├── audit.rs # the secret-shaped-plaintext gate
│ │ ├── lint.rs # `--check` shell-hygiene warnings
│ │ ├── diffutil.rs # unified-diff rendering
│ │ ├── emit.rs # emit the embedded bash helper
│ │ └── preflight.rs # required-tool preflight
│ └── tests/
│ ├── integration.rs # end-to-end with mock ssh/rsync shims
│ ├── cred_env.rs # cred-env / emit / template
│ ├── service_init.rs # service init + secret keys-check
│ ├── no_panic_in_production.rs # the lint-gate guarantee
│ └── mockbin/ # fake ssh/rsync/scp (Bash shims recording argv)
└── crates/
├── shellflow-text/ # the shared shell-text rules (zero-dependency leaf)
│ └── src/lib.rs
├── shellflow-dsl/ # the grammar + the plan model (pure, no I/O)
│ └── src/{lib,parser,plan,heredoc,ssh_spec,error}.rs
├── shellflow-core/ # runtime helpers: env, capture, templating, masking
│ └── src/{lib,env,template,error}.rs
├── shellflow-muxy/ # the optional native transport
│ └── src/{lib,conn,lines,session,payload,policy,outcome,error,harness}.rs
└── shellflow-secrets/ # age wrapper (the only crate that links `age`)
└── src/{lib,identity,crypto,recipients,env,fsx,error}.rs
shellflow-text — the shared shell-text rules, the zero-dependency leaf.
- Owns
shell_quote_single,strip_surrounding_quotes, and the shell-identifier rule. The DSL (which reads directive payloads), the runtime (which renders payload headers), and the secrets crate (which reads sealed env files) all depend on it, so the rules cannot drift apart — and the secrets crate can share them without dragging in the plan model.
shellflow-dsl — the grammar, pure, synchronous, dependency-light.
- Owns the deploy-script language: the scanner, the plan model, the target
resolution, the ssh-destination model, and the errors the scanner reports.
It depends only on
shellflow-textandthiserror, so the grammar can be read, tested and fuzzed without the runtime — and the runtime cannot grow a second opinion about what a script means. shellflow-corere-exports its public API, so a consumer of the runtime helpers does not have to know which crate owns which half of the language.
shellflow-core — pure, synchronous, dependency-light.
- Re-exports the DSL plan model (
ExecutionPlan,Target,resolve_hosts,SshSpec,interpolate); the grammar itself lives inshellflow-dsl. - Renders the payload env header (escaping +
set -xordering), parses the NUL-delimited@exportcapture records (with an allow-list-checked variant for the executor), and masks known secret values — all pure string logic, unit- and proptest-able. - Re-exports the workspace-wide shell-text rules (
is_valid_var_name,strip_surrounding_quotes) for its own consumers, but owns none of them: they live inshellflow-text, whichshellflow-dslandshellflow-secretsdepend on directly. There is one definition of each rule, one authoritative import path (shellflow_text), andshellflow-coreis a façade, not a second home. (Keeping a re-export here is what lets a consumer of the runtime helpers avoid knowing which crate owns which half of the language — it must not become the place a rule is edited.) - Exposes
ParseError/InterpolateError/CaptureError/RenderEnvError(with line numbers where applicable) viathiserror. - No tokio, no stdio, no network, no filesystem access.
ExecutionPlan.envis copy-on-write (Arc<Vec<_>>), so snapshotting the env into every step does not make plan construction quadratic. Everything is unit- and proptest-able.
shellflow-secrets — age wrapper.
- Wraps the
agecrate (X25519 identities) for identity loading and generation, file encrypt/decrypt, and recipient read-back. - Resolves the identity path (
-i/$SHELLFLOW_AGE_IDENTITY/~/.config/age/keys.txt). An empty or unset$HOMEis treated as absent rather than as the current directory: the default location is then undefined, and resolving it to a relative path would adopt a planted./.config/age/keys.txtas the operator's identity. The resolver fails closed with a clear message instead. A whitespace-only$SHELLFLOW_AGE_IDENTITYis treated as unset. - Parses
KEY=VALUEenv files (blank lines and full-line#comments ignored; first=splits the key; a repeated key keeps its last value and appears once, matchingsourcesemantics; single-spaceexportprefix tolerated). - Library-only: no
tokio, nostd::process. It does perform its own file I/O (identity load, env-file read), which is exactly its stated job (§S7); it has no network or subprocess surface.nixis used for two facts the identity loader cannot get fromstdon stable Rust: the effective uid (ownership is checked, not only the mode) andO_NOFOLLOW(a symlink planted at the identity path is refused rather than followed).fsxowns the atomic-write primitives (staged temp file + rename forwrite_atomic, hard-link forwrite_new_file, parent-directoryfsyncfor both) that every secret-bearing file uses. shellflow-corestays free of async, I/O, and crypto.
shellflow-muxy — the optional native transport (the muxy feature).
- One connection per host: ssh carries the muxy bridge, a pump thread is the
only consumer of the client's events, and a step's output streams as
complete lines over the session. Documented in
docs/muxy-transport.md; the ssh transport covers every step when the feature is off.
bin/shellflow — the I/O boundary.
- clap parsing and script loading.
- The execution engine: spawns
bash,ssh,rsyncviatokio::process::Command; fans out to hosts withtokio::task::JoinSet; streams prefixed output. executor::transportis the one builder for everysshinvocation and for the-etransport stringrsyncgets:BatchMode=yes,-T, the connect bound andControlMasterare decided once, instead of at each of the eight call sites (which is how two of them lost-Tand three used three different timeouts).exit.rsowns both exit-code protocols: the CLI's own (§6) and the target-side helper's (thesystemd/hostverbs, which report on a unit or a file and are a wire contract with the payloads).eyrefor application errors. There is no logging framework: therunpath owns one output path (ui), which streams user-facing lines, applies masking, and writes the audit log. The report subcommands render terminal output directly, and each printing module declares that with a scoped#[expect(clippy::print_stdout, …, reason = "…")]inmain.rs, so a new print outside those modules fails the lint instead of bypassing the masker.
Added via cargo add only (never hand-edited into Cargo.toml):
# workspace level
cargo add eyre tokio --workspace # already present
cargo add colored --workspace # ANSI UI in bin
cargo add age tempfile --workspace # secrets crate
# bin/shellflow
cargo add clap tokio eyre colored -p shellflow --workspace
cargo add shellflow-core shellflow-secrets -p shellflow --path crates/...
# crates/shellflow-core
cargo add thiserror -p shellflow-core --workspace
cargo add proptest -p shellflow-core --workspace --dev
# crates/shellflow-secrets
cargo add age -p shellflow-secrets --workspace
cargo add thiserror -p shellflow-secrets --workspace
cargo add proptest -p shellflow-secrets --workspace --dev
cargo add tempfile -p shellflow-secrets --workspace --dev| Crate | Where | Why |
|---|---|---|
clap (derive) |
bin | CLI parsing |
tokio |
bin | async process supervision, JoinSet |
eyre |
bin | application error handling (per AGENTS.md) |
thiserror |
text/dsl/core/secrets/muxy | library error types (per AGENTS.md) |
colored |
bin | host-prefix and step-header ANSI styling |
age |
secrets | embedded X25519 encryption (no rage binary) |
zeroize |
bin, secrets | in-memory scrubbing of plaintext secrets |
nix |
bin | process groups, signals, user/fs handling |
sd-notify |
bin | the systemd readiness protocol (Type=notify supervision) |
sha2 |
bin | credential-store fingerprints (cred-env) |
serde_json |
bin | structured outputs (state lines, summaries) |
toml_edit |
bin | service manifests and rendered TOML templates |
shellflow-text |
dsl/core/secrets | the shared shell-text rules (the zero-dependency leaf) |
shellflow-dsl |
bin, core | local path dependency (the grammar + the plan model) |
shellflow-core |
bin, secrets | local path dependency (runtime helpers; re-exports the DSL) |
shellflow-secrets |
bin | local path dependency (crypto + env parser + recipients) |
shellflow-muxy |
bin (muxy feature) |
local path dependency (optional native transport; see muxy-transport.md) |
muxy-client / muxy-protocol |
muxy (muxy feature) |
third-party beta, pinned to an exact rev (the supply-chain boundary) |
zbus (optional) |
bin (dbus feature) |
event-driven systemd unit state over D-Bus; the CLI polls without it |
proptest (dev) |
dsl/core/secrets | property tests in the normal test loop |
tempfile (dev) |
secrets, bin | temp-file fixtures in tests |
Explicitly not used: anyhow (forbidden), russh/libssh2/ssh2 (we wrap
system ssh), futures (unnecessary — JoinSet covers fan-out), scc
(no shared concurrent maps in this design), winnow/pest (see D-3),
reqwest/dashmap (forbidden by AGENTS.md).
bin/shellflow/src/host.rs is a dispatch table plus the shared vocabulary
(HostError, path/unit/digest helpers); the verbs live in submodules, one
responsibility each:
| Module | Responsibility |
|---|---|
host/probe.rs |
read-only state: systemd-analyze verify, systemctl show parsing, the readiness verdict, the unit inventory |
host/lifecycle.rs |
verify → reload → enable → restart → wait, in that order |
host/install.rs |
the atomic install (write beside, then rename) and digest assertions |
host/backup.rs |
snapshots: write, seal, prune, shred |
host/credstore.rs |
the credential store layout, its keys, the value-based leak check |
host/privileged.rs |
sudo -n and the elevated filesystem/systemctl primitives |
HostError is an enum keyed on the reason (Usage, Internal, Failed,
TimedOut, Delegated) rather than a { message, code } pair, so a caller can
tell "the operator asked for something impossible" from "the condition did not
hold" without parsing text. exit_code() is the wire contract with the
controller (1 / 1 / 3 / 4 / the delegated code).
executor::Native is the only place the muxy feature forks the
executors. Every step struct carries a Native unconditionally — the probed
login environment, the per-host connection cache and the plan index — so the
shape of a step does not depend on the build. What remains behind
#[cfg(feature = "muxy")] is the decision itself
(transport::should_use_muxy, one function) and the attempt it guards; a
build without the feature answers false for every class and carries an empty
Native. --transport muxy then fails closed at the CLI with exit 3 instead
of silently falling back.
USAGE: shellflow [OPTIONS] [SCRIPT]
ARGS:
<SCRIPT> Deploy script path [default: deploy.sh]
OPTIONS:
-v, --verbose... Increase verbosity (-v, -vv, -vvv) [see §8.1]
-n, --dry-run Simulate; no writes. Syntax-checks payloads.
-d, --diff Show itemized file changes; implies no writes.
-t, --target <TARGET> Restrict to these servers/groups (comma-separated)
-o, --only <STEP> Run only matching blocks (by name or 1-based index)
-s, --skip <STEP> Skip matching blocks (repeatable)
-p, --parallel <N> Max concurrent hosts per step [default: 16; 0 = all]
-c, --continue-on-error Continue after a failed host/step; print summary
-k, --check Syntax-check only: local `bash -n`, remote
`ssh host bash -n -s`; no execution
--timeout <SECS> Per-step timeout for all steps [default: none;
@timeout overrides it per step]
--output <MODE> stream (default) | grouped — print each host's
logs as one block when that host finishes
--mask-min-len <N> Minimum @secrets value length that is masked;
defaults to $SHELLFLOW_MASK_MIN_LEN, else 6
-l, --log-file <PATH> Append every streamed line (tagged host+stream)
to a file for audit (run path only)
--no-color Disable ANSI colors
--local Run remote blocks/copies on the controller
(debugging; no network or sudo required)
-i, --identity <PATH> age identity for `@secrets` decryption
--no-ssh-reuse Do not reuse SSH connections within a run
(every step opens its own connection)
-h, --help Print help
-V, --version Print version
SUBCOMMANDS (the bare positional `shellflow deploy.sh` == `shellflow run`):
run [SCRIPT] [flags] run a deploy script (the default)
keys generate | public manage age identities
secret encrypt|decrypt|edit|creds|keys-check|find-duplicates
template check <tmpl> <file.env.age> cross-check ${KEY} against a sealed env
template render <tmpl> <file.env.age> [--format toml|json|none]
emit cred-env [-o PATH] print the bash helper (fallback targets;
covers exec/render only)
cred-env exec|render|credstore-install target-side helpers (see §S12)
host service|install|backup|credstore|assert-no-leak|assert-digest|
assert-credstore|inventory target-side host helpers (§S12.1)
systemd notify|wait|reload|status|watch|transient|exec|watchdog
unit render|manifest|verify service manifest and drop-in gates
service init|check|diff|manifest|status repository scaffolding and fleet gates
audit [PATH] scan the repo for secret-shaped plaintext
unit render <svc> <unit> [--repo PATH] generate the unit's drop-in from
[--output PATH] [--check] its manifest
unit manifest <svc> <unit> [--repo PATH] print the manifest
unit verify [--repo PATH] check the repository's consistency
systemd notify|wait|reload|status|watch|transient|exec|watchdog
target-side systemd helpers (§S12.1)
service init <svc> [--host <alias>] repository scaffolding (see §S12.2)
EXIT CODES:
0 success (skipped blocks and dry-runs with no errors)
1 plan/parse error (bad script, unresolved @export variable, an
unresolvable target, a syntax error found by a dry-run/check)
2 CLI usage error (clap)
3 transport/setup failure (missing ssh/rsync/bash, ssh exit 255, a broken
payload pipe, rsync I/O error)
4 script execution failure (a local/remote step exited non-zero)
5 a step exceeded --timeout/@timeout (any class: remote, local, copy,
guard, or the target helper's own budget)
130 interrupted by SIGINT/SIGTERM (see §7.5)
4 versus 5 matters to a CI retry policy: "the deploy broke" is a rollback,
"the box was slow" is a retry. 1 versus 4 keeps "the playbook is wrong, nothing
was attempted" separate from "the deployment failed". `exit.rs` is the single
definition of these values.
42 is reserved inside a guarded remote payload as the guard's "skip"
channel; it never becomes the process exit code. The payload installs an
`EXIT` trap that remaps a block's own `exit 42` to 1, and the guard disables
the trap before it exits 42. A block without a guard is never interpreted as
a skip.
`--log-file` belongs to `run`, not to the global option set: the maintenance
subcommands produce no streamed lines and now reject the flag instead of
accepting it and silently writing nothing.
The distinction between 1 and 4 is deliberate: 1 means "the playbook is
wrong" (nothing was attempted), 4 means "the deployment failed". A CI gate
can therefore tell the two apart without parsing the output.
**Two protocols, deliberately separate.** The `systemd` and `host` subcommands
run *on the target* and report on a unit or a file, so their codes are a wire
contract with the payloads that call them (`remote::wait_command`,
`remote::reload_command`, `executor::host`): there, `3` means "the condition
did not hold" and `5` means "the unit does not exist". They are not the CLI's
codes, and `exit.rs` keeps them in a separate module so a literal cannot drift
between the two.
The subcommands use the same classes: a failure that means "the request could
not even be attempted" — a missing `ssh` in `service check`, an unreachable
host — is a transport/setup failure (3), and everything else is 1. `service
check` therefore agrees with the `run` path about the same missing tool
instead of reporting it as a generic error.
Example invocations:
shellflow deploy.sh # real run
shellflow -vv deploy.sh # show every command + payload
shellflow -vvv deploy.sh # set -ex + ssh -v live trace
shellflow --dry-run --diff deploy.sh # preview changes, change nothing
shellflow -t web1 deploy.sh # deploy one host from group `web`
shellflow -k deploy.sh # syntax-check everythingA deploy repository repeats the same facts in three places: the systemd unit, the playbook, and the operator's memory. The unit declares the credential keys, the render template, and the binary; the playbook repeats the service name and the host.
The service manifest (services/<svc>/<unit>.service.toml) states them
once:
host = "api"
unit = "dufs.service"
binary = "/opt/dufs/bin/dufs"
secret_file = "services/dufs/{host}.env.age"
credentials = ["DUFS_AUTH_PASSWORD"]
ports = [5000]
user = "akagi201"
runtime_dir = "dufs"
[[render]]
template = "/opt/dufs/etc/config.yaml.tmpl"
target = "/run/dufs/config.yaml"shellflow unit render <svc> <unit> derives the unit's drop-in from it: the
LoadCredentialEncrypted= wiring, the ExecStartPre=… cred-env render …
line, and the ExecStartPost=… systemd-notify --status=… line are identical
in shape across a repository, so they are generated rather than copied —
and the shared hardening drop-in rides along, which makes the hardening a
default rather than an opt-in.
The manifest is also the fleet map: shellflow service status --all reads the
host of every service from the manifests instead of parsing playbook text, and
service check uses the same source of truth.
service verify (unit_verify) is the repository-side checker: it reads the
manifests, drop-ins, and playbook facts and reports what would not match the
target. It is a sibling of service check (which probes a live host).
A deploy repository has parts that are generated (the unit drop-ins) and parts that are written (the playbooks, the manifests). The generated parts drift away from the sources they come from, and the drift is silent.
shellflow unit verify answers, statically:
- Is every generated drop-in up to date? The drop-in is re-rendered from the manifest and compared with the file on disk.
- Does every
@installsource exist? The install steps are read out of the playbook with the tool's own parser, so the check sees exactly what a deploy would do, and every source is looked up in the repository. - Is every generated drop-in installed? A service whose drop-in is never installed would run with the unit's own (stale) wiring.
- Does the shared hardening reach every service that takes it? The
hardening is a default, not an opt-in; a unit that cannot take it says so
in its manifest (
hardening = false). - Does every backup run before the install it protects? The directives
execute in source order, so a
# @backupline that sits below the# @installlines snapshots the new file: the rollback copy would restore what was just deployed. The check compares the plan's own step order.
The check is deliberately offline. When it cannot read a sealed credential file (no identity is available), the affected drop-in is reported as skipped in both the per-check output and the summary, and the summary never calls the repository consistent without naming the skipped count. A skipped check is not a pass.
for each step (sequentially):
resolve hosts (respecting --target restriction)
print header [STEP n/m] <name> <kind> -> <target>
# n is the step's 1-based index in the unfiltered plan (the number
# --only/--skip accept); m is that plan's step count, even when a
# filter runs only some of them
if payload display requested (-vv, dry-run, diff): print script preview
match step:
Local -> run_local(step)
Copy -> fan_out(run_copy, hosts, --parallel)
Remote -> fan_out(run_remote, hosts, --parallel)
if fail-fast and any host failed: abort remaining steps (exit 3/4)
print summary (elapsed per step, per-host status)
// Simplified: real implementation uses two reader tasks + one channel (see 7.4).
let mut child = Command::new("ssh")
.args(spec.to_ssh_args()) // ["-p", "2222", "deploy@10.0.0.2"]
.arg("-T")
.arg("-v") // only when verbose >= 3
.arg(if syntax_only { "bash -n -s" } else { env.shell.invoke(false) })
// ^ real runs use the host's login
// shell, e.g. `bash -l -s` / `zsh -l -s`
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
// Payload layout — env lines always precede tracing so exports are never
// traced:
// set -eu
// export PATH='***' <- probed login PATH (from the rc file)
// export KEY='***' <- env block (from @env/@export state)
// set -x <- only at -vvv, AFTER the env block
// <user script>
let payload = format!(
"set -eu\n{env_lines}{trace_line}{script}",
env_lines = render_env(&env_with_login_path), // single-quote-escaped values
trace_line = if verbose >= 3 { "set -x\n" } else { "" },
);
// 1. Write payload, then DROP stdin so remote bash/zsh sees EOF.
child.stdin.take().unwrap().write_all(payload.as_bytes()).await?;
// 2. Concurrently read stdout (white) and stderr (red / yellow at -vvv),
// prefixing every line with [host]; known env values are masked (see §9).
// 3. wait(); map non-zero exit to eyre error with host context. ssh exit 255
// is classified as a transport error (exit 3), other non-zero as script
// failure (exit 4) — see §6.Key points:
- One connection per host per remote block — the payload is streamed from memory, not from a temp file. This is the Mina win.
- Login-shell parity: before the first remote block of a run, each host is
probed once (cached per run) with a script that prints the login shell
(
$SHELL) and the PATH an interactive login would see — the rc file (.zshrc/.bashrc) is sourced inside the probe shell only, where toolchain managers (proto/nvm/rustup/mise) export their PATH entries. Payloads then stream over<shell> -l -s(bash -l -s/zsh -l -s) with that PATH injected into the env block. The rc file is never sourced inside the payload: interactive configs (oh-my-zsh hooks, aliases, prompts) breakset -euin scripts. Unsupported shells (fish/nushell), unreachable hosts, and probe failures fall back to plainbash -l -swith no injected PATH, and--check/--dry-runstay onbash -n -s(no probe, no login env). An explicit@env PATH=…/@export PATHalways wins over the probe.The probe's completion marker, not its exit status, is the success criterion. The probe also reports whether the target has
rsync, and a host withoutrsyncmakes that check fail — so if the exit status were the criterion, the whole probe result (the login shell and the rc-extended PATH) would be discarded on exactly the minimal hosts that need it most. Each field degrades independently instead. set -euby default (crash on unset vars and errors);-vvvupgrades toset -exfor live tracing.@env/captured variables are injected as aexport KEY='value';block at the top of the payload (single-quote-escaped), always before anyset -xtracing, and the values are masked in previews, trace output, and log files (§9). Local steps receive them viaCommand::env()instead of text injection.- Fail-fast by default: a non-zero remote exit aborts the remaining steps.
--continue-on-errorcollects per-host results and prints a final table.
let mut cmd = Command::new("rsync");
cmd.args(["-a", "-z"]); // archive + compress
if delete { cmd.arg("--delete"); } // optional flag: @copy … --delete
if dry_run || diff { cmd.args(["--dry-run", "-i"]); }
if let Some(port) = spec.port { cmd.args(["-e", &format!("ssh -p {port}")]); }
cmd.arg(src).arg(format!("{}:{dst}", spec.to_dest())); // user@host:pathrsync --dry-run -i computes the real local↔remote delta and itemizes it
(>f++++++++ new file, f..t...... timestamp-only, *deleting removal) —
this is our diff engine (pyinfra feature #2).
Target directory creation & no-rsync fallback. Whether the target has
rsync is discovered once per host per run during the login-environment
probe (a single ssh round trip that also yields the login shell and PATH;
see §7.2). The result is cached in the per-run ShellCache and reused for
every copy step — no extra probe connection. When rsync is present on the
target, the transfer runs under --rsync-path "mkdir -p <parent-dir> && rsync" so a file-style destination's parent directory is created
automatically (matching rsync's own dir/ semantics). When rsync is missing
on the target, @copy falls back to ssh mkdir -p <parent-dir> + scp src host:<parent-dir>/, keeping the design promise that targets only need bash
(§9). This preserves the delta/idempotency properties in the common case and
degrades gracefully to a plain copy on minimal hosts.
The fallback cannot mirror deletions or filter files, so a copy step that
asked for --delete or --exclude fails closed on such a host (exit 1)
instead of performing a materially different transfer than the playbook
requested — an excluded file would land on the target and stale files would
survive, while the step reported success. --allow-unfiltered-copy is the
explicit opt-out; the downgrade is then reported in the step output.
Dry-run still probes the target — deliberately. §7.2's "no probe, no login
env" rule governs remote blocks: in --check/--dry-run they stay on
bash -n -s and never open a second connection. A copy step is different: the
whole point of rsync --dry-run -i is to compute the real local↔remote
delta, so the target must be reached anyway, and the rsync-or-scp decision
has to be made before the transfer starts. The probe is therefore the
mechanism that answers that question (§7.3), and it is cached per host per
run, so a playbook with several copy steps still opens one connection per
host. --check is the mode that stays entirely offline: it skips the copy
step altogether rather than touching the target.
- Each step fans out over hosts with
tokio::task::JoinSet, bounded by--parallel Nviatokio::sync::Semaphore(default: 16;0= unbounded). - Correctness fix over the naive sketch: the common
tokio::select!over two infinite read-loops is wrong — when one stream hits EOF theselect!returns and the other reader is dropped, silently truncating stderr/stdout. Instead, per host we spawn two reader tasks (stdout, stderr) that push lines into a smallmpsc, and a single consumer prints[host] linein order. Both streams are drained to EOF. - Output is line-buffered and flushed immediately;
--no-colorstrips ANSI. - Output modes (
--output):stream(default) renders every line as it arrives;groupedbuffers each host's lines and prints them as a single[host]block when that host finishes, keeping high-fanout logs readable. - Audit trail (
--log-file): every streamed line (tagged[host]plus stream marker) is appended regardless of UI mode; masking applies to the file too (§9). -vvprints the exact argv of every spawned process (viaDebug), so users can copy-paste the command to reproduce issues.
- Preflight. Before running anything, shellflow verifies that
bash,ssh,rsync, andscpexist onPATHand are executable, and fails with a friendly message (exit 3) if any is missing — no mid-runCommand::newsurprise. (scpis required because@copyfalls back to it on rsync-less targets; see §7.3.) Each probe is bounded, so a PATH-shadowed wrapper that never exits cannot hang the CLI before the timeout machinery exists. - Process groups. Every child is spawned with
process_group(0), so it leads a fresh process group. Signalling only the direct child is not enough: a block runsbash -c, whose own descendants (cargo->rustc, asleep 300 &) would survive. Two mechanisms cover the whole tree:terminate()— on a timeout or transport failure:SIGTERMthe group, wait up to a 5s grace period for the direct child, thenSIGKILLthe group unconditionally (a grandchild that trapsSIGTERMmust not outlive the step just because its parent exited promptly).GroupGuard— an RAII guard dropped on every exit path, including the task cancellation caused by SIGINT, whichSIGKILLs the group unless it was disarmed after a clean reap. Killing the group also closes the inherited stdout/stderr pipe ends, so an orphan can no longer keep the log stream open after a step has finished.
- Timeouts.
--timeout <secs>applies globally;@timeoutoverrides per step. Each host task wrapschild.wait()intokio::time::timeout; on expiry it tears the group down as above and records the host asTIMEOUT(a failure: abort or continue per--continue-on-error). - Signals.
tokio::signalinstalls SIGINT/SIGTERM handlers. On receipt, the run is cancelled cooperatively: every in-flight host task observes the signal, terminates its own process group (SIGTERM, a grace period, thenSIGKILL), and returns normally; the executor is then awaited to completion before the process exits. shellflow prints a summary of completed/failed/pending steps and exits 130. No orphan processes survive.Cancelling by dropping the executor's future is not enough: the host tasks are aborted asynchronously, so their process-group guards may never run before the process exits — which is how an orphaned
sshsurvives a Ctrl-C. Cooperative cancellation makes the teardown a precondition of the exit, not a race against it. - Preflight probes are isolated too. The preflight probe leads its own process group like every other child, so a PATH-shadowed wrapper that backgrounds helpers cannot leave them behind when the probe is killed.
- A backgrounded descendant cannot hang a step. A block that backgrounds
a process (
nohup svc &) leaves the descendant holding the step's output pipe. The drain of the reader tasks is therefore bounded: a group that still holds the pipe past the grace period is killed rather than hanging the run, and--timeoutstays a real bound on the step.
Local steps run as separate processes, so cd and shell variables do not
survive a step boundary (remote blocks are fresh SSH sessions by design).
Rather than a long-lived interactive shell — which silently breaks step
isolation and error semantics — state is passed explicitly (Decision Record
D-7):
- When a local step declares
@export VAR, shellflow appends a capture line that writes the values to a private temp file as NUL-delimited records (NAME=VALUE\0); the temp path is passed viaCommand::env, so it never appears on stdout. NUL is used because a shell value may legally contain a newline: a newline-delimited format would let one exported value forge another and would silently truncate a legitimately multi-line value. The reader is the pureshellflow_core::parse_capture_records. - After the step, shellflow reads the file into
RunState. Scripts that callexitbefore the capture line cannot export — documented, acceptable. The capture file is owned by an RAII guard, so it is deleted on every exit path, including a failed step: it holds plaintext and must not outlive the step, least of all on a shared CI runner. - Later
@copysrc/dststrings are interpolated fromRunState($VAR/${VAR}); unresolved names are a plan-build error. - Later local steps receive the values via
Command::env(); later remote blocks receive them in the env header (masked, §9). - Every captured value joins the mask registry unless the playbook
declared it
--public. The decision is a property of the declaration and of the value's length (--mask-min-len), never of the variable's name: the ordinary name for a credential a block just fetched from a vault isDATABASE_URL, so a name heuristic ("looks secret") reads as a safety property while being a guess.--publicis the explicit opt-out for a value that is meant to be read, and it is visible in the playbook next to the declaration, where a reviewer sees it.
This makes versioned artifact paths work end-to-end:
# @local
VERSION=$(git rev-parse --short HEAD)
# @export VERSION --public
# @copy target/release/myapp-$VERSION -> /srv/myapp-$VERSION @web| Level | Shows |
|---|---|
| default | Step headers ([STEP 1/4] build · local), live [web1] … prefixed output, per-step elapsed time, final summary |
-v |
Plan preview (servers, groups, step list), rsync transfer lines, host status per step |
-vv |
Exact ssh/rsync/bash argv, full payload script previews, guard (@only_if) evaluations |
-vvv |
Everything above plus set -ex injected into every payload and ssh -v on the wire; Bash trace lines (+ echo …) arrive on stderr colored yellow — an OS-level realtime stdin/stdout/stderr trace with zero extra machinery |
Sample -vvv output:
[STEP 3/4] restart · remote -> web
|--- Remote Payload (web) ---
| set -eu
| echo "==> restarting myapp on $(hostname)"
| sudo systemctl restart myapp
|-----------------------------
[web1] + echo '==> restarting myapp on web1'
[web1] ==> restarting myapp on web1
[web1] + sudo systemctl restart myapp
[web2] + echo '==> restarting myapp on web2'
[web2] ==> restarting myapp on web2
[web2] + sudo systemctl restart myapp
✔ Step 3/4 done in 1.24s (web1 ✓, web2 ✓)
| Mode | Local blocks | Remote blocks | Copy steps |
|---|---|---|---|
--dry-run |
print payload; run bash -n locally |
print payload; run ssh host bash -n -s (syntax only) |
rsync -avzn --delete -i — real delta, nothing written |
--diff |
same as dry-run | same as dry-run | dry-run itemize filtered to changed items only, color-coded by symbol |
Nothing is written in either mode; both exit 1 if any bash -n finds a
syntax error. This is the pyinfra "prove it before you change it" loop, achieved
entirely with rsync/bash primitives (see D-4).
A copy that cannot be previewed is skipped, never performed. rsync --dry-run is what produces the delta; the scp fallback (a target without
rsync, §7.3) and the local cp have no dry-run mode at all — previewing
them would mean performing them. So on such a host the copy step is skipped
with a warning and the run reports the host as SKIPPED: a "dry run" that
wrote to the target would be the most dangerous flag in the tool. The
--delete/--exclude fail-closed rule (§7.3) applies to dry-runs too, so a
playbook that asks for a filtered copy still refuses to run against a target
that cannot honour it.
State in dry-run. Local steps are not executed in dry-run, so
@exported variables are unknown there. A @copy path referencing an
unresolved variable prints a warning and is skipped for itemization instead of
guessing a path.
Guards are not evaluated in dry-run. An @only_if guard is a command
like any other, and it runs with the block's environment — so evaluating it
could change the target. In --dry-run/--check the guard is reported
rather than run. The same rule applies to a remote block resolved on the
controller with --local.
Three cooperating layers, deliberately not a state engine:
- Transport idempotency —
rsynctransfers only deltas; re-running a copy step is a no-op when content matches. - Guard idempotency —
@only_ifblocks are skipped where the precondition already holds; skipped blocks printSKIPPED (guard)and count as success, so repeated runs converge. The skip travels through exit code 42, which is reserved inside a guarded payload only: the guard clears the payload'sEXITtrap before using it, and the trap remaps a block's ownexit 42to 1. A remote block without a guard therefore cannot be mistaken for a skip. - Author idempotency — plain Bash (
[ -f ] ||,command -v,systemctl is-active) for everything the tool can't infer.set -euand pre-runbash -nmake partial failures from typos impossible.
- Inherit the user's SSH trust: keys,
~/.ssh/config,ssh-agent, jump hosts, YubiKey — all handled by the systemssh. We never touch credentials. - No interactive prompts: we use
ssh -T(no TTY) and-o BatchMode=yeson every ssh invocation the tool makes. A transfer tool that starts its own ssh is given the same options (rsync through-e, scp directly), so a copy cannot stop at a password or host-key prompt in the middle of a run. The secrets payload travels over that channel, so an unknown host key or a missing key must fail closed rather than raise a prompt an operator may approve for a rogue key. Host-key policy stays with the user's~/.ssh/config— the tool does not addaccept-neworno, which would auto-trust an unseen key. Provision a first connection explicitly:ssh-keyscan <host> >> ~/.ssh/known_hosts.sudoin scripts requires passwordless sudo or a pre-provisioned key. This is documented behavior, not a bug. - Remote requirement: only
bashon the target;rsync/sshneeded only on the controller. Remote blocks additionally run under the host's login shell ($SHELLprobed once per run,bash/zshsupported; anything else falls back to bash) with the rc-extended login PATH injected into each payload, mirroring a manualssh host— see §7.2. - Secrets in
@env/@export. Values never appear in any process argv. For local steps they are passed viaCommand::env(); for remote steps they exist only inside the payload streamed over stdin, placed before anyset -xso the export lines themselves are never traced (§7.2). Known values are masked (***) in payload previews, trace output, and--log-file. The audit log is created0600(and re-tightened when an existing file is appended to); a log that cannot be opened is a hard error, not a warning — a run that was asked to keep an audit trail and silently keeps none is worse than a run that refuses to start.Masking is applied by the sink and by the call site. The sink (
print_linefor control lines,Ui::emit_host_linefor host output) is the guarantee: every byte that reaches the terminal or the audit log passes through the redactor, so a secret cannot leak because a call site forgot to ask for masking — and a render function that forgets cannot reintroduce the gap the wayplan_preview's step line once did. The call sites also redact, before they add ANSI colour: a below-threshold value is only redacted where it stands alone as a token, and an escape sequence next to it would look like an identifier character and hide the value from the narrow rules. The two passes cannot unmask anything. The process-wide mask registry is what lets the free render functions — which have noUihandle — redact exactly like theUimethods do.Redaction is stateful across output lines: a value that a target program split across two lines (
fold, a pager, a progress bar) is redacted as a whole, because the two fragments would otherwise reassemble into the original in the scrollback or in the audit trail. The payload preview uses the same stateful masker, so a value split across two lines of a block body is redacted there too. A prefix at the tail of a line is always remembered as a pending continuation, even when another range already covers those bytes, so the rest of the value cannot slip through on the next line. Every occurrence of a value is masked — a value that overlaps itself is masked whole, exactly like the stateless path. Values below the masking threshold cannot use a global substring replace (it would shred unrelated output), so they are redacted only where they stand alone as a token or appear asKEY=value; the run names the affected keys so the exemption is never silent. Residual caveat: redaction is a plaintext match, so a base64/hex rendering of a value is never redacted. - Decrypted values in memory. The plaintext buffer is zeroized when it is
dropped, and the resolved values are scrubbed when the run ends. The
residual is explicit: the values are also cloned into the run-state env map,
the mask registry, and the child process environment, where the same
UID can read them through
/proc/<pid>/environ; they are notmlock'd and may reach swap. A same-UID or root attacker on the controller can read the0600age identity directly and decrypt every sealed file anyway, so this is a defense-in-depth property, not a boundary. - The age identity must be owner-only.
keys generatewrites0600, and the loader enforces it: a group- or world-readable identity is refused (the identity opens every sealed env file the operator owns).SHELLFLOW_ALLOW_UNSAFE_IDENTITY=1is the explicit opt-out. - Trust boundary: the deploy script is code with the controller user's privileges. Users should review scripts as they would any shell code.
- No
unsafeanywhere;unwrap_used/expect_used/panicare denied by the workspace lints.
Example-based unit tests (colocated #[cfg(test)]):
- directive recognition incl. whitespace tolerance and CRLF stripping;
@server/@group/@env/@copypayload parsing (incl.rsplit_once('@')for copy targets and port-bearing specs);- block flushing and mode switching; dropping comment-only blocks;
@only_if,@timeout, and@nameattachment to the following block;@exportattachment to the preceding local block; error after a remote block;@copy --deleteparsing and$VAR/${VAR}interpolation fromRunState; unresolved variables are errors, not silent literals;- the NUL-delimited
@exportcapture protocol: a value containing a newline or=round-trips intact and can never be read as a second record; - env-block rendering: single-quote escaping, ordering before
set -x, masking of known values; mask ordering is normalized once per run (prepare_masks), not once per output line; - the stateful masker: a value split across lines is redacted as a whole (two and three lines, a split at a line end, a split at the first character), the text around a masked value survives, every occurrence in a line is masked (a value that overlaps itself is masked whole, like the stateless path), and the longest secret wins a tie — the mask set is ordered longest-first and then by value, so the ordering (and every tie it breaks) is the same on every run and on every host;
resolve_hosts: alias / group / raw spec /--targetrestriction;SshSpec:user@host:port→["-p", "2222", "user@host"]and rsync dest;- here-documents: quoted/escaped/plain/
<<-delimiters,<<<here-strings, quoted regions and trailing comments not opening one, multiple openers on one line drained FIFO, tab-only terminator indentation, CRLF, unterminated bodies, and the property that a body line never splits the plan.
proptest properties (in the ordinary cargo test path):
- Reparse idempotence: any non-directive script body, when wrapped in a generated sequence of directives, re-parses to the same step payloads (modulo trailing newline);
- Block separation: generated directive/boundary sequences never produce a
step whose content bleeds across a
@copy/@remoteboundary; - Determinism:
resolve_hostsordering is stable for a fixed plan.
End-to-end tests with mock ssh/rsync shims placed earlier on PATH
(tests/mockbin/), which record argv to a log file and behave deterministically:
- execution order and per-host fan-out (parallelism observable in logs);
- payload integrity: the exact script sent over stdin (mock
sshwrites stdin to a file); -vvvinjectsset -exand addsssh -v;--dry-runsendsbash -n -sandrsync --dry-run -i; nothing written — including through thescpfallback, which has no dry-run mode and is therefore skipped rather than performed;- fail-fast aborts remaining steps vs
--continue-on-errorsummary; --target,--only,--skip, exit codes,--no-color;- timeout: a sleeping mock
sshis SIGTERM'd and reportedTIMEOUT; - SIGINT: an in-flight mock
sshis killed and shellflow exits 130, and the interrupt summary is recorded in the--log-fileaudit trail; --output groupedemits per-host blocks;--log-filecontains tagged lines;- the plan preview and the step headers share one numbering space (the
unfiltered plan index, which is also what
--only/--skipaccept), also when a filter runs only some of the plan; - preflight: PATH without
sshfails fast with exit 3 and a friendly message; theservice checksubcommand reports the same failure class the same way; -vvvwith an@envsecret: the mock's echoed payload shows the value masked as***, including in the localbash -cpreview;- a
# @remoteline inside a here-document body: nosshinvocation at all, while a directive after the terminator still runs; @secrets --identitydecrypts with the pinned key and no run-wide-i;SHELLFLOW_MASK_MIN_LENand--mask-min-lenboth gate short-value masking;- preflight fails when
scpis missing even thoughbash/ssh/rsyncexist; - a step with no hosts renders
—, and--onlykeeps header and summary numbering in sync; - process-group teardown: a background grandchild of a timed-out local block
is gone afterwards, and a child that traps
SIGTERMisSIGKILLed. - failure-path hygiene: a failed
@exportstep leaves no capture file behind; an exported value cannot forge a sibling variable; a broken ssh payload pipe is classified as a transport error (exit 3), never a timeout; a failed--local --deletecopy leaves the previous destination intact and cleans up its staging directory.
These tests require local bash, ssh-less (mock shims), and no network.
just format
just lint # typos, rumdl, cargo sort, fmt, clippy -D warnings, cargo shear
just test # cargo test --all-features (incl. proptest)
just mutation # cargo-mutants; fix survivors
just check-cn # no CJK in code/comments (docs/comments are English-only)just lint runs clippy --workspace --all-targets --all-features, so test
code is linted too. The deny lints (panic, unwrap_used, expect_used)
and the pedantic/nursery groups are scoped to production code with an
explicit cfg_attr(test, allow(...)) at each crate root: the constraint is
stated where it holds instead of being satisfied by silently skipping test
targets.
Fuzz (cargo-fuzz) and Criterion benchmarks: N/A for v1 — the grammar is a
fixed line scanner and the workload is I/O-bound. Revisit if the DSL grows
expression evaluation or a documented latency SLA appears.
- Parser is a single O(n) scan; plan construction is negligible.
- Mina-style single-connection streaming avoids per-command SSH overhead.
rsyncdelta minimizes bytes on the wire.JoinSet+ semaphore give bounded, real multi-host concurrency.- Release profile (
lto = "fat",codegen-units = 1,strip = "symbols") yields a small static binary with no runtime dependencies. kacherustc wrapper (already in.cargo/config.toml) keeps incremental rebuilds fast.
The engine spends SSH round trips, so they are counted explicitly:
| Operation | Round trips |
|---|---|
| Remote block | 1 |
Remote block with @only_if |
1 — the guard is folded into the payload and reports "skip" through a dedicated exit code, instead of costing a second connection |
| Copy | 1 probe per host per run (cached) + 1 rsync per transfer group |
@assert group |
1 (one framed batch) |
@service, @install, @backup, @credstore, @unit_inventory |
1 each |
@wait_active |
1 (the whole poll loop runs on the target) |
service check |
1 (eight queries batched) |
service status |
1 per host, concurrently |
--ssh-reuse is on by default (--no-ssh-reuse turns it off). A
playbook is a sequence of steps against the same hosts, and every step used
to pay a full TCP connect plus key exchange. ControlMaster=auto is safe by
construction: OpenSSH reuses the master when it is alive and creates a new
one when it is not, so a stale control socket is detected and replaced rather
than breaking a deploy.
A batch asks many questions in one round trip. Its answers are parsed as they arrive: the response is never buffered whole and then copied into a second set of strings, so the peak memory is one answer plus the answers already collected. The transport also knows when the batch is complete — every expected answer has arrived — and closes the connection then, instead of waiting for a trailing query that may be slow.
The target-side helper is built once per source revision and target
triple and cached under ~/.cache/shellflow/. The build is the single
most expensive part of a deploy — a cross-compile takes minutes — and the
cache turns it into a one-off: the second service that needs the helper
reuses the first one's artifact.
russh/libssh2 would reimplement auth, key handling, ~/.ssh/config, agent,
and jump-host semantics. Wrapping the battle-tested system binaries is simpler,
more secure, and matches the project's zero-redundancy ethos.
The file is valid Bash (highlighting, bash -n, and execution all work), the
grammar is ~6 directives, and there is no second parser for users to learn.
AGENTS.md prefers winnow/pest for ad-hoc manual parsing. Here the grammar
is line-oriented with fixed prefixes and space/,/->/@-delimited
payloads: one match arm per directive, a shared quote-aware tokenizer
(split_words/split_flags), and a here-document state machine. The scanner
is now ~2,700 lines for 33 directives (plus ~2,000 lines of tests), which
is large but flat — each directive is independent, and none of it needs a
combinator grammar's backtracking. If the DSL ever grows nested expressions,
switching the payload parsing to winnow is a contained change.
rsync --dry-run -i ≙ diff engine; bash -n ≙ remote syntax validation;
set -ex ≙ debugger; @only_if ≙ pyinfra's when conditions. This is how we
absorb pyinfra's DX without its complexity.
JoinSet gives natural bounded fan-out and per-task error collection. A naive
select! over stdout/stderr loops drops the remaining stream at the first EOF
(see §7.4); dedicated reader tasks + a line channel drain both streams fully.
Per AGENTS.md: eyre at the application boundary (bin), thiserror in the
library (core). anyhow is forbidden in this workspace.
A persistent interactive bash would preserve cd/variables across local
steps, but it breaks step isolation, muddies set -e failure semantics, and
complicates timeouts and cancellation. An explicit @export capture (temp-file,
NUL-delimited) keeps every step a clean, killable process and makes cross-step
data visible in the plan — at the cost of one documented limitation (exit
prevents capture).
Local env is passed via Command::env() (never in argv). Remote env goes in a
payload header before set -x and is masked by shellflow in every output
path. We deliberately avoid the set -a; . /dev/stdin; set +a pattern (it does
not stop xtrace leakage either) and ssh host 'KEY=val bash -s' (puts the
secret in ssh's argv, visible to ps and to -vv argv printing).
| Requirement | How the design satisfies it |
|---|---|
bin/ CLI + crates/ libs |
bin/shellflow + crates/shellflow-core + crates/shellflow-secrets |
cargo add only; workspace = true |
§5.3 |
eyre app / thiserror lib |
§5.2, D-6 |
| One output path | no logging framework; ui streams user-facing lines, masks secrets, and writes the audit log (#![allow(clippy::print_stdout)]) |
No anyhow/log/reqwest/dashmap |
none used |
No unsafe, no unwrap/expect/panic |
workspace lints deny; code uses ? + eyre context |
Pedantic/nursery clippy, missing_docs |
all public items documented |
| proptest in normal test loop | §10.1 |
| Mutation testing | just mutation gate |
| Fuzz/benchmark conditional | marked N/A with justification (§10.3) |
| English-only docs/comments | this document and all code comments are English |
| Sequential cargo commands | per AGENTS.md, builds never run in parallel |
- M1 — Core parser (TDD).
crates/shellflow-core: data model, scanner,SshSpec,resolve_hosts,@export/@timeout/@copy --delete, env rendering + masking, unit + proptest suite. Ends with a versioned build-and-deploy fixture (git-rev captured via@export, interpolated into@copy) driving the parser tests.just testgreen. - M2 — Minimal executor.
bin/shellflow: clap CLI, preflight check, and local, remote, and copy execution (single-host, streaming with prefix). Manual smoke test with a real script. - M3 — Concurrency & lifecycle.
JoinSetfan-out,--parallel, fail-fast vs--continue-on-error, per-step/global timeouts, SIGINT/SIGTERM handling, summary output, exit-code classification (3 vs 4). - M4 — DX features.
-v/-vv/-vvv(incl.set -explacement,ssh -v, secret masking),--dry-run,--diff,--target,--only/--skip,--check,--output grouped,--log-file,@name,@only_if,@env,@exportend-to-end. - M5 — Integration tests. mock
ssh/rsyncshims; full matrix in §10.2 (incl. timeout, SIGINT, masking, preflight). - M6 — Polish.
README.mdrewrite,examples/deploy.sh, docs, finaljust format && just lint && just test && just mutationrun, git history in focused commits.
Each milestone ends with just lint && just test && just mutation green before
proceeding.
>>> [DRY RUN] no changes will be made <<<
[STEP 1/3] build · local
|--- Local Payload ---
| set -eu
| echo "==> building release binary"
| cargo build --release --locked
| VERSION=$(git rev-parse --short HEAD)
|----------------------
↳ local syntax check (bash -n): OK
↳ (state capture skipped in dry-run: VERSION unknown)
[STEP 2/3] ship · copy -> web [--delete]
! VERSION unresolved in dry-run — itemization skipped (would sync
! target/release/myapp-$VERSION -> /tmp/shellflow/myapp-$VERSION)
[STEP 3/3] restart · remote -> web
|--- Remote Payload (web) ---
| set -eu
| echo "==> restarting myapp-$VERSION on $(hostname)"
| sudo systemctl restart myapp
|-----------------------------
↳ remote syntax check (bash -n) on web1: OK
↳ remote syntax check (bash -n) on web2: OK
[web1] SKIPPED (guard: systemctl list-unit-files … | grep -q myapp)
[web2] SKIPPED (guard: …)
✔ Dry run complete — no changes made. Run without --dry-run to capture VERSION.
Formerly
docs/design-secrets.md(kept as a separate design until the docs consolidation). Sections are numberedS1–S12to stay distinct from Part I; in-text references use the§S<n>form.
Today shellflow is a deployment engine, but it has no first-class story for
encrypted configuration. Projects that need it end up with ad-hoc glue:
- an external
age/rageCLI to generate identities and encrypt/decrypt files; - a render script that decrypts secrets, base64-encodes them, and injects them
as literal
# @env KEY=<value>lines so they are masked by shellflow; - a template renderer (
sed) to substitute paths into@copydirectives; - hand-maintained systemd unit
ImportCredential=lists that must stay in sync with the encrypted env files.
All of this knowledge lives in per-project scripts and READMEs instead of in the tool. The result is a fragmented, hard-to-audit workflow where the controller needs several external binaries and every project reinvents the same pipeline.
- Single controller tool. After this change the controller needs only
shellflow(plus thessh/rsync/bashit already wraps). Noage,rage,base64, or render scripts. - Static playbooks. Playbooks reference encrypted files directly
(
# @secrets conf/prod.env.age) instead of generated ones containing base64 literals. - Uniform secret hygiene. Secrets are masked in previews, traces, and audit logs; never appear in any argv; never touch disk on the controller or the targets.
- Zero target-side crypto. Targets only need
bashand systemd ≥ 254.
- Not a replacement for systemd. Credential storage on targets stays
systemd-native (
systemd-creds,LoadCredentialEncrypted=/ImportCredential=).shellflowdecrypts on the controller and lets the target's systemd re-seal per host. - Not a Vault/KMS. Single-operator asymmetric encryption of whole files, consistent with the existing shell-native, minimal-toolchain philosophy.
- No new deployment language.
@secretsbuilds on the existing directive DSL andExecutionPlan; playbooks remain 100% valid Bash.
controller (only shellflow + ssh/rsync/bash) targets (bash + systemd >= 254)
┌───────────────────────────────────────────────┐ ┌──────────────────────────────────┐
│ keys generate / public identity │ │ │
│ secret encrypt / decrypt / edit │ │ │
│ @secrets file.env.age ──decrypt──> env vars │ ssh │ remote bash blocks receive the │
│ (masked everywhere, never in argv) │───────>│ decrypted env (masked) over the │
│ │ │ encrypted channel; targets need │
│ │ │ only bash (+ systemd-creds) │
└───────────────────────────────────────────────┘ └──────────────────────────────────┘
Key pieces:
crates/shellflow-secrets— a new library crate wrapping the Rustagecrate (the reference Rust implementation thatrageitself wraps). Handles identity loading, file encrypt/decrypt, recipient read-back, andKEY=VALUEenv parsing. No I/O leaks intoshellflow-core.- CLI subcommands —
keys,secret; the bare positional form (shellflow deploy.sh) keeps working asrun. @secretsdirective — decrypts an age file at execution time, injects the keys as environment variables into subsequent blocks, exports aLT_SECRET_KEYSlist, and registers every value for masking.
shellflow embeds the Rust age crate. rage is itself a thin CLI over
this crate, so encrypted files are interoperable: files created by
shellflow secret encrypt decrypt with rage, and vice versa.
Identity (private key) is resolved in this order:
-i/--identityflag (any subcommand that needs it);$SHELLFLOW_AGE_IDENTITY;~/.config/age/keys.txt(the age convention).
Identity files are the standard age format (X25519 private key). Existing
rage/age identities work unchanged.
Recipients (public keys) are passed as repeatable -r age1… arguments or
read from a directory of *.pub files via --recipients-dir (one key per
file). This mirrors how projects conventionally store operator public keys.
The age format is anonymous: the header stores the ephemeral share, not the recipients, so recipients cannot be recovered from an encrypted file.
secret edittherefore requires-r/--recipients-dirand fails fast if none are given — re-encrypting without them would silently drop operators.
shellflow run [SCRIPT] [flags] # current behavior; also the default
shellflow keys generate [-o PATH] # write a new identity
shellflow keys public [-i PATH] # print the public key
shellflow secret encrypt [-r age1…]... [-o OUT] FILE
shellflow secret decrypt [-i KEY] [FILE]
shellflow secret edit [-i KEY] FILE # decrypt -> $EDITOR -> re-encrypt
shellflow secret creds FILE # print ImportCredential=KEY lines
run keeps accepting the positional script so shellflow deploy.sh remains
backward compatible; all existing flags (-v/-n/-d/-t/-o/-s/-p/-c/-k, etc.)
apply to run.
#!/usr/bin/env shellflow
# @server web-1 deploy@10.0.0.11
# @server web-2 deploy@10.0.0.12
# @group web web-1,web-2
# @secrets services/myapp/env/prod.env.age
# @remote web
set -eu
# LT_SECRET_KEYS is exported automatically. Per-service store: one
# sub-directory per service keeps deploys from wiping another service's
# credentials (ImportCredential= only reads the flat store).
sudo install -d -m 0700 /etc/credstore.encrypted/myapp
# Use `tr + read + printenv`, NOT `for key in $LT_SECRET_KEYS` + `${!key}`:
# word splitting and indirect expansion are bash-only, and the remote login
# shell may be zsh (both silently misbehave there).
printf '%s\n' "$LT_SECRET_KEYS" | tr ' ' '\n' | while read -r key; do
[ -n "$key" ] || continue
printf '%s' "$(printenv "$key")" | sudo systemd-creds encrypt \
--with-key=host --name="$key" - "/etc/credstore.encrypted/myapp/${key}"
doneCredential filenames equal the key names (no
.credsuffix):systemd-credsembeds the output filename into the ciphertext, andImportCredential=KEYlooks up the store by exact name — mismatches are refused. With--local, this block runs on the controller instead of over SSH, which is useful for debugging.
@secrets <file.age>behaves like a batch of@env KEY=VALUEentries applied to all subsequent blocks, but the values are resolved at execution time from the encrypted file.- The directive is a comment, so the playbook remains 100% valid Bash.
- Repeatable: multiple
@secretslines merge in order; later files win on key conflicts (matching shellsourcesemantics). - The decrypted keys are also exported as the space-separated list
variable
LT_SECRET_KEYS, so remote blocks can iterate them without knowing the key names ahead of time.
The parser records only the file path (keeping shellflow-core pure and
I/O-free):
pub struct SecretEntry {
pub file: String,
pub identity: Option<String>,
}
// ExecutionPlan gains: pub secrets: Vec<SecretEntry>The executor resolves all SecretEntry values once, before the first step:
- resolve the identity — most specific wins: the
@secretsline's own--identity, then the run-wide-iflag, then$SHELLFLOW_AGE_IDENTITY, then~/.config/age/keys.txt. Identities are loaded lazily and cached per run, so a playbook whose files each pin their own identity never touches the default path, and a repeated path is read once. A missing or invalid identity is a hard error (never a silent skip); - decrypt the file. Later files win on key conflicts (matching
sourcesemantics) and the key list is deduplicated in first-seen order; - parse
KEY=VALUElines (blank lines and#comments ignored; the first=splits the key); - insert every key into run-state env (same layer as
@export, so explicit@envstill wins on conflicts); - register every value in the
Uimask set (see §S5.3); - export
LT_SECRET_KEYSinto run-state env — only when there are shared keys. The variable is a set of key names; an empty value would be indistinguishable from "this service has no credentials", whichcred-env credstore-installacts on by clearing the credential store. A playbook whose every entry is per-host therefore exports no variable at all, and the store is left untouched; the per-host key names are added by the executor, for the host it is about to talk to.
Ui::mask_line currently performs a global str::replace for every known
secret. Short values (e.g. 1, on) would corrupt output, so @secrets
masking applies only to values of length ≥ a threshold. The threshold
defaults to 6 and is configurable via $SHELLFLOW_MASK_MIN_LEN (or
--mask-min-len on run, which takes precedence). An unparsable or negative
$SHELLFLOW_MASK_MIN_LEN falls back to 6 rather than failing the run.
Explicit @env KEY=value literals keep their unconditional masking, and a
value captured by @export is masked the same way unless the playbook says
--public (§7.6). Nothing about masking depends on the variable's name.
Masking covers host lines, payload previews, and --log-file output through
the existing Ui path; secrets never appear in any spawned argv. Every path
that renders user content must route through the mask, not only the payload
previews: the -vv local bash -c echo goes through Ui::masked_note for
exactly this reason, since a literal secret embedded in a block body would
otherwise be printed unmasked two lines below a preview showing ***.
- Precedence for a key present in several sources:
@env KEY=value(step) >@secrets/@export(run state) > passthrough. - Missing identity, undecryptable file, or malformed env content is a hard error before any step runs (never a silent skip).
A @secrets path may contain {host}, which expands to the target alias
of each host the selected steps actually reach (after --target restriction):
# @server web-1 deploy@10.0.0.11
# @server web-2 deploy@10.0.0.12
# @group web web-1,web-2
# @secrets services/myapp/{host}.env.age
# @remote web
echo "this is $API_KEY" # different per hostSemantics:
- Expansion uses the set of aliases the selected steps target. A host no
selected step touches never requires a file, so
-t web-1with--only fetchdoes not needweb-2.env.age. - A targeted host whose file is missing is a hard error (fail closed). Guessing a path or silently skipping a host would deploy a host with the wrong credentials.
- Per-host pairs are layered onto that host's payload env only; one host
never sees another host's values.
LT_SECRET_KEYSis extended with that host's keys. - Every value is registered for masking exactly like a shared value.
--localresolves remote blocks on the controller, so a{host}playbook is ambiguous there: it requires a single host (-t <host>) and errors otherwise, rather than picking one arbitrarily.- Remote syntax checks (
--check/--dry-run) do not need per-host values: they never execute the block.
| Area | Change |
|---|---|
crates/shellflow-secrets (new) |
age wrapper: identity load, encrypt/decrypt, recipient read-back, env-file parser. Library-only; unit + proptest. |
crates/shellflow-core |
ExecutionPlan.secrets: Vec<SecretEntry>; parser recognizes @secrets (path only, no I/O). |
bin/shellflow |
clap subcommands (run default); executor resolves secrets into run-state env + mask list; Ui gains a min-length mask threshold. |
bin/shellflow/src/preflight.rs |
unchanged (age is embedded; bash/ssh/rsync remain the only required tools). |
Dependency additions (via cargo add, workspace-managed): age,
age-core, clap (already present), tempfile (dev). shellflow-core
stays free of async, I/O, and crypto.
- Controller-only secrets: encrypted files travel to the controller, decrypted in memory, injected into remote payloads over the (already encrypted) SSH channel. Targets never hold the age identity.
- No argv / no disk: secret values never appear in process argv. Every
file that holds plaintext or ciphertext —
secret decrypt -o,secret edit,cred-env render— is written0600through a same-directory temporary file and an atomic rename, so a partial or failed write never truncates the previous contents and the mode is corrected even when re-writing an existing file. The@exportcapture file is owned by an RAII guard and deleted on every exit path, including a failed step. - Masked everywhere: previews,
-vv/-vvvtraces, and--log-fileredact every@secretsvalue (subject to the min-length guard). The redaction is applied by the output sink, so every render path is covered by construction — including error messages,bash -ndiagnostics, and the audit trail. - Fail fast: missing identity, decrypt failure, or malformed env aborts before any step executes.
- Recipient safety:
secret editrequires explicit-r/--recipients-dirand fails fast otherwise — age is anonymous, so re-encrypting without the full recipient list would silently drop operators. The check runs before the ciphertext is read, so a rejected invocation never stages plaintext.
- Unit (TDD) —
shellflow-secrets: identity load/paths, encrypt→decrypt round-trip with an ephemeral generated identity, recipient read-back, env-file parsing (blank/comment/=-in-value edge cases). - Property — env-file parser invariants via
proptest(e.g. parse round-trip stability), masking threshold behavior. - Parser —
@secretsdirective recognition, ordering, andLT_SECRET_KEYSexport using the existingshellflow-coretest style. - Resolver — a per-entry
--identitybeats the run-wide-i; a playbook whose every entry pins an identity succeeds even when the default identity path does not exist; a missing identity is a hard error naming the path; later files win on key conflicts andLT_SECRET_KEYSis deduplicated; the threshold gates which values are registered for masking. - Integration — extend
bin/shellflow/tests/with mockssh/rsyncshims (existing pattern): a playbook with@secretsmust (a) not leak the value into captured argv or payload previews, (b) run the remote block with the env injected, (c) fail fast without an identity, (d) decrypt through a per-entry--identitywith no run-wide-i, and (e) honorSHELLFLOW_MASK_MIN_LENfor short values. - Mutation —
just mutationover the new crate and parser changes. - Fuzz — conditional: the env-file parser is the only parser-like surface;
add
cargo-fuzzonly if it grows into a general config format.
- M1 —
shellflow-secretscrate.agewrapper + env parser + tests. - M2 —
keys/secretsubcommands.generate,public,encrypt,decrypt,edit,creds. - M3 —
@secretsdirective. Parser entry, executor resolution, masking threshold,LT_SECRET_KEYS. - M4 — Docs & examples. Update
README.md,docs/design.md, add an end-to-end example playbook.
Each milestone ends with just format && just lint && just test && just mutation green.
rage is a CLI wrapper over the same crate; embedding it removes a PATH
dependency, keeps behavior identical, and matches the tool's zero-runtime
philosophy. The Go age implementation is not used.
The directive keeps playbooks static and valid Bash, moves decryption into the tool (where masking already lives), and eliminates render scripts and temporary files.
mask_line does global replacement; protecting short values from being
over-masked requires a threshold, which is exposed as configuration rather
than hard-coded.
Every third-party service used to ship its own bin/<svc>-start launcher: a
credential→env export loop plus, when the app only reads a config file, a
heredoc that renders the config into tmpfs. Both parts were copied verbatim
into every service and were bash/zsh-fragile in different ways.
The helpers are subcommands of the shellflow binary itself; the playbook
installs that binary once per target host (static musl cross-build via
cargo zigbuild, ~3 MB, idempotent, version upgrades ship with each deploy)
and every unit references it:
shellflow cred-env exec <cmd> [args…]— exports every$CREDENTIALS_DIRECTORY/*as an env var (filename = variable name, exact[A-Za-z_][A-Za-z0-9_]*check matching the Rust env parser) and execs the command (CommandExt::exec, so systemd's main PID and signals apply to the real program). Unset$CREDENTIALS_DIRECTORYfails closed — a unit that declares no credentials should not run throughcred-env. Replaces the per-service export loop.shellflow cred-env render <tmpl> <out>— substitutes${KEY}placeholders from the credentials into a template and writes<out>(0600, same-directory temp file + atomic rename). Substitution is literal;shellflow_core::render_templatefails with the sorted list of missing keys before anything is written. Replaces the per-service heredoc.shellflow cred-env credstore-install <svc> [--store-dir PATH]— clears/etc/credstore.encrypted/<svc>/and re-seals every key listed in$LT_SECRET_KEYS(exported by@secrets) viasudo systemd-creds encrypt --with-key=host --name=<KEY>, value on stdin. Unset$LT_SECRET_KEYSaborts (the store must not be wiped for nothing); set-but-empty is an explicit "no credentials" and clears the store. The new store is built in a staging directory and swapped in only once every key sealed successfully, so a mid-way failure leaves the previous store intact rather than empty or half-written. Replaces the hand-written install loop (and its zsh/bash portability class).- Templates are plaintext files with
${KEY}placeholders, committed next to the service. They contain no secrets, so they may live on persistent disk; only the rendered output (tmpfs) ever holds values. shellflow template check <tmpl> <file.env.age>extracts the template's${KEY}references (shellflow_core::template_keys) and cross-checks them against the encrypted env file on the controller: missing keys are fatal (they would abort the unit at start), unreferenced keys are reported as notes.- Controller-side replacements for the infra repo's bash scripts:
shellflow secret keys-check <dir>verifies all<host>.env.agefiles in a service directory share one key set (wasscripts/check-env-keys.sh);shellflow service init <svc> [--host …]scaffolds the flat layout + unit + playbook skeleton (wasscripts/new-service.sh). - Fallback:
shellflow emit cred-env [-o PATH]still prints a pure-bash asset (bin/shellflow/assets/cred-env.sh, embedded withinclude_str!) implementingexec/renderwith the same semantics, for targets that cannot host the binary. Both implementations are exercised by parallel integration suites over one shared fixture set.
- Literal substitution cannot use
${line//needle/val}: in the replacement string,&expands to the matched text on some bash versions (sed-like semantics), so a password containing&would corrupt the render. The bash fallback builds the result with${text%%"$needle"*}/${text#*"$needle"}instead, which is literal on every bash; the Rustrender_templatesplices spans from the original text (also making self-referential values safe). - Variable-name validation cannot use a
caseglob: incase $key in [A-Za-z_][A-Za-z0-9_]*), the trailing*matches any string, soBAD-NAMEpasses and the subsequentexportfails. Both implementations use the exact regex equivalent ([[ =~ ]]in bash, a char-based check in Rust), matching the Rust env parser. ExecStart=performs no command substitution:$(cat …)is passed literally, and thesh -cworkaround would expose the secret in argv (ps). Apps must read$CREDENTIALS_DIRECTORY/<KEY>themselves, usecred-env exec, or consume acred-env render-produced config file.- The encrypted path form needs
LoadCredentialEncrypted=ID:PATH:LoadCredential=expects a plaintext source andImportCredential=only searches the flat credential-store directories (systemd.io/CREDENTIALS, systemd.exec(5)).
crates/shellflow-core/src/template.rs: unit + proptest coverage of the placeholder scanner andrender_template(boundaries, malformed forms, multi-byte, adjacent placeholders, literal values, missing-key fail-closed). 30 of 31 mutants caught (1 unviable).bin/shellflow/tests/cred_env.rs: two parallel suites over shared fixtures — (a) the bash fallback asset underbash, (b) the realshellflow cred-envbinary (CARGO_BIN_EXE_shellflow) with a fake$CREDENTIALS_DIRECTORYand a stubsudo/systemd-credsonPATH:execexport semantics (including invalid names and fail-closed without the directory), flag passthrough, exit code propagation,renderliteral substitution (values with&|$\\'"), non-UTF-8 rejection, 0600 output, no temp-file leftovers, andcredstore-installhappy path (argv hygiene: values never in argv), stale key clearing, and fail-closed on empty/missing/invalid keys and unsafe service names.bin/shellflow/tests/service_init.rs:service initscaffolding (file set, executable bit, overwrite refusal, name validation, generated playbook passes-kend-to-end with a real encrypted env) andsecret keys-check(match/mismatch/no-files cases).