module-manager¶
The module lifecycle manager: install, update, delete, test, reconcile, and snapshot TAPPaaS modules, with tier/source classification lint and environment-aware deployment. It owns the per-module JSON config in config/ and drives the Proxmox cluster (over SSH) to provision and maintain the module's VM.
What it owns¶
- Per-module config JSON in
config/(<module>.json, or<module>-<environment>.jsonfor non-default environments), plus.origbackups used for a 3-way merge of operator edits against release updates. - The
tier(foundation|app) andsource(official|community|private|local) classification on each module JSON, validated againstmodule-fields.json. - The
"kind":"module"tag stamped onto every deployed config at install time — the authoritative markerlist/showuse to tell a deployed module apart from the co-located state files (zones.json,site.json, …). Configs from before the tag fall back to a heuristic (any ofdependsOn/provides/location); provider-only modules (e.g.templates, no vmid/vmname) are kept.
Standardized verbs (ADR-007) — module-manager¶
The module-manager CLI presents the standardized verbs on entity module (the verb-alignment front door). It is a thin orchestrator: the CONFIG-layer verbs (list/show/validate) run directly over config/*.json; the LIFECYCLE verbs delegate to the bash scripts below (which stay live until a later retire phase).
| Verb | Maps to | Notes |
|---|---|---|
module list | — | enumerate deployed modules (--json for the cascade) |
module show <m> | — | one deployed config in full (--json) |
module resolve <m> | src/resolve.ts | desired state: the config plus the schema defaults it does not declare (--json) |
module drift <m> | src/converge.ts | that desired state vs the live guest, per service. --service cluster:vm --json prints the record a converge applies |
module validate [<m>] | tier/source lint | all modules, or one; --allow-fork |
module add <m> | install-module.sh | create + provision |
module modify <m> | update-module.sh | release update (snapshot + test + 3-way merge). --set field=value also changes a declared field first (ADR-020) |
module delete <m> | delete-module.sh | --archive (default) / --remove |
module reconcile <m> | src/inspect.ts | read-only drift report (default); --apply → leaf converge (src/reconcile.ts) |
module test <m> | test-module.sh | --deep, --vmid, --zone0 |
module snapshot-vm <m> | snapshot-vm.sh | special VM op (not CRUD) |
Common options: --config-dir <dir>, --json (list/show/resolve/validate), -h. The leading module entity keyword is optional (it is the only entity).
show vs resolve — show prints the deployed config verbatim; resolve prints what that config means once the module-fields.json defaults for fields it does not declare are filled in. That resolved value is what a converge actually uses, so the two differ exactly where a field is undeclared: show omits cputype, resolve reports host (marked default). There is one resolver behind it, shared by the drift report and the apply path, so the reported desired value and the applied one cannot diverge (ADR-020 D1).
Not to be confused with
resolve-module.sh, which answers a different question — where a module's source directory is.list --resolutionis that one's reporting front door.
Changing a field: modify --set (ADR-020)¶
module-manager module modify nextcloud --set cores=8 --set memory=16384
module-manager module modify nextcloud --set zone0=iot --force
One verb, one algorithm. Bare, modify is the release update update-tappaas already runs. With --set it writes the field into the deployed config first and then runs that same algorithm — snapshot, 3-way merge, converge, test, updateTime. There is no second apply path to keep in step with the first.
What it refuses, and when. A change is refused up front only when the schema alone can say so:
| Class | Example | What happens |
|---|---|---|
in-place | cores, memory, cputype, vmtag | applied live, no downtime |
grow-only | diskSize | a grow applies; a shrink is refused at apply time |
in-place-reboot | zone0, bridge0 | needs a guest reboot → deferred unless authorized |
migrate | node | relocates the guest → deferred unless authorized |
manual | storage | reported; moving a disk stays an operator action |
immutable / recreate | vmid, bios, image* | rejected before anything is written |
A --set naming an immutable field is rejected whole — if any field in one command cannot be applied, none of them are written, so config and cluster never move apart. A field none of the module's services use is also rejected: writing it would change the config and nothing else.
--force vs rebootOk — three levers that no longer collide¶
Some changes need downtime. Whether we are allowed to cause it is a separate question from whether the change needs it, and it has exactly two answers:
module modify <m> --force— an operator, now.rebootOk: trueon the module, honoured only inside the unattended sweep, and only because the site already accepts downtime in that window (automaticReboot). Defaultfalse: silence never authorizes a reboot.
update-tappaas --force is neither. It means "run the sweep now" — a scheduling override — and is deliberately never forwarded, or a routine hourly update could reboot production guests.
When a disruptive change is not authorized the converge applies everything else, prints a machine-parseable line, and still exits 0 — not applying a change is not a failure:
⚠ DEFERRED: nextcloud net0 needs a disruptive change (reboot/offline migrate) that is not authorized
Apply in a maintenance window: module-manager module modify nextcloud --force
update-tappaas collects those and ends the sweep with one summary of what is still pending.
reconcile vs drift — both compare declared state with reality, for different readers. reconcile <m> is the operator's three-way report (Released[git] / Desired[~/config] / Actual), field by field, plus the dependency-service section. drift <m> is the two-way record a CONVERGE acts on: which apply unit each change belongs to, what class it is, which hook takes it, and what side effects it drags along. drift --service <p:s> --json is literally the input to update-service.sh --apply-drift, so what you read is what would be applied — there is one differ behind both (ADR-020 D7).
reconcile vs modify — reconcile --apply re-applies the existing config (idempotent converge: each dependency's update-service.sh + the module's own update.sh/install.sh, all run from the module directory), with no snapshot, no tests, no 3-way merge, and no updateTime bump. modify (update-module.sh) changes the config via a release update, then performs the same apply by delegating to reconcile --apply, wrapped in snapshot + pre/post tests + rollback. reconcile is the leaf the site/environment reconcile --deep cascade walks down to.
Service contract — services/<svc>/update-service.sh is the converge for an already-installed module and every service must ship one (enforced by test.sh). install-service.sh is create-only prerequisites; where a service has no create-only work it simply execs update-service.sh. There is no fallback from one to the other: install-service.sh has create semantics (cluster:vm's calls Create-TAPPaaS-VM.sh, which refuses an existing VMID), which is why reconcile previously failed on every VM-backed module.
What reconcile <m> (no --apply) reports — a read-only drift report in two parts:
- Config fields — three-way
Released[git]/Desired[~/config]/Actual[running VM]; for a module with novmidthe Actual column is N/A and the diff degrades to Released-vs-Desired. - Dependency-service state — for each
dependsOnentry, that provider's read-onlyservices/<service>/test-service.sh <module>(the same verifiermodule testruns): declared firewall rules, NAT rules, discovery relays. For a policy-only module (no VM) this is the whole module, so without it a clean field diff said nothing.--no-servicesskips it.
Detected drift exits 0 — this is a report, and list --diff plus the --deep cascade propagate the rc. A check that could not run (missing or non-executable test-service.sh) exits 1: unknown state is not clean. A provider that ships no test-service.sh is reported as NOT checked, never as passing.
The service checks cost one child process — usually one firewall API round-trip — per dependency, so they are on for a single reconcile <m> and off for the fleet/cascade paths: list --diff needs --services to include them (and says so when it does not), and the environment reconcile preview passes --no-services.
module-manager module list
module-manager module show nextcloud --json
module-manager module validate --allow-fork
module-manager module add nextcloud --environment acme
module-manager module reconcile nextcloud # report: fields + dependency services
module-manager module reconcile nextcloud --no-services # report: fields only
module-manager module reconcile nextcloud --apply # converge to the current config
module-manager module list --diff --services # fleet rollup, services included
Underlying scripts¶
All bash, linked onto PATH by install.sh. These remain the source of truth (the manager verbs orchestrate them) until a later retire phase.
install-module.sh — install a module¶
install-module.sh <module-name> [--environment <name>] [--allow-fork]
[--force] [--reinstall] [--<field> <value>]...
--environment <name>— target environment (sets the VM name and zone; default env →<module>, otherwise<module>-<env>).--variant <name>is a deprecated alias.--allow-fork— permit atier:foundationmodule from a non-officialsource.--force— re-run against an existing install.--reinstall— delete then install (recover a failed partial install).--<field> <value>— override any module JSON field.
update-module.sh — update a module¶
--environment <name>— resolve the installed config name (deprecated alias--variant).--force— proceed despite a failing pre-update test.--no-snapshot— skip the pre-update snapshot / rollback.--debug,--silent.
It snapshots the VM, tests, updates, and rolls back on a fatal failure.
delete-module.sh — delete a module¶
delete-module.sh <module-name> [--archive|--remove] [--vmid <id>]
[--environment <name>] [--yes|-y] [--force]
--archive(default) keeps the config;--removedeletes it.--vmid <id>— target a specific VMID.--environment <name>(alias--variant).--yes/-y— skip the confirmation prompt.--force— skip dependency checks; required fortier:foundationmodules.
test-module.sh — run a module's tests¶
snapshot-vm.sh — manage a module's VM snapshots¶
No action = create a snapshot. --list lists; --cleanup <N> keeps the last N; --restore <N> restores N steps back (1 = most recent).
copy-update-json.sh — copy/normalize a module JSON into config¶
copy-update-json.sh <module-name> [--variant <name>] [--environment <name>]
[--default-environment <name>] [--vmname <v>] [--vmid <v>]
[--zone0 <v>] [--proxyDomain <v>] [--<field> <value>]...
Applies environment defaults (vmname suffix, auto-incremented vmid, zone from the environment), validates fields against the schema, and writes canonical config-block form.
module-format.sh — convert JSON form¶
validate-module-tier-source.sh — tier/source lint¶
tier:foundation requires source:official (override with --allow-fork); invalid tier/source enums are rejected; source:community warns. Used standalone and at install time.