Files
computer-playbook/cli/administration/deploy/development
kevinveenbirkenbachandClaude Opus 5 435ff42219 fix(storage): measure the docker root from the caller's namespace
docker info reports DockerRootDir as the DAEMON sees it, and
host_storage_constrained fed that string straight into shutil.disk_usage,
which statvfs()es it in the CALLING process's mount namespace. The two
coincide only when the caller is the daemon's host. In the nested act
container of the workspace jobs they do not, so os.statvfs('/var/lib/docker')
raised FileNotFoundError, matrix.py died, and because 00_one.sh invokes it
bare under set -euo pipefail the whole swarm drill went down with it. All
five workspace jobs of run 30560683608 failed this way, byte-identically,
across five base images and five nested distros.

docker_root_free_bytes() now takes the vantage point as a required keyword:
it measures the daemon's data root when that path resolves here, and the
caller's stated local path when it does not. matrix.py passes "/", which is
the same vantage it already trusts for its ENOSPC abort 270 lines earlier;
overlayfs passes statfs through to the upper filesystem, so a container of a
daemon measures that daemon's data-root filesystem through its own rootfs.

Nothing is caught and nothing is guessed. An unreachable daemon still raises
RuntimeError out of docker_data_root(), which is the guard this module exists
for, and local_vantage carries no default because only the caller knows where
it is running.

The compose-side twin loses its || true and its hardcoded /var/lib/docker
substitution, so an unresolvable root no longer measures a guessed filesystem.
Its compose.exec locality was already correct and is unchanged.

Verified against the pre-fix module under the CI condition: with the data root
stubbed to a path absent from this namespace, the old code raises
FileNotFoundError and the new code measures the local vantage; with the daemon
stubbed unreachable, the new code still raises RuntimeError.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:59:09 +02:00
..
…
…
…
…
…

Development matrix deploy

Iterates per-role variants against a development inventory. See below for how the include set is resolved and where disable= prunes it.

Include resolution, disable, and orphan pruning

How a primary app list becomes the per-round inventory include set, and where disable= prunes the transitive closure.

Deploy entry points

flowchart TD
    MK["make compose-deploy apps=… disable=…"] --> MAIN["scripts/.../deploy/main.sh"]
    MAIN -->|mode=initialize / reinstall| INITSEL["apps/initialize/selection.sh"]
    MAIN -->|mode=update| UPDSEL["apps/update/selection.sh"]

    INITSEL --> INIT["cli … deploy development init --apps … (disable via env)"]
    INIT --> DEPLOY["cli … deploy development deploy --apps …"]

    UPDSEL --> UPDEXEC["container/update/selection.sh (existing inventory)"]

    INIT -.->|builds & prunes inventory| INV[("per-round inventory dir(s)")]
    UPDEXEC -.->|reuses inventory, disable = ansible extra-var only| INV
  • initialize / reinstall rebuild the inventory through init.py; this is the only path that prunes the include set. deploy re-derives the same pruned set for its deploy ids (see below).
  • update reuses the existing inventory; disable reaches ansible as an extra-var that renders services disabled, but does not touch the include.

init.py: plan then prune

flowchart TD
    A["--apps → primary_apps"] --> B{"disable set?"}
    B -->|yes| C["parse_services_disabled → find_provider_roles<br/>→ disabled_app_ids"]
    C --> D["primary_apps −= disabled_app_ids"]
    B -->|no| E
    D --> E["plan_dev_inventory_matrix(primary_apps)"]
    E --> F["per round: include_R = variant-merged closure"]
    F --> G{"disable set?"}
    G -->|no| H["round_include = include_R"]
    G -->|yes| I["prune_orphans_after_disable(include_R,<br/>primary_apps, disabled_app_ids, round_overrides)"]
    I --> J["(kept, pruned)"]
    J --> K["round_include = kept<br/>log pruned"]
    H --> L["build_dev_inventory(round_include)"]
    K --> L

deploy/cli.py: re-derive the same include

init.py writes the inventory; deploy/cli.py decides which application ids to hand to cli.administration.deploy.dedicated. Both derive their per-round set from the same plan_dev_inventory_matrix closure and MUST apply the same prune_orphans_after_disable cut with the same round-merged services map. validate_application_ids rejects any id that is absent from the inventory, so a deploy-side set wider than the init-side set aborts the run before the first role deploys.

flowchart TD
    PLAN["plan_dev_inventory_matrix(primary_apps)"] --> IR["include_R (per round)"]

    IR --> INITP{"disable set?"}
    INITP -->|yes| IP["prune_orphans_after_disable"]
    INITP -->|no| IK["include_R"]
    IP --> IW["build_dev_inventory → inventory dir"]
    IK --> IW

    IR --> DEPP{"disable set?"}
    DEPP -->|yes| DP["prune_orphans_after_disable<br/>(same round_overrides)"]
    DEPP -->|no| DK["include_R"]
    DP --> DD["round_deploy_ids"]
    DK --> DD

    DD --> VAL{"validate_application_ids<br/>vs inventory"}
    IW --> VAL
    VAL -->|equal sets| RUN["ansible-playbook"]
    VAL -->|deploy ⊃ inventory| ABORT["exit 1: not present in inventory"]:::abort
    classDef abort fill:#fee,stroke:#c00;

Both sides pass primary_apps (not the round closure) as the BFS seed and the variant-merged services map for that round, so the reachability walk sees the same topology the inventory baked.

Closure resolution (per round)

plan_dev_inventory_matrix → _resolve_round_include runs CombinedResolver(follow_run_after=False) over each primary app and unions the results.

flowchart LR
    subgraph edges["prerequisites(role)"]
        RA["run_after<br/>(NOT followed for inclusion)"]
        DEP["dependencies<br/>(app roles only)"]
        SVC["services<br/>(meta/services.yml flags)"]
    end
    SVC --> GATE{"_is_enabled_shared:<br/>is_explicit_truth(enabled)<br/>AND is_explicit_truth(shared)"}
    GATE -->|edge exists| PROV["provider role pulled"]
    GATE -->|else| DROP["not included"]

is_explicit_truth treats both forms as truth at resolve time:

enabled / shared value resolve-time truth
literal true yes
"{{ '<role>' in group_names }}" yes
literal false no

So a conditional "{{ 'web-app-matomo' in group_names }}" edge pulls its provider into the static closure regardless of whether web-app-matomo is actually selected. The closure is the co-deploy superset.

Example: web-svc-cdn closure

web-svc-cdn's only truthy service roots are matomo and prometheus (both conditional). Everything else hangs beneath them.

flowchart TD
    CDN["web-svc-cdn (primary)"] --> MAT["web-app-matomo"]
    CDN --> PRM["web-app-prometheus"]
    MAT --> KC["web-app-keycloak"]
    PRM --> KC
    MAT --> MAILU["web-app-mailu"]
    KC --> PG["svc-db-postgres"]
    KC --> MDB["svc-db-mariadb"]
    KC --> LDAP["svc-db-openldap"]
    MAILU --> MDB
    KC --> CSS["web-svc-css"]
    KC --> LOGOUT["web-svc-logout"]
    MAT --> DASH["web-app-dashboard"]
    CDN --> FILE["web-svc-file"]
    CDN --> ASSET["web-svc-asset"]
    CDN --> SIMPLE["web-svc-simpleicons"]

Orphan pruning (prune_orphans_after_disable)

BFS from surviving primary apps over dependencies + services edges, cutting every role in disabled_app_ids. Reachable → kept; the rest of include_R (disabled roots excluded) → pruned.

flowchart TD
    SEED["stack = primary_apps − disabled"] --> POP{"pop node"}
    POP -->|in disabled or seen| POP
    POP -->|else| MARK["reachable += node"]
    MARK --> EDGES["push deps + services<br/>(skip disabled, skip seen)"]
    EDGES --> POP
    POP -->|empty| SPLIT["kept = include ∩ reachable<br/>pruned = include − reachable − disabled"]

disable=matomo,prometheus on web-svc-cdn cuts both roots; the whole subtree beneath them is unreachable from the surviving primary (cdn), so kept = (web-svc-cdn,).

flowchart TD
    CDN["web-svc-cdn (survives)"]
    MAT["web-app-matomo (cut)"]:::cut
    PRM["web-app-prometheus (cut)"]:::cut
    KC["web-app-keycloak"]:::orphan
    MDB["svc-db-mariadb"]:::orphan
    PG["svc-db-postgres"]:::orphan
    CDN -.-> MAT
    CDN -.-> PRM
    MAT --> KC
    PRM --> KC
    KC --> MDB
    KC --> PG
    classDef cut fill:#fee,stroke:#c00,stroke-dasharray:4;
    classDef orphan fill:#eee,stroke:#999,stroke-dasharray:2;

Reachability, not syntax

Pruning walks whatever edges the resolver reports; it never inspects enabled for the hard-vs-conditional distinction. A literal enabled: true dependency (e.g. web-app-keycloak → svc-db-postgres) is kept only while its declaring node stays reachable. If the declaring node is cut or orphaned and no surviving node pulls the dependency, it is pruned too.

flowchart LR
    Q{"dep reachable from a<br/>surviving primary?"}
    Q -->|yes| KEEP["kept (hard or conditional)"]
    Q -->|no| PRUNE["pruned (hard or conditional)"]

Scope

Path include pruned? disable effect
compose initialize / reinstall yes named roots + orphaned closure
compose update no ansible extra-var only
compose CI matrix (legacy_resolver direct) no co-deploy superset, no prune
swarm (utils.tests.swarm.derive_includes) yes named roots + orphaned closure

The swarm path applies the same cut through the blocked parameter of applications_if_group_and_all_deps; disable roles are cut from the dep walk, so orphaned providers never enter the provisioned inventory and swarm_deploy_targets (bounded by inventory presence) cannot re-add them.