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>
YAML's folded scalar (`shell: >`) only collapses newlines to spaces
when the continuation lines sit at the same column as the base
content; lines indented deeper preserve the newline. The earlier
`command:` form hid this because shlex.split flattened multi-line
input, but commit a4cb5b89e migrated these tasks to `shell:` for the
container_address subshell, so bash now sees real newlines and
executes each `--flag=value` as its own command, e.g.
`/bin/bash: line 2: --url=…: command not found`. Pulling the flag
lines back to the base column yields the same single-line command
the pre-swarm `command:` form produced.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
In compose mode the `address` key resolved to `instance`, which for a
non-shared local DB is the consumer entity (e.g. "mailu") rather than
the real container_name (`{entity}-database`, e.g. "mailu-database").
Callers like roles/web-app-mailu/tasks/02_wait_for_mariadb.yml then ran
`container exec mailu ...` and crashed the deploy with "No such
container: mailu" (see CI run 27247587073 job 80468402392).
For shared central DBs `container == instance` so this is a no-op
there; the fix only affects the non-shared local-DB path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`ansible.builtin.script` preserves the trailing newline that `echo`
emits in `premount_state.sh`, so the assert compared `"empty\n"` /
`"mounted\n"` to `["mounted", "empty"]` and always failed on a fresh
runner, blocking all swarm-CI deploys before any role could run.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The all-off matrix variant pins services.sso.enabled=false while
web-app-keycloak stays in group_names, so the previous group_names-based
defaults yielded disable-login-with-password AND no OIDC — leaving no usable
login method, and the native Playwright test failed (no password form).
Derive both toggles from PENPOT_SSO_ENABLED (the variant-aware sso flag),
matching how the oidc/ldap flags are already computed:
- native_login = not sso (off under OIDC, on otherwise)
- registration = sso (on under OIDC for JIT provisioning)
Drops the services.penpot.native_login / registration_enabled meta keys
(a group_names default can't see variant overrides). README + req-023 updated.
- meta/services.yml: new `penpot.native_login` toggle, default off when
web-app-keycloak is present (forces SSO), on otherwise.
- vars: PENPOT_FLAGS renders enable-/disable-login-with-password from it.
- tasks: administrator local-password bootstrap gated on native_login.
- playwright: test-login-native.js skips when `sso` is enabled.
- meta/main.yml: drop the Figma parenthetical from the description.
- README + req-023 updated.
Adds a mode-aware `container_address` lookup plugin and a
`resolve-container-id` helper script that resolve swarm task IDs at
exec time (compose mode returns bare names unchanged). Migrates ~25
roles + ~28 task files from `command:` to `shell:` so the lookup's
`$(...)` subshell evaluates correctly, and extends `database` lookup
with an `address` key for cross-role DB exec.
Three new lint tests enforce the contract: every container exec
target goes through the resolver, lives behind an IS_STACK_HOST gate
(walks the include graph one hop deep), and uses `shell:` rather than
`command:` when the target is a Jinja reference. Existing nocheck
suppressions on bare-name DB exec sites (nextcloud redis, listmonk,
moodle OIDC, mailu) are replaced with the lookup-based form.
Fixes the systemd Environment= injection in sys-ctl-cln-disc-space
(it never expanded $(...)) by passing STACK / SERVICE_KEY parts and
resolving inside the script. Extracts shopware admin and mediawiki
extension install bodies into dedicated .sh files invoked via env.
Rewrites password-bearing shell tasks to either `| quote` directly or
the `-e VAR=... | quote` + `sh -c '... ${VAR} ...'` env-bridge pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Biber OIDC login failed the deploy with `?error=registration-disabled`:
Penpot routes first-time OIDC logins through the registration path, so a
disabled flag blocks JIT-provisioning of not-yet-seen federated users
(admin already existed, biber did not). LDAP self-provisions without it.
- meta/services.yml: registration_enabled now follows OIDC (on when
web-app-keycloak is present), off otherwise; native admin is bootstrapped
and LDAP needs no registration. vars/README/req-023 updated to match.
- playwright: split the combined OIDC and LDAP login specs into one file
per persona — test-login-{oidc,ldap}-{admin,biber}.js — wired through the
orchestrator; README Testing section updated.
The web-app-keycloak swarm deploy failed at "docker stack deploy
keycloak" with `network "openldap" is declared as external, but
could not be found` (run 27063963773 job 79881947011 line 9772).
Root cause: swarm-mgr-01 was in the svc-db-openldap inventory group
on paper, but svc-db-openldap was never actually run during the play,
so the openldap overlay network never got created — Keycloak's
compose declared it as external and the stack deploy aborted.
svc-db-postgres and svc-db-mariadb both carry default_placement:
manager in their meta/services.yml so the constructor's auto-place
loop (tasks/stages/01_constructor.yml:115-127) pins them to the
first svc-swarm-manager. svc-db-openldap is structurally identical
but lacked the same declaration, so the auto-placement skipped it
and tasks/groups/svc-db-roles.yml's application_allowed gate
returned false (host not in group), short-circuiting the role.
Mirror the same one-line declaration so the constructor places
openldap on the swarm manager, the role runs, and the openldap
overlay network is created before any downstream consumer
(keycloak, mailu, mattermost, ...) declares it external.
When matrix-mdad is recreated, the systemd unit dir is fresh but the
state volume keeps the marker. ConditionPathExists then skips bootstrap
and Synapse never gets a unit. Drop the condition, gate skip on marker
AND matrix-synapse.service existence, and remove a stale marker on
mismatch so bootstrap re-runs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four consecutive tasks in tasks/main.yml each carried an inline
"when: IS_STACK_HOST | bool" gate. Move them into a dedicated
02_stack_host_prep.yml and include that file once with the
IS_STACK_HOST gate on the include, so the condition lives in one
place. nfs_prep.yml stays inline because it intentionally runs on
every host (workers need their NFS mount points prepared too) and
up.yml stays inline because extracting a single-task file would not
reduce the duplication that the refactor is targeting.
Ansible's include_tasks `when` semantics mean a False gate on the
parent include skips the file entirely, while inner per-task `when`
clauses ANDed with the parent gate produce the same condition the
inline form expressed. The compose_repository_pull, has_domain and
TLS-mode conditions remain on their respective tasks. The
pre-existing TODO comment on the directories task ("This could lead
to problems in sys-svc-compose directories which are based on a git
repository") was an untracked planning note and is dropped per
CLAUDE.md's comment policy.
- tasks/main.yml: remove `| default('')` from changed_when on command
results (stdout always set when the conditional fires) per
test_no_redundant_default_on_module_io.
- env.j2: drop `| default('')` on LDAP.SERVER.SECURITY (defined
unconditionally in group_vars/all/12_ldap.yml) per
test_no_jinja_default_on_spot_path.
sys-svc-mail-msmtp is loaded by sys-service-loader during the server
bucket pass. Its previous direct include of sys-ctl-hlth-msmtp ran
inside that pass — well before the web-app bucket pass deploys
web-app-mailu's SMTP listener. With MODE_DEBUG=true the eager flush
then fired msmtp against 127.0.0.1:465 and got "Connection refused",
which triggered sys-service's diagnostic-fail handler and aborted the
swarm baserow play (see run 27226937629 job 80398032232).
The bucket order (universal > server > web-svc > web-app) intentionally
forbids server-bucket services from declaring run_after a web-app
dependency, so the fix cannot be expressed as a run_after edge. Route
the hlth-msmtp include through sys-service-loader/list_or_shoot.yml
instead — the role is appended to _service_loader_post_run during
preload and only executes after every preload bucket (including
web-app) has finished, at which point web-app-mailu's compose-up
handler has already brought the SMTP container up.
list_or_shoot.yml gracefully degrades to a direct include when invoked
outside any preload context, so standalone callers (tests, ad-hoc
playbooks) remain unaffected. The new wiring mirrors the established
pattern in sys-svc-compose/tasks/main.yml which already routes
sys-ctl-cln-docker, sys-ctl-hlth-container, sys-ctl-hlth-volumes,
sys-ctl-rpr-container-hard and svc-bkp-container-2-local through the
same post-run queue.
The swarm-test rig provisions storage via utils/tests/swarm/write_extras.py
which emits storage.nfs as the complete {server, export_base, version}
triple, overwriting (not merging) the group_vars default. Any key nested
under storage.nfs that is NOT re-emitted by the provisioner is lost on
the test rig, which broke svc-storage-nfs-server with
"object of type 'dict' has no attribute 'state_subdir'" on the
e415f97b0 swarm baserow CI job (run 27226937629).
Move state_subdir out of the shadowable storage.nfs sub-dict to a
top-level constant STORAGE_NFS_STATE_SUBDIR, and DRY the four call
sites that previously hand-built `[export_base, state_subdir]|path_join`
into a single derived constant STORAGE_NFS_STATE_PATH. The provisioner
needs no change because the constants live outside its override target.
Migrates four consumers (svc-storage-nfs-server/tasks/03_exports.yml,
05_controller_mount.yml, templates/exports.j2 and
svc-storage-nfs-client/tasks/main.yml). Fixes Cluster A and the
cascading Cluster B (workers cannot probe-mount because
nfs-server never wrote /etc/exports).
- spec: split the monolithic Playwright spec into per-surface companions
(_shared.js + test-login-{native,oidc,ldap}.js) required by playwright.spec.js;
OIDC and LDAP cover both administrator and biber; native registers a local
account (skips when OIDC is the login path / registration disabled).
- meta/main.yml: description now describes the application, not the role.
- meta/server.yml: canonical domain -> penpot.design.{{ DOMAIN_PRIMARY }}.
- meta/info.yml: add upstream intro video.
- meta/services.yml + vars: registration_enabled default in services.yml
(off when OIDC enabled, operator-overridable); PENPOT_FLAGS toggles
enable/disable-registration accordingly.
- templates/env.j2: bind PENPOT_SMTP_ENABLED to email.enabled and
PENPOT_LDAP_SSL/STARTTLS to LDAP.SERVER.SECURITY (constants, not literals).
- templates/compose.yml.j2: trim the backend comment to the exception and gate
the JVM CA-trust import behind self-signed TLS (no-op in production); readable
YAML block.
- templates/playwright.env.j2: dotenv_quote APP_BASE_URL; drop redundant blank lines.
Verified live: 5 passed, 6 skipped, 0 failed (OIDC/project/asset skip on this
sso-off deploy; native + LDAP + baseline + guest pass).
01_setup.yml and 02_keycloak.yml only contain shell tasks that exec
into MATTERMOST_CONTAINER / MATTERMOST_LDAP_CONTAINER /
MATTERMOST_KEYCLOAK_CONTAINER, which exist solely on STACK_HOST. On
swarm workers the chdir to the mattermost instance directory fails
during preprocessing, leaving mm_ready_wait as a module-failure dict
without .rc; the next "until: mm_ready_wait.rc == 0" then aborts with
"object of type 'dict' has no attribute 'rc'" at 0.4s, breaking the
swarm deploy for both workers.
Wrap both include_tasks in a single block gated on
"when: IS_STACK_HOST | bool" so workers skip the entire post-deploy
chain cleanly. The OIDC condition stays task-scoped on the keycloak
include and is ANDed with the inherited block gate at runtime.
The block-with-IS_STACK_HOST pattern is already used by
svc-bkp-container-2-local and web-app-pixelfed. Compose mode is
unaffected because IS_STACK_HOST evaluates true for non-swarm
deployments.
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.
The lookup read DEPLOYMENT_MODE via str(vars_.get(...)).strip() without
running it through templar.template(). DEPLOYMENT_MODE in
group_vars/all/18_swarm.yml is a Jinja expression
({{ 'swarm' if (groups['svc-swarm']|default([])|length) > 1 else 'compose' }}),
so the untemplated literal never equals 'swarm' and the lookup always
fell through to the compose branch — emitting depends_on as a mapping
(e.g. `redis: {condition: service_healthy}`). docker stack deploy
rejects that with `services.X.depends_on must be a list`, breaking
mastodon, friendica, gitea, onlyoffice and matomo on swarm.
Mirror the templating pattern already used by every sibling lookup
(compose_replicas.py, compose_volumes.py, compose_only.py,
compose_restart.py): capture self._templar, fetch DEPLOYMENT_MODE, and
pass it through templar.template() inside contextlib.suppress(Exception)
before comparing against 'swarm'. Behaviour for already-resolved values
('swarm', 'compose', missing default) is unchanged; only the unresolved
Jinja case is fixed.
Add a regression test (test_unresolved_jinja_deployment_mode_is_templated)
that drives the lookup with the exact group_vars expression plus a stub
templar and asserts the list form is emitted.
Synapse runs as UID 991 inside the container but the two configs are
bind-mounted :ro from host paths owned root:root with the default mode
0640. UID 991 is neither owner nor in the root group, so the container
exits on every restart with PermissionError: /data/homeserver.yaml.
Render both bind-mounted files with mode 0644 via the new role-scoped
constant MATRIX_SYNAPSE_BIND_MOUNT_MODE so the container UID can read
them. The files are read-only inside the container and contain no
secrets that aren't already in the host group-readable layer.
Local V1 deploy with full Infinito stack reproduced the CI Playwright
failure: OIDC login showed 'This login provider is unavailable right
now.' because Synapse's Twisted HTTP client could not validate the
self-signed cert behind https://auth.infinito.example, leading to
OidcDiscoveryError. Verified the failure in-container via curl from
the matrix-synapse container (`SSL certificate problem: unable to get
local issuer certificate`).
The Infinito compose-CA injector wraps each compose service's
entrypoint with a trust-store install — but only the matrix-mdad
service is a compose service. The MDAD-managed inner containers
(synapse / element / bridges / jitsi) are launched by MDAD's docker
daemon inside matrix-mdad and never see Infinito's CA wrapper.
- services.yml.j2: bind-mount CA_TRUST.cert_host into matrix-mdad
itself, gated on TLS_MODE == 'self_signed', so the CA file is
reachable for re-mounting into inner containers. Same path inside
matrix-mdad as on the host, so MDAD's docker run --mount src=...
resolves correctly.
- vars.yml.j2: matrix_synapse_container_extra_arguments now bind-mounts
the CA from {{ CA_TRUST.cert_host }} into the synapse container at
MATRIX_CA_MOUNT_PATH and exports SSL_CERT_FILE / REQUESTS_CA_BUNDLE
so synapse's Twisted client and any python-requests calls validate
against it. Gated on TLS_MODE == 'self_signed' — no injection on
letsencrypt / off TLS modes.
- vars/main.yml: MATRIX_CA_MOUNT_PATH constant for the in-container
cert destination so the path is defined in exactly one place.
Verified live in-container after the patch: synapse curl to OIDC
discovery succeeds without -k, /_matrix/client/r0/login surfaces the
oidc-keycloak provider, and SSO redirect returns 302.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`make autoformat` (mbake) rewrites multi-whitespace gaps in YAML
inline comments to a single space. Pure formatting touch on the
`# nocheck: stack-host-template` marker, no functional change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CI run 27063963773 shopware compose build failed with rc=2 but the
operator only saw the Dockerfile-chunk summary because BuildKit
interleaves resolver chatter into stdout while the failure summary
lands in stderr. The old `(err or out)` fallback collapsed to err
and discarded the actual composer dependency-resolution error.
Pure diagnostics improvement: emits both streams labelled clearly so
future composer / pip / npm / cargo build failures surface their
real root cause without re-running with verbose progress flags.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resolves Cluster G.2 (2 compose jobs from CI run 27063963773): mailu
user-mgmt commands (`flask mailu user`, `flask mailu password`)
were SIGKILL'd with rc=137 after 4-6s. Flask CLI inside the admin
container hit the 1g cap while executing user-creation / password
ops.
Raising to 2g matches the headroom Mailu's upstream docker-compose
recommends for non-trivial user counts.
Locally validated: mailu variant=0 reinstall completes (23m, no rc=137).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resolves Cluster B (22 swarm jobs from CI run 27063963773): worker
hosts running sys-stk-full for their apps silently skipped the
sys-stk-front-proxy include (when clause required local openresty
membership). Workers' app vhosts were never created on the manager,
so openresty had no server_name for them. Playwright wait-for-url
then failed at https://<app>.infinito.example/ with TLSV1_UNRECOGNIZED_NAME.
Replace the membership when-clause with `apply: delegate_to:
<first-openresty-host> + run_once: true`. Now any host invoking
sys-stk-full produces the vhost on the openresty host exactly once,
regardless of where the caller lives.
Compose mode unchanged: openresty auto-placed on localhost, delegate
to localhost from localhost is a no-op.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resolves Cluster D (4 swarm jobs from CI run 27063963773):
flowise, kix, mediawiki, nextcloud all failed with
"msg: Destination directory <path> does not exist" because their
templates render to non-standard subdirs (config/, volumes/mw-local/,
volumes/infinito/, volumes/) that aren't part of the default
sys-svc-compose dir-creation flow.
Pre-create task uses identical delegate_to: STACK_HOST + run_once + when
as the subsequent template task, so the parent always exists on the
correct host with no possible race.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resolves Cluster C (12 universal jobs) + Cluster F (4 swarm jobs)
from CI run 27063963773.
Cluster C: `groups[application_id]` raised "'application_id' is
undefined" in contexts without an active role (universal-deploy for
sys-ctl-alm-compose etc.), aborting the play at group_vars eval time.
Cluster F: per-role overrides of application_id (e.g. web-opt-rdr-
domains setting it to a domain string) silently flipped DEPLOYMENT_MODE
to 'compose' on every host. IS_STACK_HOST then evaluated to True on
swarm workers, defeating the stack_host_template skip-on-workers gate
and causing "Destination directory /etc/nginx/conf.d/servers/http
does not exist" failures on swarm-wrk-*.
`groups['svc-swarm']` is host-stable: depends only on inventory
membership, never on per-role context.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resolves CI swarm rc=69 ("msmtp: needs a password"): controller-side
Ansible lookups (lookup('email').password etc.) couldn't see the
tokens.yml that Mailu writes target-side, because the controller has
its own filesystem in swarm mode.
Mount NFS-shared /var/lib/infinito on every swarm host AND on the
controller during deploy (in-play delegate_to: localhost task at end
of svc-storage-nfs-server role). State subdir exported separately
with no_root_squash + wildcard CIDRs so root-owned writes succeed
and the controller can mount from outside the svc-swarm IP set.
Single-writer guarantee on FILE_TOKENS via delegate_to: STACK_HOST +
run_once on sys-token-store/{01_core,write}.yml. Backup roles
(svc-bkp-container-2-local, sys-ctl-cln-bkps, sys-ctl-cln-faild-bkps,
user-backup/03_permissions_folders, three direct callers of
04_seed-database-to-backup.yml) gated on IS_STACK_HOST to prevent
N-host write races on the shared NFS backup tree.
Fail-fast assertion in svc-storage-nfs-client + 05_controller_mount
when pre-existing local /var/lib/infinito would be hidden by mount-
over. Operator must migrate or wipe before re-deploying.
SPOTs: SWARM_LAB_NETWORK in workflow env block, storage.nfs.state_subdir
in group_vars/all/15_storage.yml, DIR_VAR_LIB unchanged. UPPER_CASE
constants in NFS roles. Cleanup script umounts controller view before
container teardown to prevent stale-mount leak between matrix jobs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Local V1 deploy diagnostic showed docker.service inside matrix-mdad
failing repeatedly:
iptables -t nat -N DOCKER: iptables v1.8.11 (legacy):
can't initialize iptables table 'nat': Table does not exist
The runner Dockerfile forced iptables-legacy via update-alternatives;
on a Debian 13 host with nf_tables as the kernel default the legacy
backend can't see the nat table, so the inner dockerd never starts,
matrix-mdad-bootstrap.service stays in 'dependency failed' state, and
the Synapse loopback probe times out for the full retry budget.
Drop the override and let Debian's default (iptables-nft, which talks
to nftables natively) be selected. The host's nf_tables stack is then
addressable from inside the DiD container.
Tests: 186/186 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CI failure 27049736006 showed the probe at http://127.0.0.1:8021 timed
out (Connection reset by peer) for the full 15-min budget even though
matrix-mdad and its inner synapse were running.
Root cause: with matrix_synapse_container_network=host, MDAD's docker
run -p flag (rendered from matrix_synapse_container_client_api_host_bind_port)
is silently ignored — host networking does not use port mappings.
Synapse binds inside matrix-mdad's network namespace on its native
container port (8008 client, 8048 federation), not on the
externally-advertised host bind port. The outer compose service was
forwarding host 127.0.0.1:8021 -> matrix-mdad:8021, where nothing was
listening.
- services.yml.j2: outer compose port forwards now target the real
internal synapse / federation / element ports (8008 / 8048 / 8080).
Host ports stay aligned with MATRIX_SYNAPSE_PORT / MATRIX_ELEMENT_PORT
/ MATRIX_FEDERATION_PORT so the front-proxy targets a single 127.0.0.1
port regardless of flavor.
- vars.yml.j2: drop the now-useless matrix_synapse_container_*_host_bind_port
and matrix_client_element_container_http_host_bind_port settings —
they only render a -p flag MDAD would ignore on --network=host.
Tests: 186/186 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per reviewer feedback: the 59 __init__.py added by the earlier
'restore missing __init__.py' commit sit in test directories that are
empty on the rebased branch (no test files) and do not exist on
upstream. They were leftovers from the pre-rebase test layout. Removing
them; on a fresh checkout those empty dirs do not exist, so
tests/lint/filesystem/test_init_files.py has nothing to flag.
`docker stack deploy` rejects compose map-form `depends_on` with
`services.<svc>.depends_on must be a list`; the syntax is only valid
for `docker compose v2`. Until now the lookup always emitted the
map form, so every swarm-deployed app with a local DB or Redis
aborted the stack deploy handler in sys-svc-compose with rc=1
(CI job 79876947270 on friendica).
Branch on `DEPLOYMENT_MODE`:
* compose: keep the map form so health-check ordering still applies.
* swarm: emit the bare service-name list; swarm has no health-check
gated ordering anyway.
Adds two regression tests pinning the emitted shape per mode.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>