The dev inventory resolves DIR_VAR_LIB via lookup('env','INFINITO_DIR_VAR_LIB'), but compose.yml never forwarded the var into the infinito container, so it resolved empty inside the DiD: relative FILE_TOKENS made the mailu no-reply token write and read at diverging paths and the sys-svc-mail assert failed.
Forward the key in the infinito service environment block, and add a lint test that fails when any lookup('env','INFINITO_*') in inventories/development/*.yml is not forwarded in compose.yml, so this class of bug cannot recur.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a lint-packages target/workflow that parses the generated packaging metadata with each ecosystem's native parser (dpkg-parsechangelog, rpmspec, bash -n for the PKGBUILD), failing on any parser stderr so warnings cannot slip through. Wired into the make lint fan-out and ci-orchestrator's lint gate; an install group provisions the parsers (best-effort, cross-distro).
Hardens package_mirror so the generated changelogs parse without warnings: every RPM body line gets a '- ' continuation prefix (a column-0 '*' was read as a new %changelog entry -> 'bad date'), and the archived-releases notice is folded into the oldest kept entry instead of trailing after the final entry (free text there made dpkg-parsechangelog warn). Regenerates packaging/debian/changelog and the Fedora spec accordingly.
Adds a test-lint check (test_package_changelog_completeness) asserting both package changelogs list every CHANGELOG.md + docs/changelog release and that their inline window matches CHANGELOG.md, plus unit regressions for the RPM continuation prefix and the Debian footer fold.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve 69 conflicts across ~70 files: union main's image-version bumps and new service fields (bond, resource limits) with the swarm branch's default_placement / NFS / IS_STACK_HOST config; retire the three per-deploy-type test workflows main still edited (consolidated into compose+swarm); adopt main's run-once 01_core dispatchers while re-applying each role's swarm guard. Fix the post-merge lint surfaced by main's stricter rules: bond + resource-budget keys, container-exec stack-host gating + resolver routing, leaky/dead proxy vars, suitecrm once-flag, xwiki variant nocheck, nextcloud oversized-block to gated include, matrix-mdad capped at 3g. make autoformat clean, make test green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In swarm, OmniAuth/OpenSSL (decidim OIDC discovery) and Node (bluesky-broker -> PDS) could not trust Keycloak's internal self-signed CA: docker stack deploy never received compose.ca.override.yml, and the override's $$-escaped entrypoint wrapper is swarm-incompatible (stack deploy does not un-escape it). Add a --no-wrapper override mode (env + CA mount only: SSL_CERT_FILE/CURL_CA_BUNDLE/NODE_EXTRA_CA_CERTS) generated on the stack host for self-signed apps, and build the stack-config/stack-deploy --compose-file list via compose_file_args (new --compose-file flag kwarg) instead of hardcoding compose.yml -- which also stops swarm silently dropping a role's own compose.override.yml. Compose untouched: self-signed-gated, no-wrapper is swarm-only, lookup defaults to -f.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The module execs 'container exec <container>' as an argv list (no shell), using config["container"] = the literal "postgres" in shared mode, which is not a swarm container name -> 'No such container: postgres' for peertube and ~12 other callers. The database lookup already provides a swarm-aware config["address"] = "$(resolve-container-id <stack> <key>)"; expand it here (shlex-split, run the resolver as argv) to a concrete id when present. Compose (address == container) is byte-identical.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Set DEFAULT_MAX_STORAGE to 350GB (was 400GB) and make it the single source of truth: the INFINITO_VARIANT_BUNDLE_MAX_STORAGE env handler's setdefault and the resolve_max_storage error message now reference DEFAULT_MAX_STORAGE instead of repeating the literal. Update the default-resolution unit test accordingly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add tests/lint/ansible/services/test_variants_shared_false_once.py enforcing the one-local-provider-variant limit on services.<key>.shared: false pins, document the variants-shared-false-once suppression marker, and flip the database service (mariadb/postgres/redis) to shared: true across the erpnext, funkwhale, shopware, snipe-it, suitecrm, taiga, xwiki and zammad variants to comply.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The example _RUN_URL in test_download.py lacked the '# nocheck: url' marker its siblings carry, so test_public_http_urls_are_reachable probed github.com/o/r/actions/runs/123 -> HTTP 404 and failed the external suite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SPOT: declare the var-lib base once in default.env as INFINITO_DIR_VAR_LIB; the env build derives INFINITO_PLAYWRIGHT_REPORTS_BASE_DIR from it; reference it everywhere (utils/paths.py, scripts via ${...:?}, tests, the dev inventory's DIR_VAR_LIB injection) instead of hardcoding the literal. group_vars keeps the literal default; the env-lookup lives in the dev inventory per the no-ansible-env-lookups lint rule. pull_specific_host.py --folder is now required and the role passes {{ DIR_BACKUPS }}.
Swarm e2e: collect_playwright_reports.sh docker-cp's the reports off the manager plus a workflow upload step, so swarm Playwright traces reach CI artifacts (was compose-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reorder the clean-container-owned / clean-pycache-dirs / clean-stale-nfs Makefile targets into ascending order, set the executable bit on the swarm NFS cleanup scripts, and opt the CI-logs analyze and download-test file reads out of the cached-read lint (a runtime read of a user-supplied log, and a temp fixture written inside the same test).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
python -m cli.meta.ci.logs.download fetches a run's job logs and artefacts in parallel, with conclusion filters (-s/-f/-c/-k), a jobs.json/summary.tsv manifest, per-request jitter to dodge API rate limits, and idempotent re-runs. python -m cli.meta.ci.logs.analyze clusters the downloaded logs by their root failing task.
Rename the agent CI-triage page debug/pipeline.md to debug/ci.md, point its download/inspect step at the new CLI, and reference the tooling from the contributing CI-debugging page. Add unit tests for both commands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace 'lookup(applications) | node_max_old_space_size(app, service)' with the 'lookup(node_max_old_space_size, app, service)' lookup form (it resolves the app config itself), dropping the boilerplate at all 4 call sites (nextcloud whiteboard, peertube x2, suitecrm). Extract the shared decimal to_bytes size parser into utils/sizes.py, used by the lookup and the peertube install_mem_limit filter (removing the cross-plugin import).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
base.yml.j2 now captures the sidecar service includes and emits 'services: {}' when none render, instead of a bare 'services:' which docker compose rejects ('services must be a mapping'). A launcher-based app with all backends shared (web-app-discourse) has no compose services because it runs its container via ./launcher, so its compose was previously invalid. web-app-discourse drops its local _has_local_sidecar gate and just includes base. compose_ca.py (ca-inject) no-ops -- writes an empty {'services': {}} override and exits 0 -- instead of dying with 'No services found after merging' when the merged compose has no services, since there is nothing to CA-inject and the downstream compose up/ps tolerate an empty stack; add test_main_empty_services_writes_noop_override. .ansible-lint-ignore: add the 04_login resolve_host_cid path_join include (a load-failure false-positive that strict ansible-lint profile:min flags). make test is green (lint, integration render, unit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the single utils/github/profile/role_summary.py script with a
focused CLI package split per responsibility (model, logparse, csvio,
render, github, sources). Output format is selectable via --format
(table default, plus markdown, csv, json); source is auto-detected as an
Ansible log, a role-runtimes CSV, or a GitHub Actions run/job URL (CSV
artifacts downloaded via gh).
The compose workflow now writes the CSV as a downloadable artifact and
appends the per-variant markdown to the step summary, and hard-fails
when the log is missing or carries no profile_roles data so a broken
metrics capture can no longer pass silently.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Role runtimes" step read /tmp/ansible-<app>.log produced by a
docker cp out of the infinito container. That cp silently failed
(2>/dev/null || true) because the ansible log was not at the expected
in-container path, so role_summary reported "log not found".
The profile_roles table is already on the deploy step's stdout, so tee
it into the file role_summary reads and drop the broken cp (it also
raced the tee on the same path).
role_summary now segments the log by the matrix-deploy round/pass
markers and emits one per-role table per variant round; falls back to a
single combined table when no markers are present.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The resource right-sizing and variant rebalancing bring every variant well under the original 37GB/73GB, so lock in the tighter 32GB/64GB host budget.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Open a new CI runner bundle as soon as it would exceed INFINITO_VARIANT_BUNDLE_SIZE variants OR INFINITO_VARIANT_BUNDLE_MAX_STORAGE (default 400GB) cumulative min_storage, so storage-heavy variants are not stacked onto one runner that then runs too long. Every job now carries its explicit variant CSV in the name; empty only for roles without variants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
meta roles applications ressources gains two summary modes alongside the existing per-service detail table: (1) no --role: one row per role, each showing its heaviest variant (mem_reservation/mem_limit/pids_limit = the max per-variant sum, cpus = max), or the --variant N footprint of every role. (2) --role X without --variant: one row per variant of X (its sum/max). (3) --role X --variant N still renders the detailed per-service table - now the only way to reach it. --format/--order/--filter/--depth/--unshared apply in every mode. The table/JSON renderers are generalized (render_summary_text/json with a key_field) and a variant query accessor is added.
Useful for the CI runner-budget question: the heaviest-variant footprint per role/variant is now directly queryable. Tests cover all three modes (detail/all-roles/per-variant).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The no-direct-yaml lint (tests.lint.repository.yaml.test_no_direct_calls) flags any .py calling yaml.safe_load/safe_dump directly. The swarm_registry_sync regression test built its temp compose.yml with yaml.safe_dump; route it through utils.cache.yaml.dump_yaml instead and drop the now-unused yaml import. Includes the ruff line-wrap of the _sync helper signature. make test (incl test-lint) is green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A service that builds an image locally produces a <prefix>X image; sibling services may reference that same already-prefixed image without their own build:. swarm_registry_sync compared the de-prefixed upstream against a locally_built set holding the PREFIXED form, so the siblings missed the membership check and fell through to docker pull X for a local-only image with no registry upstream (espocrm/bookwyrm/shopware 'pull access denied'). Store locally_built de-prefixed so it matches upstream.
Add test_swarm_registry_sync.py covering the build-sibling skip, the real-upstream pull, the already-in-registry skip and the empty-prefix short-circuit; the first test fails on the pre-fix logic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the per-role swarm host-node/container-id resolve duplicated across fider, matomo, decidim, keycloak (login + realm-import), collabora and listmonk with two shared task utils under roles/sys-svc-compose/tasks/utils/swarm/. resolve_host_cid sets <prefix>_HOST_NODE / <prefix>_LOCAL_CID (swarm: converge + node + cid delegated to the node; compose: inventory host + given cid); wait_converged is the bare convergence gate.
Teach test_variable_definitions to read templated set_fact keys like {{ prefix }}_HOST_NODE as a *_HOST_NODE name pattern (guarded so a pure {{ x }} key cannot become a catch-all), and add the playbook_dir-relative include load-failure exemptions to .ansible-lint-ignore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The required_by coverage verifier treated every flat 'required_by: {categories/roles}' as required in BOTH deploy modes. But sys-ctl-rpr-container-hard/soft repair compose compositions (DIR_COMPOSITIONS) and gate their body to compose; declaring required_by:[web] flat made the verifier expect them on swarm too, where they correctly skip, so web-opt-rdr-domains swarm CI failed 'required role(s) did not execute'. Let required_by nest under 'compose:'/'swarm:'; the verifier infers the deploy mode from the role set, mirroring DEPLOYMENT_MODE's own groups['svc-swarm-node'] definition, and enforces only the matching mode.
Migrate all 21 existing flat declarations to mirror under both modes (behavior unchanged) and mark sys-ctl-rpr-container-* compose-only. role_has_required_by reads both forms. New lint test_required_by_mode_keys requires the explicit compose:/swarm: form so the flat-vs-gate contradiction can't recur.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The required_by coverage verifier (_role_body_executed) reads ANSIBLE_LOG_PATH, but the swarm and compose deploy point that at a log FILE, which ansible prefixes every line with '<ts> p=<pid> u=<user> n=<name> <LVL>| '. That prefix defeated both the line-anchored non-skip status match and the TASK-header block split, so every required role (e.g. sys-ctl-cln-bkps under any svc-bkp deploy) false-positived as 'did not execute' although it actually ran on the manager. Strip the prefix before matching; reproduced locally with a minimal multi-host include and verified the file slice flips while raw-stdout matching is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
--run URL selects the run whose results determine the --failed apps;
without it the latest deploy run on the branch is used. Repo + run id are
parsed from the URL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When sys-stk-full renders a shared service (web-svc-logout, web-svc-collabora)
it restores application_id="{{ sys_stk_full_application_id }}"; that reaches the
container_ports lookup as the raw, un-templated string (the lookup reads it from
`variables`, not as a templated positional arg), so it raised "Application ID
'{{ sys_stk_full_application_id }}' not found" and broke the swarm deploy of
every app pulling in those shared services. Template it through self._templar,
the way container_depends_on already resolves the vars it reads. Add a
regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the unused shebangs (EXE001), mark the PASS emoji constant noqa
S105, annotate the test fixture as ClassVar, and use reserved example.com
hosts / a nocheck marker for the placeholder run URLs so the
url-reachability test stops flagging them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add 'infinito administration deploy ci status' and '... trigger' for
inspecting and re-running the compose/swarm deploy-test matrix of a
branch's CI run.
status: per-role docker (compose) and swarm result plus an aggregated
total column (green only when both modes are green), a trailing 'run'
column with the job URLs, --failed [total|swarm|compose] filtering, and
table/string output. Default reads the latest deploy run on the current
branch; --url reads a specific run.
trigger: dispatches entry-manual.yml for the current branch -- all roles
by default, only the last run's failures with --failed [scope], or an
explicit --apps list.
The target repo is resolved from the branch's tracking remote (a fork),
not gh's default repo. Covered by unit tests for run parsing, status
rendering, and trigger dispatch logic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The container_ports migration left make test red; reconcile it:
- Delete 10 port vars orphaned when their compose mappings moved to the
container_ports lookup (OPENLDAP_DOCKER_PORT_OPEN, RABBITMQ_INTERNAL_PORT,
REGISTRY_CACHE_PORT, ERPNEXT_FRONTEND_PORT, FLOWISE_PORT_PUBLIC,
JITSI_WEB_HTTP_PORT, KEYCLOAK_SERVER_HOST, MINIO_API_PORT_INTERNAL,
OPENCLOUD_HTTP_PORT, OPENWEBUI_PORT_PUBLIC).
- Add PORT_BANDS.public entries for the fixed protocol ports the migration
declared (smtp/smtps/submission/pop3/pop3s/imap/imaps/sieve, rtmp, c2s/s2s).
- Route the container_ports internal-declared lint through the SPOT constants
(ROLE_FILE_TEMPL_COMPOSE / ROLE_FILE_META_SERVICES), cached read_text, and
load_yaml_any, per repo conventions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Take the host IP as an optional third element of each term
([service, protocol, host_ip]) instead of an ip= kwarg, and build the whole
ports: block from declared ports.local/public (host) + ports.internal
(container). Convert every compose template across 84 roles, folding the
remaining raw var/numeric/specific-ip mappings (including mailu's per-interface
mail ports bound to MAILU_IP4_PUBLIC). Multi-term calls render one term per line.
Add the compose-ports-must-use-container-ports lint: it forbids any raw
"<host>:<published>:<container>" publish item under a ports: block and requires
every term to declare ports.internal. Remove
tests/lint/roles/test_swarm_internal_ports.py, now subsumed.
Fix a container_depends_on rendering regression. The lookup self-indented
(default indent=4), so the added 4-space placement + | indent(4) triple-indented
the block. Flip the lookup default to column 0 (consistent with
container_networks), make every call use the 4-space + | indent(4) form, and add
a lint enforcing it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite every hardcoded literal container port in a compose.yml.j2 publish
mapping (- "<host>:<...ports.local|public.kind...>:<LITERAL>") to read the
declared ports.internal.<kind> via lookup, across 22 roles, so the published
(local/public) port and the container (internal) port are a single declared
source of truth. Mappings whose container already references the lookup or a
variable are left as-is.
This surfaced and fixed a real swarm bug: web-app-openproject declared
ports.internal.http: 8080 while the container actually listens on 80, so the
swarm resolve_upstream pointed openproject at the wrong port. Reconciled to 80
(the mapping's real container port).
Extend tests/lint/roles/test_swarm_internal_ports.py with a second check: a
published mapping's container segment must come from ports.internal via a lookup
(or a variable referencing it), never a hardcoded literal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generalize tests/lint/roles/test_swarm_internal_ports.py from http-only to every
published port kind (http, websocket, ssh, ...) referenced in a compose port
mapping (ports.<local|public>.<kind>): each must declare the matching
ports.internal.<kind> so the role's swarm port surface is complete and
consistent. Fix the roles it flagged:
- pihole, shopware: ports.internal.http (80, 8000)
- erpnext, mastodon, odoo: ports.internal.websocket (9000, 4000, 8072)
- gitea, gitlab: ports.internal.ssh (22)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The swarm resolve_upstream (utils/networks/proxy.py) reads
services.<entity>.ports.internal.<kind> and hard-fails (ValueError) when a
proxied role exposes a compose port mapping (services.<entity>.ports.local.<kind>)
but never declares its container-side internal port. Compose ignores
ports.internal, so the gap is invisible until a swarm deploy.
Add tests/lint/roles/test_swarm_internal_ports.py to catch the whole class, and
fix the three roles it flagged: web-app-funkwhale (http: 80), web-app-kix
(http: 80), web-app-zammad (http: 8080). web-app-espocrm was fixed earlier.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a `skip: [compose, swarm]` field on a role's meta/services.yml primary entity
so an app can opt out of the test-deploy matrix per deploy mode, replacing the
hardcoded exclude regex in apps.sh (the "TODO: Remove exclude condition"). The
filter threads through both discovery paths: the CI workflows pass
INFINITO_DEPLOY_MODE -> apps.sh -> `cli meta roles applications type
--skip-mode`, and roundtrip.sh honours the same get_role_skip SPOT per app/mode.
Apply skip: [compose, swarm] to svc-storage-nfs-server and svc-storage-nfs-client
(NFS storage is swarm-only and set up via the cluster inventory, not the matrix)
and web-opt-rdr-www (the previously regex-excluded app).
Covered by get_role_skip and skip_mode-filter unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-existing test-lint failures surfaced while greening the suite:
- default.env: move the INFINITO_SWARM_* block into ASC order (it sat before
INFINITO_LIMIT/LINT instead of after INFINITO_SUBNET) and trim the
INFINITO_SWARM_TEST_LABEL comment under the 140-char cap.
- Makefile: order act-swarm-clean before act-swarm-down and fold the
act-swarm-clean comment block to one description line plus Note: markers.
- scripts/tests/deploy/swarm/clean_all.sh: add the executable bit.
- tests/unit/plugins/filter/test_ruby.py: autoformat reflow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DEPLOYMENT_MODE is binary (swarm / compose), so a negated `!= 'swarm'` gate
always means `== 'compose'`. Replace every negated gate across roles/ and
group_vars with the positive form for readability, and add a lint
(tests/lint/ansible/variables/test_no_deployment_mode_negation.py) that forbids
`DEPLOYMENT_MODE !=` in .yml/.yaml/.j2 with a `# nocheck:
deployment-mode-negation` escape.
The compose-verb / chdir / jinja-gate lint guidance messages now suggest the
positive form too; the gate matcher (utils/annotations/task_gate.py) and its
test inputs keep recognising the legacy `!= 'swarm'` so a nocheck-marked
exception is still detected as compose-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A swarm docker stack deploy env-file value must be a single physical line; the
multi-line GITLAB_OMNIBUS_CONFIG was read as separate whitespace-named env
entries and aborted the deploy. Add a depth-aware ruby_single_line filter that
collapses the Ruby program to one line (statement-boundary newlines at bracket
depth 0 become ';', newlines inside an open bracket or a string become a space
so hash/array literals like the OIDC omniauth_providers stay valid) and apply it
on the swarm branch only; compose keeps the multi-line value unchanged.
Covered by tests/unit/plugins/filter/test_ruby.py (10 cases incl. a realistic
omnibus config, escaped quotes and nested literals).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The role cached keycloak's swarm container ID into KEYCLOAK_LOCAL_CID and reused it for every 'container exec <CID> kcadm.sh'. In swarm the realm-import scale-up reschedules keycloak, so the cached CID dies mid-block; the next kcadm exec aborts with 'No such container' and the module falls into the create branch, surfacing as 'Failed to create Keycloak object' on Update Client settings (15 swarm apps).
Make kcadm resilient: keycloak_kcadm_update.py re-resolves a fresh healthy CID via the swarm service label and retries on 'No such container' (bounded). Quiesce churn first: 05b waits for replicas X/X stable across 3 consecutive polls before re-resolving (stability poll extracted to files/wait_replicas_stable.sh). Add --filter health=healthy to CID resolution in 04_login and 05b. The 26.6.3 pin is unrelated. Swarm-gated; compose runs the module exactly once as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Delete the per-deploy-type workflows (test-deploy-local/universal/
workstation) and the redundant act-all/act-app runner scripts (replaced
by make act-workflow). Only compose and swarm deploy tests remain.
Both now discover the full application matrix (every deployment type,
lifecycle-filtered to alpha/beta/rc/stable) instead of just the server/
web-* scope, via apps.sh resolving applications.type without --type.
Retire INFINITO_DEPLOY_TYPE entirely: discovery is now unconditional and
the deploy step never read it. Removed from apps.sh, ci/all.sh, the local
deploy scripts, the two workflows, default.env, the env passthrough list,
the dotenv generator test, the Makefile compose-deploy target, and docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split the single complexity __main__.py into graph (discovery + BFS),
model (ComplexityRow + scoring), render (table/json/string) and cli
(argparse + main); __main__ stays a thin entry point.
Add a 'base' column (sha1 of the role's name unioned with its embedded
services, sorted) so roles in the same mutual-embedding cluster share a
key, and a 'siblings' column listing the rest of that cluster. --unique
keeps one role per base. roundtrip's default app list now passes
--unique to skip redundant same-cluster deploys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>