Skip to content

Architecture

How appctl fits together. Read ../README.md for why it is built this way and ../AGENTS.md before changing it.

Data flow

What happens when you run appctl install. One question per diagram: what happens, in order; then where a secret goes.

Hold "Alt" / "Option" to enable pan & zoom
flowchart TD
    CFG["app.yaml<br/>the one configuration file"]
    LOAD["config.load<br/>parse and validate"]

    SEC{{"secrets check<br/>required keys present in .env ?"}}
    STOP["STOP — names the missing keys<br/>before anything is touched"]

    RENDER["render<br/>YAML to JSON on this laptop"]
    OUT["tmp/rendered/app/config.json"]

    VARS["one temp JSON, mode 0600<br/>secrets and rendered config paths"]
    ANS["ansible-playbook -vvv<br/>extra-vars from the file, never argv"]

    PLAY1["PLAY 1 — common/lxc<br/>pct create, discover the DHCP lease"]
    PLAY2["PLAY 2 — apps/app<br/>install, configure, start"]

    LOGS["tmp/runs/TIMESTAMP/<br/>commands.txt log.txt recap.txt errors.txt"]

    CFG --> LOAD
    LOAD --> SEC
    SEC -->|no| STOP
    SEC -->|yes| RENDER
    RENDER --> OUT
    OUT --> VARS
    SEC --> VARS
    VARS --> ANS
    ANS --> PLAY1
    PLAY1 --> PLAY2
    PLAY2 --> LOGS

    classDef fail fill:#fde8e8,stroke:#c0392b
    classDef file fill:#eef4ff,stroke:#4a6fa5
    class STOP fail
    class CFG,OUT,VARS,LOGS file

Three things are worth noticing.

Nothing for configuration runs on the guest. render happens on your laptop. The play copies a finished file. An application needs no code of its own to be configured.

The extra-vars file is flat. --extra-vars @file does not unwrap an ansible: key; that namespace is a config-file convention. A wrapped file is accepted without complaint and defines nothing, so every secret arrives empty while the controller holds good values. Measured both ways.

Rendered-config paths travel in the same file as the secrets rather than on the command line. They are controller paths and harmless, and keeping the whole variable set in one place means one file to delete.

Where a secret goes

appctl install is the same command, followed through time. The value crosses three boundaries and never reaches a log.

Hold "Alt" / "Option" to enable pan & zoom
sequenceDiagram
    autonumber
    participant OP as "Operator"
    participant LC as "appctl"
    participant ENV as ".env on the laptop"
    participant ANS as "ansible-playbook"
    participant GUEST as "The guest"
    participant APP as "The application"

    OP->>LC: appctl secrets set NAME value
    LC->>ENV: write, mode 0600, gitignored
    Note over ENV: the only place the value exists in plain text

    OP->>LC: appctl install
    LC->>ENV: read
    ENV-->>LC: the values
    LC->>LC: fail here if a required one is unset
    LC->>ANS: extra-vars from a temp 0600 file
    Note right of ANS: never in argv, so never visible in ps

    ANS->>GUEST: write the EnvironmentFile, mode 0600, owned by root
    GUEST->>APP: start the unit
    APP->>APP: resolve dollar-brace NAME from the EnvironmentFile

    Note over LC,APP: the value crosses three boundaries and never enters a log

In a rendered config file a secret appears only as a dollar-brace NAME reference, which the guest resolves at startup. That is what makes a config file safe to commit.

appctl build is a separate path and does not appear above: it produces the wheel and the lockfile that the play later installs. See build-pipeline.md.

Modules

Module Owns Knows about applications?
config.py the schema of app.yaml and nothing else no
targets/ where and how an application runs no — a target is not an application
secrets.py .env parsing, writing, checking no
render.py YAML → JSON with {{ }} substitution no
build.py wheel + lockfile from a git checkout no — a repo is a repo
install.py running Ansible, streaming, redaction no
state.py writes the state: block into app.yaml, as text no
cli.py argument parsing, dispatch, exit codes no
errors.py the exception hierarchy no

Dependencies flow one way. cli depends on everything. install and render depend on config. Nothing depends on build except cli. There is no import cycle and no module imports two others for convenience.

config.py knows the three source kinds — package, repo, toolchain — because the schema has to accept all three. It knows nothing about any of them beyond whether they have an artifact, because a kind that had a per-application behaviour would put the application in the framework.

Why config.py is the centre

Every other module needs the same three things: which applications exist, where the repository root is, and what each one requires. That is Config. Parsing it in one place, with one strictness policy, means a typo in app.yaml fails once and names the key — rather than surfacing later as a service listening on the wrong port.

Why the boundary holds

The repository is split into two role roots:

common/   the framework — names no application
apps/     one directory per application

src/appctl/ sits on the framework side. The boundary is not decorative: it is what makes adding an application safe.

The cost. Some things that would be convenient are unavailable. The tool cannot pre-check that an application's unit name is correct, cannot know that example-app needs two ports, cannot offer a --example-app flag. Each of those would be a piece of the framework that knows about an application, and the second application needing the same thing would turn it into either a duplicate or a conflict.

The benefit. Adding an application cannot break an existing one, because nothing shared changed. apps/example-app and apps/another coexist with no shared code beyond the contract, and each derives every path from app_name.

The app_name clause in the contract follows from the same rule. The framework declares no default for it, because a role's meta dependency has its defaults applied after the including role's own — so a framework default would silently override the application's. That was masked while there was one application, because its name happened to equal the default.

Extension points

Where a change belongs:

You want to… Put it in
configure an application differently apps/<app>/config.yml and its app.yaml config: block
install a different artifact apps/<app>/ and the source: block
require another secret the secrets: list in app.yaml
change how guests are created common/lxc/
support another delivery target src/appctl/targets/ — see targets.md
change the venv contract common/python/
add an application apps/<app>/ — see the deployflow-add-app skill
add a appctl command src/appctl/cli.py plus a new module
teach the tool an application name nowhere. Put it in apps/.

Invariants

These must hold. Each was a real failure before it became a rule.

A secret never reaches a log. Ansible echoes extra variables back at -vvv from two places: the argument vector and its own extra_vars: output. install.py rewrites both. Verify:

appctl install <app> -- -e some_secret=PLAINTEXTVALUE
grep -rl PLAINTEXTVALUE tmp/        # must print nothing

A second run reports changed=0. A role that reports a change on every run is reinstalling itself, which tells you nothing about its own state. The usual cause is a sentinel that can never succeed — it looks like a guard and silently holds a gate open. 'changed' in "unchanged" is True; an import of a name that does not exist always fails; a glob read by next() twice raises StopIteration.

An unknown key in app.yaml is an error. A misspelled key is otherwise a silent no-op.

An unimplemented target fails before any side effect. A manifest that names vm or docker_vm is refused while nothing has been built, downloaded or started. The registry never falls back to proxmox_lxc: an application that asks for a virtual machine must not be silently installed into a container.

The shared code names no target. pct, the container ID, the template and the guest network live in targets/proxmox_lxc.py. A module outside targets/ that mentions one of them is in the wrong place.

A record is written to app.yaml after every build and install, and only the state: block is rewritten — never the whole file, because a YAML round-trip destroys the comments that explain why each value is what it is.

Run logs are never pruned. appctl deletes exactly one file: the temporary extra-vars file. Everything under tmp/runs/ survives.

appctl install X also runs the lxc tag. Play 1 is what discovers the DHCP lease. Without it play 2 cannot resolve the guest at all. It is guarded by a stat, so on an existing guest it costs a few no-op tasks.

A toolchain role declares no dependency on common/python. That dependency asserts app_port is defined, and a build host serves nothing. A role with no dependency still runs, because play 2 loops over each host's app_roles and include_roles it — so the exemption costs no playbook change. What it does cost is the framework's derived paths: a toolchain defines what it needs.

A toolchain's role asserts the machine, not the install. The minimums are measured. A builder sized at 12 GB passed every install check and then failed during a build with an error naming a memory limit rather than the guest — and the guest stopped answering SSH. A role that installs a compiler and reports success has not said anything about whether a build can run on it.