AGENTS.md¶
How to work in this repository. Read this before changing anything.
What this is¶
Application delivery to a deployment target. A stock Ubuntu template goes in, Ansible configures it, the application runs under systemd with a sha256-pinned dependency tree. No container image is ever built.
appctl is the tool: it builds artifacts, delivers them through the named
target, and manages secrets. It is the intended entry point for everything.
The two boundaries that matter¶
app.yaml configuration: applications, targets, builds, secrets
common/ framework. Names no application, anywhere.
lxc/ pct create from a stock template, DHCP, address discovery
python/ toolchain, service account, venv — the contract
apps/ one directory per application
example-app/ the sample used by the quickstart
builder/ a toolchain: a guest that exists to build other applications
src/appctl/ the appctl package
targets/ where an application runs; proxmox_lxc is the only one built
inventory/ the operator's own guests. GITIGNORED here, and absent on a
fresh clone; inventory.example/ holds the templates. Recorded in
the private deployflow-state repository — see below.
roles_path = common:apps. A role's meta dependency declares what it needs,
so a new application never edits a playbook.
An application must not require anything from another application, and
common/ and src/appctl/ must never name one. If your change makes the
framework aware of a specific app, it is in the wrong place — put it in
apps/<name>/.
The shared code must not name a target. pct, the container ID, the
template, the storage, the bridge and the guest network live in
src/appctl/targets/proxmox_lxc.py. A module outside targets/ that mentions
one of them is in the wrong place. A new target is a new file under
targets/, and it does not change config.py.
A role that installs no service does not depend on common/python. That
dependency is what asserts app_port is defined, and a toolchain serves
nothing. A role with no dependency still runs — play 2 loops over each host's
app_roles and include_roles it — so no playbook changes. See
apps/builder/meta/main.yml.
A toolchain's product is a machine that can build, so its role asserts the machine. Not the exit code of the install commands. A guest that is too small does not fail at install — it fails hours later, during a build, with an error that names a memory limit rather than the guest.
Commands¶
uv tool install --editable . # or: uv pip install -e .
appctl list # every app, its target, and last install
appctl targets # implemented targets, and the planned ones
appctl secrets check # required secrets present? exit 1 if not
appctl render # YAML -> JSON, without deploying
appctl build example-app # wheel + pinned lockfile + BUILDINFO
appctl install example-app # deliver one application; the name is positional
appctl install # valid only when exactly one is configured
The application name is a positional argument, not a flag. Flags follow it, and
anything after a bare -- is passed to ansible-playbook untouched:
appctl install example-app -q # terse
appctl install example-app --forks 4 # guests at once
appctl install example-app -- --check --diff # straight to ansible-playbook
Use the -- form for anything the tool also defines. Passing --quiet without
it does nothing useful, because ansible-playbook has no such option.
Run logs are kept and never pruned. Each run writes commands.txt,
log.txt, recap.txt, errors.txt and meta.txt under tmp/runs/TIMESTAMP/.
Read commands.txt first; log.txt is mostly SSH plumbing.
docs/architecture.md lists the invariants. Read it before changing a role:
several of them are failures that passed review and only showed up under test,
and several produce a green play and a broken service.
The tool¶
The tool is appctl in src/appctl/. Its own documentation is in docs/, and
it takes precedence over this file for anything about the tool:
| docs/README.md | the index |
| docs/architecture.md | how it fits together, and where the target boundary is |
| docs/configuration.md | every key in app.yaml |
| docs/build-pipeline.md | the two build paths |
| docs/rendering.md | how YAML becomes the configuration the guest reads |
| docs/secrets.md | how a secret reaches the guest |
| docs/targets.md | the target interface, and how to add a target |
| docs/site.md | the documentation site: how it is assembled and how to publish it |
Adding an application¶
Read .agents/skills/deployflow-add-app/SKILL.md
first. It has the full process, the postconditions a role must carry, and the
sentinel traps.
The short version: one directory under apps/, an entry in app.yaml, and
changed=0 on the second appctl install. Nothing in common/ or
playbooks/ changes. The skill's last step records the result in the private
state repository — see The inventory and the manifest are not in this git
below.
Setting up a machine¶
Read .agents/skills/deployflow-init/SKILL.md
first. It is the preflight for a machine rather than for an application: the
toolchain and GitHub access, the inventory and the four values it cannot know,
SSH access to the Proxmox host, which VMIDs are free, the addresses Proxmox has
leased to each guest, the secrets a run needs, and the first sync of the state
repository. On a machine that is already configured it only checks and reports.
Rules that are not stylistic¶
A provider may need no secret at all. Some model providers authenticate with
OAuth: the guest holds a token in its own state directory, and nothing secret
belongs in this repository for them. An assertion that demands an API key for
an OAuth provider is a wrong requirement, not a safe one — it blocks a run that
is correct. Read the application's defaults/main.yml before changing that.
Secrets. .env at the repo root, gitignored, mode 0600, written by
appctl secrets set. NAME value and NAME --generate are both supported;
the bare form prompts. .env.example is
committed and lists which keys are required. Never put a secret in app.yaml,
in group_vars, or in a role default. Never pass one in argv — it is visible
in ps. appctl redacts password-shaped values from the logs it writes.
ansible-vault is gone. The vault file this repo used was encrypted with a
throwaway password that was then deleted, which made it undecryptable and
unrecoverable. Do not reintroduce it.
Guests get a DHCP lease, not an address. Proxmox's SDN zone hands one out and
registers the hostname; common/lxc reads it back with
pct exec <vmid> -- hostname -I and sets ansible_host. A statically addressed
Ubuntu 26.04 guest comes up with no IPv4 at all and systemd-networkd exiting
243/CREDENTIALS. Do not add an address to the inventory.
appctl install refuses to guess. With several applications deployed it
stops and prints the exact command to run. Configuring an application does not
deploy it — only an app_roles entry does, and appctl list shows which is
which. Secrets are checked for the selected application only; requiring them all
up front lets one you are not deploying block one you are.
appctl install X also runs the lxc tag. Play 1 is what discovers
the lease; without it play 2 cannot resolve the guest. It is guarded by a stat,
so on an existing guest it costs a few no-op tasks.
Configure with YAML, not with code. An application's settings live in
apps/<name>/config.yml and are rendered on the controller by appctl render,
which install runs for you. common/python copies the result to
app_config_target. Do not add a script to files/ that runs on the guest
to produce configuration: the application merges its own defaults into whatever
it is given, so a second implementation is redundant and free to drift. Use
{{ name }} for controller substitution and ${NAME} for runtime secrets; the
second form is what keeps a secret out of the repository.
Never round-trip app.yaml through a YAML dumper. It carries a comment
above nearly every setting, explaining why it is what it is, and yaml.safe_dump
destroys all of them — measured, 18 comment lines in and 0 out. Anything that
writes configuration must edit the slice it owns and leave the rest
byte-identical. That is what src/appctl/state.py does: it rewrites only the
state: block, and refuses rather than guesses when it cannot find it.
state: is tool-written. A build or an install fills it in. Do not hand-edit
it, and do not add sections to it: only built and installed are accepted, and
anything else would be silently dropped by the next write. It lives in a committed
file, so every run is a diff — that is the trade for having the record next to
the thing it describes.
Unknown config keys are an error. app.yaml is strictly validated on
purpose: a misspelled key would otherwise be dropped and the service would come
up on the wrong port.
Sentinels: the trap that wastes the most time¶
Every task that checks a precondition is a sentinel, and a sentinel that can never succeed is worse than none — it looks like a guard and silently keeps a gate permanently open, so the role reinstalls itself on every play.
All of these shipped green here and were found only by running the play twice:
| Trap | Fix |
|---|---|
'changed' in stdout |
matches "unchanged". Use == 'changed' after trim |
| importing a name that does not exist | check the import on the guest before trusting it |
a glob read by next() twice |
StopIteration. Materialise with list() |
regex_replace '^.*:' |
greedy: keeps the port, not the host |
':\\d+$' in a YAML block scalar |
block scalars are literal; Jinja reads \\d as an escaped backslash and matches nothing |
The acceptance test for any role change is changed=0 on a second
appctl install. If it reports changes, one of the above is the reason.
Verified state¶
Measured on Proxmox VE 9.2.3:
- Base Ubuntu 26.04, Python 3.14.4
appctl install <app>→ a settled run reportschanged=0appctl build <app>→ byte-identical wheel for the same commit
These are observations from one node, not a benchmark. Do not quote them as a comparison with another deployment system.
Drift repair in one re-run: deleted entrypoint, stopped and disabled unit, deleted state directory. A first settle after a real change takes longer, because the built tree crosses the link.
Before you commit¶
ansible-playbook --syntax-check playbooks/site.ymlappctl secrets check— a run needs thesegit statusmust not show.env,tmp/,apps/*/files/dist/,tools/, orinventory/appctl install <app>twice: the second must bechanged=0scripts/sync_state.sh --checkreports no difference — the private record is current- after an install, the state repository's diff shows only the
state:block changed, never the comments inapp.yaml pytest testspassespython scripts/build_site_docs.py --clean && mkdocs build --strict— if you changed a document, a link, or a diagram.python scripts/serve_docs.pydoes the same and watches the sources.
The inventory and the manifest are not in this git¶
Two files describe every install on this machine, and both are gitignored:
| File | Holds |
|---|---|
app.yaml |
the applications, their targets, source pins, secret names, and the state: block each build and install writes back |
inventory/ |
the operator's own Proxmox host, guest IDs, groups, app_roles, per-guest sizing |
inventory/ names machines only the operator can reach; app.yaml names their
VMIDs and their credentials by name. Nothing in either is a credential, and none
of it is public either — which is exactly why neither belongs in a repository
that a stranger can clone.
The committed templates are inventory.example/ and app.example.yaml, and a
fresh clone creates the real files from them:
mkdir -p inventory/group_vars
cp inventory.example/hosts.yml inventory/hosts.yml
cp -r inventory.example/group_vars/. inventory/group_vars/
cp app.example.yaml app.yaml
Three things follow from the files being absent on a fresh clone, and all three are expected rather than broken:
appctl listreports every application as "no guest", because it finds noapp_roles. That is the truth about a clone with no inventory.- There is nothing to deploy: no manifest means no application to name.
- The CI syntax check runs against
inventory.example/hosts.yml, which exercises the same playbook and the same role paths.
Where they are actually recorded¶
Ignored is not the same as unrecorded. Both files are synced to the operator's
private deployflow-state repository by scripts/sync_state.sh, which
copies them, commits what changed, and pushes. The record survives this machine;
a fresh clone of this repository does not.
Any change to app.yaml or inventory/ ends with a sync. The
deployflow-add-app skill runs it as its last step; an agent changing either
file without that skill runs the script itself.
./scripts/sync_state.sh --check # exit 1 if the record has drifted
./scripts/sync_state.sh # record it
The script copies nothing else. .env holds the only credentials and is never
read by it, and it refuses to stage .env, key material or a PEM block. It
never creates the repository — that is deployflow-init's job — and a push that
fails leaves the commit on the local clone for the next run to push.
Do not commit either file here. Both are in .gitignore, so git add
would need -f — and if a file from either lands in a commit, treat it as a
leak. The host names and guest IDs are not secret, but they are not public.