After Dark Systems / Service Automation HubCompletely Programmable Infrastructure

OpenSAH / Farm tool guides

vpscfgfarm: contracted fleet changes.

A door in front of production. Every command to the fleet goes through it, and the door decides: a governor, reviewed contracts and runbooks, typed approvals and a hash-chained journal.

Available Tag v0.1.0 Apache 2.0

What it is

vpscfgfarm is contract-based configuration and deployment safety for a fleet of Linux servers reached over SSH (Docker, docker compose, k3s). It has three doors: vpscfg-control.py for contracted deploys, vpsexec for gated ad-hoc commands, and vpsrun for running a reviewed sequence a human approves once. Every door reads one governor file, refuses by default, and journals what it allowed and what it refused. Use it when you, a small team or an agent working under a human's approval want every production command to pass through reviewed, tested, journaled code.

What it is not. It is not a configuration-management system (it can drive Ansible behind the same gates), not a multi-tenant service, not a secrets store, and not a sandbox against a local user who can already edit the doors. It protects production from mistakes made through the door. It cannot stop someone who goes around it with raw SSH; fleet changes exists to notice when that happens. A semantically disastrous but ordinary command, approved by a human, is indistinguishable from intent: the doors make it slow, visible, bounded and recorded, not impossible.

Requirements

  • Python 3.9 or newer, with PyYAML (hash-pinned in requirements.txt).
  • ssh, rsync and tar on the operator machine.
  • Managed hosts: Linux servers reachable over SSH, each one a Host alias in ~/.ssh/config.
  • A real terminal for approvals. Mutating approvals and grants refuse without a TTY.

Install

git clone https://github.com/afterdarksys/vpscfgfarm.git
cd vpscfgfarm
python3 -m pip install --require-hashes -r requirements.txt

Pin the interpreter

The three doors carry one shebang naming a real interpreter with -I (isolated mode). The committed shebang is the maintainer's machine, so point it at the interpreter that has PyYAML:

tools/pin-interpreter.sh "$(python3 -c 'import os,sys;print(os.path.realpath(sys.executable))')"

It rewrites line 1 of vpscfg-control.py, vpsexec and vpsrun, and refuses unless the target is an absolute, real, executable binary that is not group- or world-writable and reports isolation under -I. Never use #!/usr/bin/env: it lets PATH choose the program that reads the governor. Start the doors by path (./vpsexec, or a symlink such as ~/bin/vpsexec), not as python3 vpsexec.

Verify the install

./vpscfg-control.py status
python3 -m unittest discover -s tests

The repository ships no governor, so on a fresh clone status refuses with control file missing — nothing runs without the governor. That refusal is the expected first result: until you configure an estate, every door refuses. The test suite proves the refusals; CI treats a skipped test as a failure.

Quick start with the example estate

The repository includes a fictional three-host estate in contrib/example-estate/:

HostAddressPostureRole
web1192.0.2.10frozenServes www.example.com, no failover. Also reachable as web1.example.internal (an alias_of).
db1192.0.2.20lockdownThe database.
edge1198.51.100.5testReverse proxy; where it is safe to be wrong first.

Point the tool at a copy of it with a pointer file at the root of the tool checkout:

cp -R contrib/example-estate ~/vpscfg-estate
printf '%s\n' "$(cd ~/vpscfg-estate && pwd -P)" > estate.path
chmod 600 estate.path
./vpscfg-control.py status     # the example's three hosts and their postures

Merge the stanzas from ssh_config.example into ~/.ssh/config: every governed host must be an SSH alias, and a policy group naming a host you cannot reach refuses to load. Then use the introspection verbs, which change nothing:

./vpsexec hosts       # postures, as the governor sees them
./vpsexec groups      # @web, @db, @edge, @docker, @shop, @test
./vpsexec checks      # the named read plays
./vpsexec edge1 'touch /tmp/hello'   # a mutation without --run: prints the plan and stops

Reads such as ./vpsexec @all 'uptime' connect to the hosts over SSH. The example addresses are documentation ranges and will not answer. Replace the example hosts, addresses and keys with your own, start every host at frozen or test, and change postures only in reviewed commits.

On your own estate, a first read session looks like this:

./vpsexec @all 'uptime'
./vpsexec @docker 'docker ps' --compare
./vpsexec check failed
./vpscfg-control.py web1 containers
./vpscfg-control.py edge1 details

Delete estate.path to return to the default estate, the tool checkout itself. There is deliberately no flag or environment variable for choosing the estate.

Key concepts

The governor

vpscfg-control.json at the estate root is the one file that grants anything. Nothing in policies/ can loosen it.

PostureReadsMutations through vpsexec
releasedYesYes, with --run, --reason and approval.
testYesThe same. Use it where a mistake is cheap.
lockdownYesOnly with --break-glass and a committed runbook naming the exact command.
frozenYesThe same as lockdown.

A host missing from hosts is treated as frozen. mode: lockdown or mutation_enabled: false refuses every mutation, break-glass included. allowed_verbs must contain exec for vpsexec to work at all. alias_of declares that two names are one machine. scopes holds the scopes released for contracted deploys.

The deny-always floor

Volume, system, image and network prunes, volume rm, compose down -v, DROP TABLE|DATABASE|SCHEMA, TRUNCATE, rm -rf /..., kubectl delete ns, mkfs, dd if= and terraform destroy are compiled into all three doors. The governor's deny_always list may add patterns; a governor that omits a floor entry is treated as tampered and refuses everything. No policy file, runbook, --break-glass or approval reaches the floor.

Policies and runbooks

Policy files in policies/*.yaml (not recursive, so policies/examples/ is inert) can only make the doors stricter:

A policy file canA policy file cannot
Name host groupsChange any host's posture
Add deny patterns (deny_extra)Remove a deny pattern
Add risky patterns (risky_extra)Widen the read-only allowlist
Authorise an exact command on exact hostsLift the deny-always floor

A runbook is a policy file with runbooks:. Each entry authorises one exact command on exact hosts. A file with runbooks needs expires: "YYYY-MM-DD" (after that day its runbooks are disarmed and every run warns) and a shape declaring its step count, hosts and break-glass mix. Entries can carry break_glass: true, a verify read that must exit non-zero on failure, timeout, container and rollback: true:

version: 1
expires: "2026-12-31"
shape: {steps: 1, hosts: [web1, edge1], break_glass: true}
reason: "Outage runbooks"
runbooks:
  - hosts: [web1, edge1]
    cmd: "systemctl restart nginx"
    where: host
    break_glass: true
    why: "wedged nginx worker pool"

Unknown keys and duplicate keys refuse the whole load. Retire a finished runbook with git mv policies/<file>.yaml policies/examples/superseded/ in a reviewed commit.

Contracts

The contracted-deploy door works from one YAML contract per service in contracts/. A contract records what to sync, what must never be overwritten, what healthy means and what else is in the blast radius:

service: shop-api
host: web1                      # must resolve in ~/.ssh/config
deploy_dir: /srv/shop-api
sync:
  include: ["server.js", "routes/", "middleware/", "migrations/"]
  never:   [".env.production", "Dockerfile", "package.json", "package-lock.json"]
build:   "docker compose up -d --build --force-recreate"
freeze:  false
pre:
  - backup: "tar czf /root/{service}-pre-{ts}.tar.gz {deploy_dir}"
verify:
  - http: { url: "https://api.example.com/health", expect: 200, timeout: 10 }
  - container: { name: "shop-api", state: "healthy", within: "90s" }
blast_radius:
  shares_host_with: "every other service on web1"
  volumes: []

sync.never is required. freeze: true is a hard lock; unfreezing is a git commit. Every operation runs the same pipeline: contract, plan, gate, apply, verify. Reads stop after the plan, and mutations need --run.

The journal

Every invocation, allowed or refused, lands in a locked, fsynced, hash-chained JSONL journal in the tool checkout's journal/ directory (control.jsonl, executor.jsonl, vpsexec.jsonl). If the journal cannot be written, nothing changes. Secret values are masked in remote output before it is shown, compared or journaled; if the masker fails, the output is withheld.

The release ratchet

The contracted-deploy path ships maximally locked: stage 0 allows reads, status and plans only, and the executor refuses even when every gate is green. Controls come off one scope per commit, smallest first (container, then pod, then service, then host), each tested and recorded in the governor's scopes.tested before it enters scopes.unlocked. docs/STABILITY.md lists the stages and what stays locked forever.

The contracted-deploy door

vpscfg-control.py validates the governor, judges the invocation, journals it and hands it to the vpscfg package, which re-validates independently. The upstream docs write its grammar as vpscfg ...; you run it as ./vpscfg-control.py ....

./vpscfg-control.py status                     # governor state: mode, postures, scopes
./vpscfg-control.py web1 containers            # live docker ps (read-only)
./vpscfg-control.py db1 overlays               # k3s namespaces
./vpscfg-control.py edge1 details              # host identity, OS, uptime
./vpscfg-control.py web1 drift shop-api        # git, contract and live state, three-way
./vpscfg-control.py web1 verify shop-api       # run the contract's post-conditions
./vpscfg-control.py lint                       # contract quality floor
./vpscfg-control.py fleet watch                # every contract's verify block
./vpscfg-control.py fleet changes              # live state vs baseline; flags unjournaled changes
./vpscfg-control.py index --all                # cached record of what lives on each host
./vpscfg-control.py journal verify             # check the journal's hash chain

Deploying: preflight, then run

./vpscfg-control.py edge1 deploy shop-proxy --preflight          # GO=0 / warnings=1 / NO-GO=2
./vpscfg-control.py edge1 deploy shop-proxy --preflight --json
./vpscfg-control.py edge1 deploy shop-proxy --run --reason "why" # only within 30 min of a GO

Preflight is read-only. --run refuses unless the latest preflight for the same plan (contract, sync arguments and source bytes) was GO or GO-WITH-WARNINGS in the last 30 minutes. Mutations also need --reason, journaled forever, and the scope must have graduated in the governor. Refusals exit 2 with the reason on standard error.

vpsexec: gated ad-hoc commands

For what a contract cannot express, such as restarting a wedged process on several machines during an outage or reading five machines' logs at once.

vpsexec <hosts> '<command>' [flags]                  # the command is ONE argument
vpsexec <hosts> push <file> --sha256 <hex> [flags]   # content-addressed file push

Hosts are edge1, a list such as web1,edge1 (run in the order named), a group such as @web, @all, or a mix. One machine reached by two names runs once.

The three tiers

TierExamplesWhat it takes
readdf -h, docker ps, systemctl status x, journalctlNothing. Any host, no --run. Reads fan out in parallel.
mutating./deploy.sh, make install, anything unrecognised--run, --reason, a posture that allows it, and approval. Mutations run serially and continue past a failure.
riskysystemctl restart, docker rm, reboot, iptablesAll of the above, plus a runbook or override naming the exact command for every host.
floorPrunes, volume rm, DROP, rm -rf /..., mkfsNever.

The read tier is an allowlist with per-command option grammars; anything not on it is a mutation, and command substitution is refused outright. For example, curl is a read only when -q comes first, so a health check is written curl -qfsS URL.

Common flags

FlagMeaning
--runActually execute. Without it you get the plan and stop.
--reason "why"Required for anything that is not a read; journaled.
--in NAME, --in-match GLOB, --in-allRun inside live containers.
--verify 'CMD'A read post-condition per host; its failure fails that host.
--canary [N]The first N hosts must pass before the rest are touched.
--compareReads only: group hosts by identical output and flag the odd one out.
--break-glassReach frozen or lockdown hosts, backed by a committed runbook.
--approve=HASHNon-interactive approval of one exact plan hash (not enough on its own for break-glass).
--grant FILE, --grant autoUse an approval contract instead of typing the break-glass phrase.
--timeout NPer-host timeout in seconds.

Checks and read-back

vpsexec check                  # list the named read plays
vpsexec check failed           # what is broken, everywhere
vpsexec check disk @docker     # override the default host set
vpsexec last web1 -n 3         # the last runs in full: command, reason, approval, output, verify
vpsexec log --since 2h         # the journal, filtered
vpsexec runbook lint policies/web-release-example.yaml

Checks are reads with a name, so they need no --run or approval. runbook lint checks a runbook at authoring time (shape, expiry, timeouts, pushed artifacts, STOP steps) and ends in GO, GO-WITH-WARNINGS or NO-GO.

Approvals, break-glass and grants

Nothing mutating runs without explicit, per-plan approval, typed at a real terminal. The phrase scales with what is at stake:

SituationType
One hostThe host name
Several hostsFLEET:<n>
--in-allALL:<hosts>
Break-glassBREAK-GLASS:<n>

There is no --yes. Without a TTY, a plan can be approved only with --approve=<plan-hash> from an earlier dry run, which authorises one byte-identical plan and never a break-glass plan on its own.

Break-glass elevates posture per host, only for hosts that need it, and only with --break-glass plus a committed runbook with break_glass: true naming the exact command for each elevated host. The floor, the master switch and mode: lockdown still refuse. An ad-hoc --override file can never grant break-glass.

Approval contracts ("grants") replace the typed phrase for one reviewed runbook's break-glass steps, for a bounded time, and nothing else. Every other gate runs unchanged on every use.

vpsexec grant new web-release-example --ttl 30m          # preview; writes nothing
vpsexec grant new web-release-example --ttl 30m --run    # mint: TTY only, type GRANT:web-release-example:30m
vpsexec grant list
vpsexec grant revoke <session_id>

Short grants bind one runbook file by the hash of its bytes, so editing the runbook invalidates them. Reusable grants (--kind reusable) bind pushed policy and a maximum number of uses. Both need a TTY when minted and at every use.

Plays and the runbook walker

When a fix is several approved mutations, an agent can write plays/<name>.yaml, and a human runs vpsrun plays/<name>.yaml in a real terminal, reads every step's plan and types one phrase. Each step still goes through the door twice, once for its plan hash and once to run it, so a plan that changed after it was shown halts the play. The agent reads the result with vpsexec last.

The example estate's runbook walker runs a runbook's steps in order through vpsexec:

contrib/runbook-walker/walk.sh --runbook web-release-example --list
contrib/runbook-walker/deploy.sh web-release-example --ttl 30m \
    --reason "shop 1.4.0 release" --artifacts contrib/example-estate/artifacts

deploy.sh mints one grant, then runs walk.sh, which checks pushed artifacts by sha256 before the first step, records progress so you can resume with --from N, skips rollback steps and pauses after "Stop if" steps.

Files and locations

PathWhat it is
estate.pathOptional pointer at the tool checkout root: one line, an absolute real path to your estate. Must be yours, mode 0600 or 0644, and never committed.
vpscfg-control.jsonThe governor, at the estate root. Not shipped.
policies/*.yamlGroups, checks, deny additions, credential paths and runbooks (estate).
contracts/*.yamlOne contract per service (estate).
plays/Queued plays for vpsrun (estate).
journal/The hash-chained journals and the repair database, in the tool checkout (gitignored).
~/.ssh/configThe host registry. Include is followed as OpenSSH follows it.
~/.vpscfg/grants/Minted approval contracts (0600 files in a 0700 directory).

Optional integrations

Each is off until named in the governor's integrations block. No environment variable or flag turns them on.

  • Telemetry: an https:// endpoint that receives a copy of each journal record. The local journal stays authoritative; push failures never block a mutation. The key comes from $VPSCFG_TELEMETRY_KEY or ~/.config/vpscfgfarm/telemetry-key.
  • Traefik file provider: read a host's dynamic config to suggest http: verify checks.
  • Firewall manager: the desired state that fleet firewall judges live firewall state against.
  • Maintenance windows: a JSON export of active windows; --require-blackout refuses hosts outside one.
  • badfarm IaC scan: a separately installed badfarm on PATH (or $VPSCFG_BADFARM). fleet iac PATH scans a local tree, and deploy preflight's iac check scans the contract's repository.
  • Secret source: the command secret-put --from-pipe runs, without a shell, to read one secret.

How it fits with the other tools

  • badfarm discovers problems read-only and can scan a contract's infrastructure code for vpscfgfarm's preflight.
  • goodfarm plans repairs and drives them through vpscfgfarm's repair protocol: ./vpscfg-control.py HOST deploy SERVICE --repair-action describe|approve|apply|status. Only service-deploy/v1 executes, through the normal deploy gates; the distro-upgrade and reboot recipes can be approved but their apply records a refusal.
  • vpscfgfarm executes through its gates, and holds all the authority.
  • farmops is planned to schedule routine work under standing grants that vpscfgfarm checks on every call. Today farmops has only its catalog format, loader and journal.

Built from the public vpscfgfarm repository (README.md, contrib/README.md, the example estate, docs/HOWTO.md, docs/VPSEXEC.md, docs/DESIGN.md, docs/STABILITY.md, docs/REPAIR-PROTOCOL.md and the vpscfg CLI help text) at commit e899b93.