# 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
```mermaid
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
```mermaid
flowchart TD
A["--apps → primary_apps"] --> B{"disable set?"}
B -->|yes| C["parse_services_disabled → find_provider_roles
→ 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,
primary_apps, disabled_app_ids, round_overrides)"]
I --> J["(kept, pruned)"]
J --> K["round_include = kept
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.
```mermaid
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
(same round_overrides)"]
DEPP -->|no| DK["include_R"]
DP --> DD["round_deploy_ids"]
DK --> DD
DD --> VAL{"validate_application_ids
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.
```mermaid
flowchart LR
subgraph edges["prerequisites(role)"]
RA["run_after
(NOT followed for inclusion)"]
DEP["dependencies
(app roles only)"]
SVC["services
(meta/services.yml flags)"]
end
SVC --> GATE{"_is_enabled_shared:
is_explicit_truth(enabled)
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 |
| `"{{ '' in group_names }}"` | yes |
| `"{{ '' not 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.
```mermaid
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`.
```mermaid
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
(skip disabled, skip seen)"]
EDGES --> POP
POP -->|empty| SPLIT["kept = include ∩ reachable
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,)`.
```mermaid
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.
```mermaid
flowchart LR
Q{"dep reachable from a
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.