Files
computer-playbook/cli/administration/deploy/development/README.md
kevinveenbirkenbachandClaude Opus 5 a0494660c0 docs(services): say what is_explicit_truth actually matches
The docstring promised the narrow form "{{ '<role>' in group_names }}" and
pointed at tests/integration/roles/meta/test_services_dynamic_flags.py, a path
that does not exist. The helper substring-matches "in group_names", so a
negated predicate matches too -- and that is deliberate: such a flag is true on
some node shape, and the static closure is the co-deploy superset, so the
dependency must resolve at tooling time either way.
tests/integration/roles/meta/services/test_dynamic_flags.py:194 asserts the same
substring rule, which is the real contract.

Reported during triage as a latent defect (seaweedfs' frontend is gated on
"not in group_names" and reads as enabled on a tor node). It is not one; the
description was. Tightening the matcher would have changed behaviour against the
test that pins it, so that is filed as won't-fix and only the wording changes
here. The README table gains the row it was missing.

No executable line in this diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:55:22 +02:00

211 lines
8.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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.
```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<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.
```mermaid
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 |
| `"{{ '<role>' 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<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,)`.
```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<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.