| description | Non-obvious itsup CLI semantics not visible in --help — the run/down orchestration order, apply's fail-closed validate gate plus sequential topo-ordered deploy with label-based hash skip, and the only codified exit-code semantics (0/1 plus passed-through subprocess returncodes). |
|---|
itsup is the itsUP management CLI (itsup.cli:main, a Click group registering
the commands in commands/). This spec records only the behaviors that
itsup --help and the per-command --help do not reveal; distribution and
invocation are in local/projects/itsup/design/itsup-cli, and per-context secret loading is
in local/projects/itsup/spec/secrets-management.
-
itsup runstarts the stack in dependency order: DNS → proxy → API → monitor. The two container stacks come up throughdocker compose; the API and the monitor are daemon units the host supervisor owns, sorunstarts them through it rather than spawning processes of its own. The monitor starts in report-only mode —runwrites that mode into the supervisor-read flags file before starting the unit — and full blocking protection requires a separateitsup monitor start. Before starting anything,runregenerates proxy artifacts (write_proxy_artifacts()) and aborts if that fails. At boot it intentionally does not pull images (the host's own DNS may not be up yet). On any stack failurerunexits with that stack's subprocess returncode. On a host with no monitor support (macOS — the monitor is Linux-only)runskips the monitor step with a notice and continues. (commands/run.py) -
Startup ownership. Neither daemon is boot-activated: on Linux the
itsup-apianditsup-monitorunits carry no[Install]section and are never enabled; on macOS the API agent's plist is written but not bootstrapped by the installer, since aKeepAlivejob starts as soon as it is bootstrapped.itsup-bringup.serviceis the exception and is unchanged by this: it does carry[Install], is enabled, and is restarted when its definition changed or it was inactive — which is how boot brings the stack up at all. The paths that start or restart a daemon are:itsup run— the ordered, whole-stack path: the only thing that activates the daemons as a set, in the order above. Installation reaches it transitively when it restarts (systemd) or bootstraps (launchd) bringup — which it does when the bringup definition changed or bringup was inactive. Writing a unit file and reloading systemd does not execute it.itsup monitor start/stop— the operator's explicit monitor control, transitioning that one unit through the supervisor and touching nothing else.- the API's supervisor-owned self-restart, used by the self-update path to replace a running API in place.
- the supervisor's own crash respawn — automatic recovery rather than an operator control surface.
An install issues no per-daemon supervisor verb — it never starts, stops, restarts or reloads the API or the monitor individually. It writes their definitions, and it reaches the ordered
itsup runby a route that differs by supervisor:- Linux — transitively, by restarting bringup when that unit changed or
was inactive; or directly, while the host's supervision cutover is still
pending and bringup is unchanged and active, which is what makes
make install-runtimeleave a first-cutover host live. systemd retries nothing here: the next install does. - macOS — never directly. While the cutover is pending the installer
reloads the bringup agent even when its plist is unchanged, so its guardian
performs the run, and then waits for the outcome; launchd relaunches a failed
guardian, so it owns the retry. Once that cutover has completed the installer performs no direct run
again. The transitive path is unchanged and unconditional: whenever the
bringup definition changed or bringup is inactive, every install restarts it
(
bin/install-bringup.sh:192) and itsExecStart=itsup runstarts the stack — so a daemon the operator stopped survives an install only when bringup is unchanged and active. A written definition is therefore inert until the daemon is next brought up in a way that re-reads it, which is what both supervisors do natively: writing a unit file and reloading systemd never restarts a running service, and writing a plist does not reload a loaded job. The paths that apply it differ by supervisor and, withinrun, by daemon: - Linux API —
sudo systemctl restart itsup-api, or the API's self-restart. A bareitsup rundoes not apply it:run's API step is a start verb, which no-ops on an active unit. - Linux monitor —
itsup monitor start,sudo systemctl restart itsup-monitor, or anyitsup run, whose monitor step rewritesMONITOR_FLAGSand restarts the unit (and therefore also resets the mode to report-only). - macOS API —
itsup downthenitsup run, the only sequence that unloads and re-registers the job.
A crash respawn follows the same split: under systemd the auto-restart uses the manager's reloaded unit, so it applies a changed definition; under launchd it relaunches the still-registered job, so it does not. Where no bringup action occurs, a daemon the operator deliberately stopped stays stopped across an install.
-
itsup downstops in the reverse order: monitor → API → all upstream projects → proxy → DNS. Upstream projects are stopped in parallel (ThreadPoolExecutor, max 10); the infra stacks are stopped sequentially. The monitor and API are stopped through the supervisor, and a unit that is already inactive is not an error; proxy/DNSdownfailures are logged but not fatal —downcompletes regardless.--cleanadditionallyrm -fs the stopped itsUP containers (projects + proxy + dns) in parallel and never touches non-itsUP containers. (commands/down.py)
applyfirst runs a global, fail-closedvalidate_all()design-by-contract gate: a single invalid project or cross-project collision blocks every deploy (exit 1) — evenitsup apply <one-project>is refused while any project is invalid. (commands/apply.py:45)itsup apply(no argument) deploys sequentially in topological order (list_projects_topo()), prefixed bydnsthenproxy— it is not parallel. It collects failures and exits 1 if any target failed, after attempting all of them. (commands/apply.py:90)- Change detection compares the live
docker compose config --hash <service>against the running container'scom.docker.compose.config-hashlabel — there is no stored hash file. Per stateless service, rollout is skipped when the hash matches, when the service was not running before (first-time deploy gets a plainup -d, no rollout), or when it is not the--servicefilter target. A failed individual rollout is logged and does not fail the deployment. (lib/deploy.py:62,lib/deploy.py:257)
itsup logs [TARGET] is the single entry point to itsUP's host-side logs. Each
target names a producer, and the command routes it to whichever backend actually
holds that producer's records; the operator never needs to know which. It is
host-only (see the gate below) and read-only.
Bare invocation is discovery: itsup logs with no target prints every
routable target with its description, one pair per line, and exits 0. An
unrecognised target exits 1 and prints the routable set. Every rejection the
router owns — unknown target, malformed option value, unavailable backend — exits
1; the CLI declares no exit-2 contract, so parameter failures are translated
rather than left to the parser's own usage exit.
Targets and their backends. Six supervised-unit targets — api, monitor,
bringup, apply, backup, healthcheck — plus the file-backed access
(Traefik's JSON access log under logs/, emitted verbatim: the router is a raw
passthrough reader of that file and formats nothing, because the record's single
JSON format is what makes it CrowdSec's parseable feed).
The unit targets resolve per platform, following the supervision contract in
local/projects/itsup/design/logging: on Linux to journalctl -u <unit>; on macOS to the
launchd agent's StandardOutPath, read from the installed agent's own plist so
the path comes from what launchd actually loaded. monitor and healthcheck are
Linux-only daemons and have no macOS agent, so on macOS they are refused with
that reason rather than routed to a file that will never exist.
There is deliberately no <project> target — container logs stay docker-led
through itsup svc / proxy / dns — and no cli target: interactive runs
are observed live, and their diagnostic file is read with instrukt-ai-logs itsup.
One option surface, translated per backend. -f/--follow, --since, and
--grep mean the same thing on every target. They select which lines are
emitted; none of them changes a line's content, on any backend. Every line the
router prints is the line its backend produced.
--sincetakes the duration grammar<n>{s,m,h,d}. The journal's own--sincerejects a bare duration, so the router parses the duration and hands the journal an absolute timestamp. Theaccessbackend has no such grammar to delegate to, so the router applies the same cutoff itself, reading each JSON record's own timestamp field (time, elseStartUTC) to decide inclusion and then emitting the record unchanged. A record whose timestamp field is absent or unparseable is excluded: the router cannot prove it is inside the window, and admitting it would silently widen the window that was asked for.--grepis a case-sensitive Python regular expression, applied by the router itself to each complete line just before it is printed — on every backend. Foraccessthat line is the raw JSON record, so a pattern can match a field name as readily as a value. The journal's own-gis not used: it matches only the record's message field, while the line the operator sees also carries the supervisor's timestamp and identity, and its smart-case default would make the same pattern mean different things on different backends.--followis future-only everywhere: it streams what arrives after the command starts and never replays history first. The journal is asked for-n 0alongside-fso it does not emit its recent window, matching the file backend, which follows from the end of the file. Followingaccesssurvives the host'scopytruncaterotation of that file (local/projects/itsup/design/logging).- An invalid duration or an invalid regex is rejected at the option boundary with exit 1 before any backend is reached.
--since has one designed exception: macOS unit targets. Filtering by time
requires a per-line time, and the supervisor is what supplies it — journald does,
launchd does not. A launchd agent's StandardOutPath captures the daemon's
stream verbatim, and that stream deliberately carries no timestamp because the
supervisor is expected to add one. So --since against a macOS unit target exits
1 naming that reason, while --follow and --grep remain available there. The
access target is unaffected on either platform: its record carries its own
RFC3339 timestamp field, which is what the router reads.
Every unavailability path refuses, never degrades. A unit target with no
reader on this platform, and a unit target whose unit is not installed, each exit
1 naming the reason. Unit existence is established before the query — a unit is
usable only when its LoadState is loaded, and any other value is reported
rather than interpreted — because a journal query against an absent unit succeeds
with empty output.
The CLI group refuses runtime-mutating commands on any machine that is not the
container host, before the command does any work. The host is the machine whose
own LAN IP equals SSH_HOST in .env — read from the file via load_env_file,
not from os.environ — and the LAN IP is detected with a UDP-socket probe.
The gate is fail-closed: an unset, empty, or non-matching SSH_HOST, or a failed
detection, denies. It runs once for the invoked subcommand in the group callback,
keyed on ctx.invoked_subcommand against a single host-only set — so it precedes
per-command argument parsing, and off-host even itsup <host-only-cmd> --help is
refused.
-
Host-only (refused off-host, exit 1):
run,apply,down,dns,proxy,svc,monitor,logs.make install-runtimerefuses off-host before it touches systemd/launchd (bin/install-bringup.sh). -
Available anywhere (GitOps + config + secrets + read):
pull,commit,status,create,init,validate,migrate,encrypt,decrypt,diff-secrets,edit-secret,sops-key,projects.
The gate is not self-grantable: there is no bypass flag or override env var, and
the refusal message advertises no escape hatch. The allow/deny split lives in one
place. (itsup/cli.py, lib/host_gate.py)
- Each deploy/secret command loads secrets for one context only (project
secrets for a project deploy;
itsupinfra secrets otherwise) — they are never merged — and prefers*.enc.txtover*.txt. Full contract:local/projects/itsup/spec/secrets-management. (lib/data.py:34)
itsup projectsprints the configured project names, one per line, to stdout — the discovery entry point before listing or editing a project's files.itsup projects <name>prints the files that constitute the project: every regular file recursively belowprojects/<name>/, deterministically sorted, plus the project'ssecrets/<name>.{enc.txt|txt}when present, one per line. An unknown name exits 1. Both forms are read-only and sit on the anywhere-allowed side of the host gate.- Every file location the CLI reports is usable from the caller's cwd: printed
absolute when the process cwd is not the install root, and may stay relative
when it is. The guarantee is universal — every command, in success output,
error text, and next-step guidance alike — because a location is resolved and
rendered in exactly one place:
lib/paths.pyowns the install root, the data trees under it (secrets_dir,secret_file,sops_config_file,projects_dir,project_dir), and the rendering (display_path). A literal data-tree path in output is a defect: it is correct only for a caller standing in the install root, and the CLI is installed globally. commitis non-interactive: plaintextsecrets/*.txtfiles present at commit time are encrypted in-process (plaintext deleted on success) before committing; when SOPS is unavailable while plaintext exists,commitfails with exit 1 instead of committing — a plaintext edit is never silently dropped by thesecrets/gitignore.--forceonly skips the rebase and force-pushes; it never skips encryption.edit-secretis interactive and human-only: without a TTY on stdin it refuses (exit 1) and prints the non-interactive round-trip (decrypt→ edit →encrypt --delete→commit).- The intended agent workflow is discoverable from the CLI itself: the group help names the GitOps flow (pull before editing; re-encrypt before commit).
The CLI emits only these — there is no 2/3/130 contract.
- 0 — success.
- 1 — validation failure, target not found, or a handled command error
(
sys.exit(1)). - passed-through subprocess returncode —
run/down/svcand similar surface the failingdocker/script returncode directly (sys.exit(e.returncode)). diff-secretsis diff-style: exits 1 when differences are found, 0 when identical (commands/diff_secrets.py:144).
applydeploys projects sequentially in egress-topological order (commands/apply.py), not in parallel.- Change detection compares the live
docker compose confighash against the running container'scom.docker.compose.config-hashlabel (container-label based), not a hash stored on disk. - Runtime-mutating commands (
run,apply,down,dns,proxy,svc,monitor) and the read-onlylogsrouter are host-only and refuse fail-closed off-host (detected LAN IP ≠SSH_HOST); see the host-identity gate above.logsis gated not because it mutates but because every backend it reads exists only on the container host.
- docs/local/design/itsup-cli.md
- docs/local/spec/secrets-management.md
- docs/local/design/deployment-orchestration.md