Commit Graph
23 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Fable 5 63328b488f fix(swarm-tests): carry DOMAIN_PRIMARY into the inventory extras
extend_inventory adds the nfs-server host without host_vars, so the
extras file is its only var source; without the cluster domain it fell
back to the group_vars default and rendered unresolvable
*.infinito.localhost endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:05:57 +02:00
kevinveenbirkenbachandClaude Fable 5 6d7bcdf705 fix(swarm-test): mirror the force-shared DB view in the credential closure
derive_includes applies the same shared:true flip as force_shared_db, so
variant rounds that pin a consumer's DB to shared:false keep the central
provider (and its generated credentials) in the include set the deploy
will actually load. extend_inventory derives its manager-placed groups
from the same variant-aware closure instead of a base-config walk, which
re-added pruned provider groups without credentials. matrix.py passes the
round's variant map to the extend step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:05:55 +02:00
kevinveenbirkenbachandClaude Opus 4.8 2f9a54cea7 fix(deploy): honor variants in include resolution
Inventory include sets ignored the active variant: swarm
derive_includes walked the variant-free base config (dynamic service
flags count as enabled) and the compose CombinedResolver followed
run_after as an include edge. Providers a variant pins to false
(e.g. mobilizon variant 1 sso) were still deployed.

- derive_includes: swap every app listed in INFINITO_APP_VARIANTS to
  its variant config, index 0 included (nextcloud variant 0 disables
  11 partner services)
- CombinedResolver: follow_run_after flag; include resolution passes
  False so run_after stays a pure ordering hint
- drop dead deps.py chain (resolve_deploy_ids_for_*) and unused
  resolve_with_edges; rewiring them would resurrect variant-blind
  run_after-following includes
- inventory.md: document the real include pipeline and
  is_explicit_truth semantics

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:21:56 +02:00
kevinveenbirkenbachandClaude Opus 4.8 bd65cb3823 fix(web-app-moodle): deliver mail through Mailu on every swarm host
moodle mail failed two ways in a swarm cluster. (1) moodle's build_context
pulled the FULL sys-svc-mail-msmtp role early, which rendered /root/.msmtprc on
the manager before Mailu had created the no-reply token and tripped the role's
run-once flag, so the manager kept an empty-password msmtprc and the health
check failed 'authentication method PLAIN needs a password'. Pull only
tasks_from: ca_bundle so the manager re-renders on the normal post-token pass.
(2) msmtprc forced host=127.0.0.1 for any in-container host, but the NFS server
sits outside the swarm mesh and has no local Mailu, so it hit 'connect to
127.0.0.1:465 refused'. Narrow the 127.0.0.1 branch to compose or a real
svc-swarm-node AND to when Mailu is actually deployed (email.external); else use
email.host, and map the NFS node's test DNS to the manager IP. Also import the
shared self-signed Root CA for the relay's TLS trust.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:26:05 +02:00
kevinveenbirkenbachandClaude Opus 4.8 9f833f3aae feat(swarm-test): force shared DB provider services in swarm inventory
Embedded per-app DBs (shared:false) resolve to mariadb-on-NFS with no placement pin in swarm, which InnoDB does not support (locking, split-brain); embedded DBs are a compose-only pattern. After provisioning each swarm-test round's inventory, force shared:true on every svc-db-* provider service so consumers route to the central provider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 03:30:06 +02:00
kevinveenbirkenbachandClaude Opus 4.8 c1c5230302 Merge branch 'main' into feature/swarm-nfs
Conflicts resolved:
- .claude/.gitignore: union of ignore patterns
- plugins/lookup/rbac_group_path.py: drop both unused conflicting imports
  (body fetches apps via lookup_loader)
- roles/sys-svc-compose/handlers/main.yml: keep HEAD's refactor (compose-up
  handlers live in compose.yml, included per DEPLOYMENT_MODE)
- utils/cache/base.py: keep HEAD's merge_with_defaults/FILE_TOKENS imports

Post-merge fixes for main's newly-enabled lints:
- pyproject.toml: grandfather 5 files under the BLE001 ratchet
- justify 6 pre-existing security # noqa markers (S108/S311/S701)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:41:39 +02:00
kevinveenbirkenbachandClaude Opus 4.8 bb4e8bd2b8 fix(svc-storage-nfs): NFSv4 + correct probe export + soft dev/act mounts (SPOT)
The dev/act ganesha server is NFSv4-only, but the swarm-test runtime extras forced storage.nfs.version: 3, so every mount failed 'Protocol not supported'. Set version 4 (write_extras + dev swarm inventory). The client probe mounted the Root_Squash export_base and a root probe-write failed; point it at the No_Root_Squash state path. Both client mounts hardcoded 'hard', so a torn-down test server wedged nodes into D-state; gate them to soft in dev/act (reusing the controller mount's gate). Hoist the shared mount-opts string into the STORAGE_NFS_MOUNT_OPTS SPOT (group_vars/all/15_storage.yml), used by all three mounts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:34:46 +02:00
kevinveenbirkenbachandClaude Opus 4.8 c97392ed44 style: apply make autoformat reformatting
Formatter-only changes from make autoformat: wrap a long Jinja
expression in the dashboard manager-ops task and reformat the swarm
extend_inventory test util. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 05:43:31 +02:00
kevinveenbirkenbachandClaude Opus 4.8 a08dddfed7 fix(swarm-test): keep the svc-swarm-manager marker group manager-only in the test inventory
extend_inventory._host_topology placed the app-under-test on the workers whenever the role had no default_placement: manager. When APP_ID is svc-swarm-manager itself, that leaked the workers into the svc-swarm-manager group, which is the marker group IS_STACK_HOST keys on, so IS_STACK_HOST went truthy on the workers and sys-ca-selfsigned skipped its CA fetch (when: not IS_STACK_HOST). root-ca.crt never landed and 'CA | enforce permissions' failed on both workers. Guard the worker extension so this marker group stays manager-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 05:43:31 +02:00
kevinveenbirkenbachandClaude Opus 4.8 3a55877a39 refactor(swarm-test): SPOT topology naming in default.env + add act-swarm-clean
Hoist the swarm-test node, network and NFS-export-base names out of the shell
harness (00_topology.sh) into default.env (INFINITO_SWARM_*), so the shell
harness and the Python harness (extend_inventory, write_extras) read one source
instead of each hardcoding the suffixes. 00_topology.sh, extend_inventory.py,
write_extras.py and 01/09/11 now read the SPOT; passthrough.py exports the keys
into the generated .env. 00_topology.sh reads default.env directly (not .env)
because the workflow sources it before make dotenv runs.

Rename topology.sh -> 00_topology.sh (sorts ahead of 01_/02_, the order it is
sourced in) and update all 10 references.

Add scripts/tests/deploy/swarm/clean_all.sh + make act-swarm-clean to reclaim
wedged act-swarm state between runs, label-scoped via INFINITO_SWARM_TEST_LABEL
so it never touches unrelated containers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:31:34 +02:00
kevinveenbirkenbachandClaude Opus 4.8 d2daa08677 feat(test): make SWARM_NAME mandatory for act-swarm cluster names
Every swarm-test cluster is named so parallel clusters get distinct container and network names. topology.sh and extend_inventory require SWARM_NAME; act-swarm-zombie defaults it to the app id (name= overrides), the CI workflow derives it from the matrix app, and act-swarm-down/shell/playwright require name=. The bare swarm-mgr-01 names are gone (node refs now carry the cluster prefix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:29:39 +02:00
kevinveenbirkenbach 39f4bc6685 feat(swarm): align mariadb/postgres tasks + post-deploy gate with container_address SPOT
Tier-1 changes verified live in act-12 (mariadb stack converged, postgres
stack started, swarm-readiness gate fired correctly for docker/mariadb/
postgres/openresty).

* `plugins/filter/compose_volumes.py` + `plugins/lookup/compose_volumes.py`:
  thread `dir_var_lib` through filter (required keyword, no default)
  and lookup (reads from `vars_["DIR_VAR_LIB"]`, raises if missing). Replace
  native NFS driver with a bind-mount from the host's already-NFS-mounted
  `DIR_VAR_LIB` because native `type: nfs` re-handshakes portmap, which is
  unreachable in nested DinD. Drop the FilterModule wrapper so the deprecated
  pipe form keeps failing fast per the lookup-only SPOT contract.

* `tests/unit/plugins/{filter,lookup}/test_compose_volumes.py`:
  thread `DIR_VAR_LIB` through every fixture (no default-fallbacks).

* `roles/sys-svc-compose/tasks/utils/post_deploy.yml`:
  compose-mode db-health probe gated to `DEPLOYMENT_MODE \!= 'swarm'`; new
  swarm-mode convergence gate runs the extracted `swarm_stack_ready.sh`
  via `ansible.builtin.script` with `STACK` env, addressed by absolute
  `playbook_dir`-rooted path so the include resolves from caller roles.

* `roles/sys-svc-compose/files/swarm_stack_ready.sh` (new): probe
  `docker stack services` for a single `STACK` and exit non-zero until
  every service's running/desired replica count matches.

* `roles/svc-db-mariadb/vars/main.yml`: new `MARIADB_CONTAINER_ADDRESS`
  constant wraps the `container_address` lookup so callers reference one
  SPOT instead of duplicating the lookup.

* `roles/svc-db-mariadb/tasks/01_core.yml` + `02_init.yml`:
  switch root-password probes and bootstrap SQL calls to
  `container exec -i {{ MARIADB_CONTAINER_ADDRESS }} sh -c 'mariadb -p"$MARIADB_ROOT_PASSWORD"'`.
  Using the container's own `MARIADB_ROOT_PASSWORD` env var mirrors the
  healthcheck and matches whatever quoting Docker swarm preserves from
  the env_file, sidestepping the compose-vs-swarm env_file quote-strip
  divergence.

* `roles/svc-db-postgres/files/set_postgres_superuser_password.sh`:
  `eval "container=$container_raw"` so the swarm-mode lookup payload
  `"$(/usr/bin/resolve-container-id <stack> <svc>)"` is re-evaluated by
  bash and resolves to the live task id at exec time.

* `roles/svc-db-postgres/tasks/01_core.yml`: rename arg to use the new
  swarm-aware `POSTGRES_CONTAINER_ADDRESS`.

* `utils/tests/swarm/extend_inventory.py`: when the seed app's primary
  service declares `default_placement: manager`, do not add the app to
  the worker group, so the swarm test inventory matches the
  single-replica placement-pinned semantics.
2026-06-14 19:08:43 +02:00
kevinveenbirkenbachandClaude Opus 4.7 5f44943d10 refactor(svc-swarm): categorise as svc.swarm + split into svc-swarm-node/svc-swarm-manager
Adds a `swarm:` subcategory under `svc:` in roles/categories.yml so the
shared-cluster role is properly invokable, and renames the existing
`svc-swarm` role + inventory group to `svc-swarm-node` (with
`get_entity_name`-aligned primary key `node:` in meta/services.yml).
`svc-swarm-manager` stays as the manager-marker role, becoming invokable
via the same new category and re-keyed to `manager:`.

Consumers of the cluster-orchestration service (svc-registry-cache,
svc-registry-docker, svc-swarm-manager) now reference the service-key
`node:` (was `swarm:`), matching the renamed primary entry. The planner
empirically resolves `service_registry['node']` -> `svc-swarm-node` and
emits it into --include for stack-host deploys, so the CI failure on
`universal-svc-registry-docker` (`svc-swarm: not present in inventory`)
no longer reaches `ValidDeployId`.

Breaking change for operators: inventory groups named `svc-swarm:` must
be renamed to `svc-swarm-node:`. `DEPLOYMENT_MODE` reads
`groups['svc-swarm-node']`, so a stale `svc-swarm:` group silently falls
back to compose mode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 18:53:08 +02:00
kevinveenbirkenbach b5247eeafb fix(tests/swarm/derive_includes): include svc-registry-docker in swarm test inventory
The swarm test rig provisions its inventory via derive_includes.py
walking each APP_ID's transitive meta-deps. svc-registry-docker is
not a meta-dep of any web app (intentionally — it would otherwise
spin up an unused registry container in compose-mode), but the
sys-svc-compose handler "swarm: retag built images for local
registry and push" (handlers/main.yml:116) requires the registry
to be reachable at swarm-mgr-01:5000 whenever an app builds its
own image (web-svc-simpleicons, etc). Without the include the
handler crashed with "dial tcp 192.168.244.3:5000: connection
refused".

Add svc-registry-docker to _EXPLICIT_INCLUDES alongside
svc-swarm-manager and svc-storage-nfs-client. The role already
carries default_placement: manager in meta/services.yml, so the
constructor auto-places swarm-mgr-01 in the svc-registry-docker
group and tasks/groups/svc-registry-roles.yml deploys it ahead of
the first app stack. Verified with:
  APP_ID=web-svc-simpleicons python -m utils.tests.swarm.derive_includes
which now emits svc-registry-docker in the include list.
2026-06-09 20:23:54 +02:00
kevinveenbirkenbachandClaude Opus 4.7 dc656e711f fix(test-swarm): inject test.<domain> + write /etc/hosts on all nodes
The previous commit's default.yml load turned MODE_DEBUG (and therefore
MODE_ASSERT) on for the swarm CI, which activates the DNS check in
tasks/stages/01_constructor.yml:12 that resolves
test.{{ DOMAIN_PRIMARY }}. The pipeline failed on all four hosts
(mgr-01, wrk-01, wrk-02, nfs-server) because:

1. write_hosts_entries did not emit test.<domain> at all.
2. 02_start_swarm_nodes wrote /etc/hosts only on the manager.

Add test.<domain> to the emitted set and write the resulting block to
mgr + both workers + nfs-server so the assertion succeeds everywhere
the play targets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 10:57:45 +02:00
kevinveenbirkenbachandClaude Opus 4.7 34520de573 fix(test-swarm): drop hardcoded dev inventory path from docstring
The inventory-vars-file SPOT drift guard
(tests/unit/cli/administration/deploy/development/test_inventory_vars_file_spot.py)
flagged the literal "inventories/development/default.yml" in the
docstring as a missed SPOT migration. Refer to "the development
inventory" in prose instead — the env var name (INFINITO_DOMAIN) is the
contract; the consuming file's path is not.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 21:06:16 +02:00
kevinveenbirkenbachandClaude Opus 4.7 0f8d908ff0 fix(test-swarm): require INFINITO_DOMAIN env in write_hosts_entries
The previous DOMAIN_PRIMARY fallback was reading a non-existent env var
(DOMAIN_PRIMARY is an Ansible variable, not an env var) and defaulted to
infinito.localhost. In swarm-CI that produced /etc/hosts entries for the
wrong domain, so dashboard's "Wait for dashboard branding assets" task
got NXDOMAIN for file.infinito.example and exhausted its 60 retries.

INFINITO_DOMAIN is the SPOT (default.env:107, exported via
scripts/meta/env/load.sh which 02_start_swarm_nodes.sh sources). Read it
directly and let KeyError surface a misconfigured caller — matches
scripts/tests/dns.sh's "${INFINITO_DOMAIN:?...}" convention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 20:57:59 +02:00
kevinveenbirkenbachandClaude Opus 4.7 23ec7a99fb refactor(roles): rename svc-{cache,docker}-* roles for prefix consistency
svc-cache-registry       -> svc-registry-cache
  svc-docker-registry      -> svc-registry-docker
  svc-docker-swarm         -> svc-swarm
  svc-docker-swarm-manager -> svc-swarm-manager

categories.yml: drop svc.docker, add svc.registry. Deliberately no svc.swarm
category so get_entity_name('svc-swarm') stays 'swarm' instead of collapsing
to '' (which would break discover_role_services).

Entity keys updated to match the new svc.registry prefix:
  services.cache-registry.* -> services.cache.*
  services.registry.*       -> services.docker.*

01_constructor: replace svc-docker bootstrap-loop entry with explicit
include_role of svc-swarm + bootstrap loop over [svc-registry, svc-storage].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 15:18:19 +02:00
kevinveenbirkenbach e5474aed49 fix(swarm-test): bake dev default.yml into provision host_vars
inventories/development/swarm.yml carries only swarm-mode-specific
overrides and omits cross-cutting cred seeds (OIDC.CLIENT.SECRET,
biber, administrator password). Until now the swarm CI provision step
did not pass --vars-file, so default.yml was never merged into the
generated host_vars; the first deploy that preloaded web-app-mailu as
a shared email service then hit object of type 'dict' has no attribute
'SECRET' when sys-svc-compose rendered roles/web-app-mailu/templates/
env.j2. Affected jobs in CI run 27006276488: web-app-chess,
web-app-espocrm, web-app-funkwhale, web-app-prometheus.

Match the server-CI pattern (scripts/tests/deploy/local/reset/
inventory.sh and apps/initialize/all.sh both pass --vars-file
${INFINITO_INVENTORY_VARS_FILE}). 05_provision_inventory.sh now does
the same: the env loader sourced via _context.sh already exports
INFINITO_INVENTORY_VARS_FILE in the script shell, so the path
resolves through the SPOT.

write_extras.py re-reads default.yml users dict and patches the
runtime SSH key onto admin authorized_keys before emitting the
extras file. Without this, the deploy-time -e @swarm-nfs-extras.yml
would REPLACE the host_vars users entry (ansible later-wins on
top-level dicts) and wipe biber + the admin password the
--vars-file just baked in.
2026-06-05 13:19:28 +02:00
kevinveenbirkenbach 24b4a6beba fix(swarm-test): venv on PATH + restate static swarm.yml keys in extras
Two distinct breakages that let the workflow fail before ever reaching
the ansible deploy:

* `python3 -m utils.tests.swarm.*` (and friends) ran with the
  actions-setup interpreter that has no project deps, so the imports
  of `plugins.filter.canonical_domains_map` blew up on `from ansible.errors`.
  The swarm preamble scripts now source `scripts/meta/env/load.sh` so
  PATH picks up the venv `make install` populated, and the workflow itself
  uses `install-force` (under `act --bind` the build/install.stamp is the
  host's and would short-circuit the in-container install) plus pushes
  `\${VENV}/bin` onto GITHUB_PATH for downstream steps.

* `ansible-playbook -e @file` replaces top-level dicts rather than
  deep-merging them. The runtime-only extras file was dropping
  `storage.nfs.{export_base,version}`, `storage.backend`, and
  `swarm.{registry.port,network}` that the static
  `inventories/development/swarm.yml` ships. Re-state those keys
  in write_extras.py so the merge ends up with everything the roles need.
2026-06-05 09:52:59 +02:00
kevinveenbirkenbachandClaude Opus 4.7 8c26810937 fix(lint): four more lint findings surfaced by full make test
* tests/unit/cli/administration/deploy/swarm/test_closure.py — drop
  the `parents[6]` + `nocheck: project-root-import` combo. The
  file-path loader still bypasses the dedicated CLI's heavy package
  __init__, but the path now comes from the canonical
  `from utils import PROJECT_ROOT` so the project-root-import lint
  passes without a per-line opt-out.

* utils/tests/swarm/write_extras.py — restructure the
  `Path.read_text()` call onto a single line so the
  `# nocheck: cache-read` annotation lands on the call site itself.
  Autoformat had previously line-broken the chained `.strip()`
  expression and pushed the marker onto a different line, making
  the suppression invisible to the lint scanner.

* tests/integration/infrastructure/compose/test_compose_volumes_call_required.py —
  the `test_no_template_uses_deprecated_pipe_form` walker used a raw
  `roles_dir.rglob("templates/**/*.j2")`, which trips
  `test_no_raw_project_walk` in the lint suite. Switch to the cached
  `iter_project_files()` helper with a roles-prefix + `/templates/`
  filter; behaviour is unchanged but the walk goes through the
  shared SPOT.

* roles/svc-cache-registry/meta/services.yml — annotate the role
  with `# nocheck: required-by-coverage`. svc-cache-registry is
  intentionally opt-in via `default_placement: manager` plus the
  swarm CLI closure, NOT via a downstream `required_by` chain, so
  the lint's non-invokable-without-required_by check would otherwise
  reject it.

`make autoformat` passes. `make test` is now green across lint /
test-external / test-integration / test-lint / test-unit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 03:13:50 +02:00
kevinveenbirkenbachandClaude Opus 4.7 3b0ffab1b3 fix(lint): satisfy 6 lint rules across swarm refactor files
CI failures from run 26987751848:

* `cache-read` (3 hits): annotate the `Path.read_text()` calls in
  utils/tests/swarm/{write_extras,extend_inventory}.py — every one
  reads a file the same script just wrote, so the cache helper would
  serve stale content. Per `# nocheck: cache-read` carve-out.
* `project-root-import`: annotate the `parents[6]` in the swarm
  closure test loader. The file-path loader bypasses the package
  __init__ to avoid pulling in the heavy dedicated CLI imports.
* No-unittest-tests: tests in test_closure.py inherited only via
  `_BaseClosureCase` (which itself extends `unittest.TestCase`), but
  the AST lint walks bases literally and does not follow indirect
  inheritance. Switch each `Test*` class to multi-inherit
  `(_BaseClosureCase, unittest.TestCase)` so the discovery check
  recognises them as runnable.
* `hardcoded-dns-resolver`: the per-block head suppression in
  inventories/development/swarm.yml was insufficient; the lint scans
  per-line and needs the marker on the actual `dns: "1.1.1.1"` line.

Verified locally: 16 of the previously-failing tests now pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 02:53:31 +02:00
kevinveenbirkenbachandClaude Opus 4.7 f67b913a4b feat(swarm): swarm-aware deploy CLI with inventory-derived dep closure
New ``cli.administration.deploy.swarm`` package wraps the strict
``cli.administration.deploy.dedicated`` runner with a layered closure
so swarm deploys never require operators (or test scripts) to
enumerate every infra role:

  effective --id = seed (operator --id, else inventory_role_groups)
                ∪ dep-walk(seed via applications_if_group_and_all_deps)
                ∪ default-placement safety net

The dep-walker is the same SPOT
(``utils.roles.applications.in_group_deps.applications_if_group_and_all_deps``)
the runtime constructor consumes via ``lookup('applications_current_play')``,
so the CLI-side closure and the in-play role set stay in sync.

The dedicated CLI keeps strict explicit-only semantics: ``main`` was
split into ``parse_args`` + ``run_from_args`` so the swarm wrapper
can share parser, mode wiring, validator and runner verbatim and
only mutate ``args.id``.

Meta-side: ``svc-db-postgres`` gains ``default_placement: manager``
(semantic symmetry with mariadb). ``default_placement`` is now a
PLACEMENT hint only ("if deployed, run on the manager"); deploy
decisions come from the dep-walk. Apps that declare ``mariadb:``
pull svc-db-mariadb; apps that declare ``postgres:`` pull
svc-db-postgres; apps that declare neither get no central DB.

Shared utils extracted so the four consumers stay aligned:
  * ``utils.inventory.groups.inventory_has_group``: YAML + INI static
    inventory group probe (extracted from ValidDeployId).
  * ``utils.roles.meta_lookup.{get_role_default_placement,
    iter_roles_with_default_placement}``: single source of truth for
    ``default_placement`` discovery (lookup plugin is now a thin wrapper).

Test pipeline (``scripts/tests/deploy/swarm/*``):
  * Heredoc-Python shells converted to proper modules under
    ``utils.tests.swarm.*`` and invoked via ``python -m``:
      - extend_inventory.py (was 08_extend_inventory.sh)
      - write_extras.py     (was 09_write_extras.sh dynamic half)
      - derive_includes.py  (replaces 06's hardcoded includes array)
      - write_hosts_entries.py (replaces 02's hardcoded /etc/hosts heredoc;
        derives every canonical + www-redirect domain from APP_ID via
        canonical_domains_map + generate_all_domains, so the test
        works for any app in the matrix without per-app maintenance)
  * 01_create_network.sh deleted (single docker command inlined in workflow).
  * 11_deploy.sh deleted (swarm CLI now called directly in workflow).
  * 09_write_extras.sh: static half moved to inventories/development/swarm.yml.
  * Remaining shell scripts renumbered 01-13 without gaps.
  * 08_wait_converge.sh: case block on DB_DEP replaced with single
    conditional (container name == DB_DEP).
  * 06_provision_inventory.sh: hardcoded includes array replaced with
    `mapfile -t includes < <(python -m utils.tests.swarm.derive_includes)`.
  * Workflow YAML step references updated to new script names + module
    invocations.

New tests (39 covering the closure surface):
  * tests/unit/cli/administration/deploy/swarm/test_closure.py (14)
  * tests/unit/utils/inventory/test_groups.py (11)
  * tests/unit/utils/roles/test_meta_lookup.py (+3 for default_placement)
  * tests/unit/utils/roles/validation/test_deploy_id.py (unchanged
    8 tests still pass after refactor onto the shared util).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 02:24:51 +02:00