From 8135cd29ee993a4bc958fcde8d0dccb6cd6973dc Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 28 Oct 2026 12:28:40 +0100 Subject: [PATCH] fix(sys-svc-compose): generate the CA override after compose build Handlers run in definition order, and "Generate CA trust override" was defined before "Build compose". On a first deploy the override read the Entrypoint and Cmd of every service image with docker image inspect before compose build had created the locally built tags, found none, and left those services without the with-ca-trust wrapper; on later deploys it read the previous build's Entrypoint and Cmd instead of the new one. The override and the compose config validation now run after the build and rebuild handlers and before compose up; pull stays first. The swarm handlers already build before they generate their override and use no wrapper, so they are unchanged. TestFirstDeployFlush replays the shell handlers of one compose-build plus compose-up flush in the order handlers/compose.yml defines them and asserts that compose up starts the freshly built image behind the wrapper. Local deploy: all six stacks with a built image carry the wrapper in compose.ca.override.yml, nextcloud for 8 of 8 services. Co-Authored-By: Claude Opus 5.5 (1M context) --- roles/sys-svc-compose/handlers/compose.yml | 60 +++++++------- .../files/test_compose_ca.py | 79 ++++++++++++++++++- 2 files changed, 108 insertions(+), 31 deletions(-) diff --git a/roles/sys-svc-compose/handlers/compose.yml b/roles/sys-svc-compose/handlers/compose.yml index ceca93dd55..307c0171c9 100644 --- a/roles/sys-svc-compose/handlers/compose.yml +++ b/roles/sys-svc-compose/handlers/compose.yml @@ -8,36 +8,6 @@ - container-recreate - compose-service-up -- name: 📝 Generate CA trust override - ansible.builtin.shell: | - set -euo pipefail - {{ lookup('compose_ca_inject_cmd', application_id) }} - args: - chdir: "{{ lookup('container', application_id, 'directories.instance') }}" - executable: /bin/bash - register: compose_ca_override - timeout: "{{ lookup('timeout', 1200) | int }}" - retries: 7 - delay: 20 - until: compose_ca_override is succeeded - listen: - - compose-up - when: lookup('ca_injected', application_id) - -- name: ✅ Validate Docker Compose configuration - ansible.builtin.shell: | - set -euo pipefail - compose config --quiet - args: - chdir: "{{ lookup('container', application_id, 'directories.instance') }}" - executable: /bin/bash - register: dc_validate - changed_when: false - failed_when: dc_validate.rc != 0 - listen: - - compose-up - when: MODE_ASSERT_LOCAL | bool - - name: 📥 compose pull ansible.builtin.script: cmd: >- @@ -92,6 +62,36 @@ listen: - compose-build +- name: 📝 Generate CA trust override + ansible.builtin.shell: | + set -euo pipefail + {{ lookup('compose_ca_inject_cmd', application_id) }} + args: + chdir: "{{ lookup('container', application_id, 'directories.instance') }}" + executable: /bin/bash + register: compose_ca_override + timeout: "{{ lookup('timeout', 1200) | int }}" + retries: 7 + delay: 20 + until: compose_ca_override is succeeded + listen: + - compose-up + when: lookup('ca_injected', application_id) + +- name: ✅ Validate Docker Compose configuration + ansible.builtin.shell: | + set -euo pipefail + compose config --quiet + args: + chdir: "{{ lookup('container', application_id, 'directories.instance') }}" + executable: /bin/bash + register: dc_validate + changed_when: false + failed_when: dc_validate.rc != 0 + listen: + - compose-up + when: MODE_ASSERT_LOCAL | bool + - name: "🌐 compose: pre-create external provider networks for the stack" ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/network/create.yml') }}" vars: diff --git a/tests/unit/python/roles/sys-svc-compose-ca/files/test_compose_ca.py b/tests/unit/python/roles/sys-svc-compose-ca/files/test_compose_ca.py index dc94fb3b47..7abc96f495 100644 --- a/tests/unit/python/roles/sys-svc-compose-ca/files/test_compose_ca.py +++ b/tests/unit/python/roles/sys-svc-compose-ca/files/test_compose_ca.py @@ -6,7 +6,7 @@ import unittest from pathlib import Path from unittest.mock import patch -from utils.cache.yaml import load_yaml, load_yaml_str +from utils.cache.yaml import load_yaml, load_yaml_any, load_yaml_str from . import PROJECT_ROOT @@ -15,6 +15,24 @@ _CA_TRUST = load_yaml(str(PROJECT_ROOT / "group_vars" / "all" / "02_tls.yml"))[ ] CA_CERT_CONTAINER = _CA_TRUST["inject_cert_container"] CA_WRAPPER_CONTAINER = _CA_TRUST["inject_wrapper_container"] +COMPOSE_HANDLERS = PROJECT_ROOT / "roles/sys-svc-compose/handlers/compose.yml" + + +def _flushed_shells(*topics: str) -> list[str]: + """Shell bodies of the compose handlers one flush runs, in run order. + + Args: + topics: the notified listen topics. + + Returns: + the `ansible.builtin.shell` body of every handler listening on any + topic, in definition order. + """ + return [ + handler.get("ansible.builtin.shell", "") + for handler in load_yaml_any(str(COMPOSE_HANDLERS)) + if set(topics) & set(handler["listen"]) + ] def _load_module(rel_path: str, name: str): @@ -1183,5 +1201,64 @@ class TestComposeCaInject(unittest.TestCase): ) +class TestFirstDeployFlush(unittest.TestCase): + def setUp(self): + self.m = _load_module( + "roles/sys-svc-compose-ca/files/python/compose_ca.py", + "compose_ca_first_deploy_mod", + ) + + def test_compose_up_starts_a_freshly_built_image_behind_the_wrapper(self): + image = "app_custom:1" + built: set[str] = set() + + def fake_run(cmd, *, cwd, env, timeout=None, capture=True): + if cmd[:3] == ["docker", "image", "inspect"]: + if cmd[3] not in built: + return 1, "", f"No such image: {cmd[3]}" + cfg = [{"Config": {"Entrypoint": ["/entry.sh"], "Cmd": ["serve"]}}] + return 0, json.dumps(cfg), "" + if cmd[:5] == ["docker", "run", "--rm", "--entrypoint", "/bin/sh"]: + return 0, "", "" + return 1, "", "unexpected" + + override: dict = {} + started_with = None + for shell in _flushed_shells("compose-build", "compose-up"): + if "compose build" in shell: + built.add(image) + elif "compose_ca_inject_cmd" in shell: + with patch.object(self.m, "run", side_effect=fake_run): + override = self.m.render_override( + {"app": {"image": image, "build": {"context": "."}}}, + {"app": ["docker", "compose", "-p", "p", "-f", "compose.yml"]}, + cwd=Path("/tmp"), + env={}, + ca_host="/host/ca.crt", + wrapper_host="/host/with-ca-trust.sh", + ca_container=CA_CERT_CONTAINER, + wrapper_container=CA_WRAPPER_CONTAINER, + trust_name="infinito.local", + ) + elif "compose up -d --force-recreate" in shell: + started_with = dict(override) + + self.assertIsNotNone(started_with, "the flush never reached compose up") + self.assertIn( + "services", + started_with, + "compose up ran before the CA override was generated", + ) + service = started_with["services"]["app"] + self.assertEqual( + service.get("entrypoint"), + [CA_WRAPPER_CONTAINER], + "compose up started the image this flush built without the CA " + "wrapper: the override was generated before compose build, when " + "the locally built tag could not be inspected yet", + ) + self.assertEqual(service.get("command"), ["/entry.sh", "serve"]) + + if __name__ == "__main__": unittest.main()