Architecture¶
flowchart TD
CLI["internal/cli<br/><small>cobra commands, flag parsing, wiring</small>"]
UI["internal/ui<br/><small>plain and json presenters</small>"]
LC["internal/lifecycle<br/><small>operations as step sequences, the engine</small>"]
P["internal/ports<br/><small>interfaces, declared by the consumer</small>"]
A["internal/adapters<br/><small>compose · sops-age · hooks · sources · verifiers</small>"]
D["internal/domain<br/><small>manifest, release, installation, errors</small>"]
I["internal/infra<br/><small>exec, atomicfs, lock, state, logging</small>"]
CLI --> UI
CLI --> LC
CLI --> A
UI --> LC
LC --> P
A --> P
LC --> D
A --> D
P --> D
LC --> I
A --> I
Dependencies point downward only. internal/domain imports nothing from this
repository and nothing beyond the standard library and a semver parser.
The rule that does the work¶
The lifecycle layer speaks only to ports, and internal/cli is the single
place an adapter is named. Nothing above internal/adapters decides anything by
asking which container runtime is running.
This is not a style preference. It is what makes the entire test suite run without Docker, without root and without a network — which is what makes a fault-injection suite that kills an operation at each of eleven steps a loop rather than an afternoon.
It is enforced, not encouraged¶
depguard in .golangci.yml fails the build on a violation. That enforcement
is load-bearing rather than decorative: when the rule was first widened to cover
the whole lifecycle layer it immediately found three real violations in
lifecycle/ops, which was reaching for the hooks, sops-age and systemd adapters
directly. The hook ABI, the machine identity operations and unit rendering now
sit behind HookRunner, SecretStore and Supervisor.
A layering rule nothing checks is a paragraph in a document that the next refactor quietly violates.
Two checkers, because one of them was doing less than it looked like¶
depguard is an import linter. It answers "may this package depend on that
one", and nothing else — so a file in internal/ports whose exported API is the
Docker Compose interpolation contract passes it without complaint, because that
file imports nothing at all.
That gap was found by asking what would happen if a second runtime were added,
and it is now covered by a second checker. just runtime-check fails the build
on a name above internal/adapters that says which runtime is running, on a
string whose value is a runtime's name, or on a comparison against one — the
middle rule because const defaultRuntime = "compose" has a neutral name and a
neutral comparison, and would otherwise walk past the other two.
The mentions that exist are an inventory rather than a suppression list. Each is
classified as port-shaped (the concept is general, only the name is borrowed),
Compose-shaped (the concept is Compose's own and has to move), or catalogue
(a runtime named as data, which a second runtime extends rather than
contradicts). just runtime-inventory prints it, and an entry left behind after
its leak is fixed fails the build too — a list checked in one direction only ever
grows.
Two checkers, two guarantees, and neither implies the other: imports say what a
package may reach for; names say what it believes it is talking to. Both are
narrower than "the architecture is correct", and the leak that matters most — a
Compose concept wearing a neutral name, like the manifest's project — is found
by reading rather than by either.
Interfaces are declared by the consumer¶
internal/ports holds the interfaces, and none of them are written by the thing
that implements them. Runtime exists because the lifecycle layer needs to
start services, not because Compose has an API worth wrapping. That is why the
methods are the ones an operation actually calls, and why an adapter that
implements them is bounded work with bounded risk: implement the interface, pass
the contract suite, register the name.
Optional capabilities¶
Some things are true of one adapter and not of another. A store backed by a KMS has no identity file to swap; a runtime with no local image store cannot say whether an image is present.
Those live as separate interfaces that a caller type-asserts for:
| Capability | What needs it |
|---|---|
RecoverableSecretStore |
installation export / import |
RegistryProber |
doctor's registry check |
ImageInspector |
doctor's offline-readiness check |
An operation that needs one asserts for it and refuses by name when the configured provider does not offer it — rather than every adapter carrying a stub for something it cannot honestly do.
Two deliberate departures¶
Both documented at the top of the packages concerned, because both look wrong until you know why:
internal/eventssits besidedomain, not underlifecycle.ports.Notifiertakes anEvent. If that type lived inside the lifecycle layer,portswould have to import the layer that consumes it, and the dependency arrows would stop pointing downward.internal/releaseowns manifest parsing.domainis restricted to the standard library and semver, so a YAML decoder cannot live there. Domain keeps the types and the validation rules; this package turns bytes into them.
Presenters are subscribers, never participants¶
The engine publishes events; internal/ui consumes them. There is no
back-channel — a presenter cannot influence control flow, and a panic in one is
logged and dropped rather than aborting an update that is midway through
migrating a database.
Plain output is the reference for what must be conveyed. Anything richer is motion added to information that was already complete.