fix(sys-svc-compose): update config hashes instead of re-rendering

The handler that rendered compose.yml.j2 again before compose up used
the variables of the include that loaded it, not of the task that
notified it. web-app-xwiki renders its compose file twice with
different OIDC/LDAP switches, so the re-render deployed it with
standard auth.

The handler now leaves the template alone. It reads the existing
compose.yml and, for each config or secret named <role>_<key>_<hash>,
replaces only the hash with the one of the current file on disk,
computed the same way as the compose_volumes lookup. A config change
still recreates the stack once, in the same run.

Fixes #1212
This commit is contained in:
Prageeth Panicker committed 2026-10-28 05:18:10 +00:00
1 parent d1b7612517
commit 6b2589a632
6 files changed
+189 -39

No files matched your search

@@ -0,0 +1,55 @@
import base64
import io
import re
from utils.roles.applications.mounts import content_hash
SECTIONS = ("configs", "secrets")
HASH_LENGTH = 8
def compose_hashed_entries(compose):
"""Return the configs and secrets whose name carries the content hash of their file.
Args:
compose: the parsed compose.yml.
Returns:
``[{name, file}]`` for every top-level config or secret named
``<role>_<key>_<sha8>`` with a ``file`` source, the shape the
``compose_volumes`` lookup writes. Explicitly named entries, which do
not end in their own key and a hash, are left out.
"""
entries = []
for section in SECTIONS:
for key, spec in ((compose or {}).get(section) or {}).items():
if not isinstance(spec, dict) or not spec.get("file") or not spec.get("name"):
continue
name = str(spec["name"])
if re.search(rf"_{re.escape(str(key))}_[0-9a-f]{{{HASH_LENGTH}}}$", name):
entries.append({"name": name, "file": str(spec["file"])})
return entries
def compose_rehashed_name(name, content_b64):
"""Return a hashed config or secret name with the hash of its file's current content.
Args:
name: the ``<role>_<key>_<sha8>`` name from compose.yml.
content_b64: the file content as ``slurp`` returns it.
Returns:
The name with its last eight characters replaced by the hash the
``compose_volumes`` lookup computes: sha256 of the file read as UTF-8
text with universal newlines, as ``utils.cache.files.read_text`` does.
"""
text = io.StringIO(base64.b64decode(content_b64).decode("utf-8"), newline=None).read()
return name[:-HASH_LENGTH] + content_hash(text, HASH_LENGTH)
class FilterModule:
def filters(self):
return {
"compose_hashed_entries": compose_hashed_entries,
"compose_rehashed_name": compose_rehashed_name,
}
+3 -8
View File
@@ -6,17 +6,12 @@
- compose-build
- container-recreate
# Exception: compose.yml first renders before the role writes its config and secret files, so their content-hash names lag one deploy; rendering again at flush picks up the files as they are now.
- name: "📁 Re-render compose.yml with the current config hashes"
stack_host_template:
src: "{{ application_id | abs_role_path_by_application_id }}/templates/compose.yml.j2"
dest: "{{ lookup('container', application_id, 'files.compose') }}"
mode: "0640"
force: true
# Exception: compose.yml first renders before the role writes its config and secret files, so their content-hash names lag one deploy. Only those names are recomputed from the files at flush; re-rendering the template here would run in the scope of the include that loaded the handler, not of the notifying task, and lose role switches set only there.
- name: "🔁 Update the config and secret hashes in compose.yml"
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/rehash_configs.yml') }}"
listen:
- compose-up
- container-recreate
when: (application_id | abs_role_path_by_application_id ~ '/templates/compose.yml.j2') is file
- name: 📝 Generate CA trust override
ansible.builtin.shell: |
@@ -0,0 +1,36 @@
---
- name: "🔍 Stat compose.yml of '{{ application_id }}'"
ansible.builtin.stat:
path: "{{ lookup('container', application_id, 'files.compose') }}"
get_checksum: false
register: sys_svc_compose_rehash_stat
delegate_to: "{{ STACK_HOST }}"
- name: "🔁 Update the hashed config and secret names of '{{ application_id }}'"
when: sys_svc_compose_rehash_stat.stat.exists
block:
- name: "📖 Read compose.yml of '{{ application_id }}'"
ansible.builtin.slurp:
src: "{{ lookup('container', application_id, 'files.compose') }}"
register: sys_svc_compose_rehash_compose
delegate_to: "{{ STACK_HOST }}"
- name: "📖 Read each hashed config and secret of '{{ application_id }}'"
ansible.builtin.slurp:
src: "{{ item.file }}"
loop: "{{ sys_svc_compose_rehash_compose.content | b64decode | from_yaml | compose_hashed_entries }}"
loop_control:
label: "{{ item.name }}"
register: sys_svc_compose_rehash_sources
delegate_to: "{{ STACK_HOST }}"
- name: "🔁 Point each hashed name of '{{ application_id }}' at its file's current content"
ansible.builtin.replace:
path: "{{ lookup('container', application_id, 'files.compose') }}"
regexp: "(?<![\\w.-]){{ item.item.name | regex_escape }}(?![\\w.-])"
replace: "{{ item.item.name | compose_rehashed_name(item.content) }}"
loop: "{{ sys_svc_compose_rehash_sources.results }}"
loop_control:
label: "{{ item.item.name }}"
when: item.item.name != (item.item.name | compose_rehashed_name(item.content))
delegate_to: "{{ STACK_HOST }}"
Whitespace-only changes.
@@ -0,0 +1,71 @@
import base64
import importlib.util
import tempfile
import unittest
from pathlib import Path
from plugins.lookup.compose_volumes import _config_secret_name
from utils import PROJECT_ROOT
_spec = importlib.util.spec_from_file_location(
"compose_rehash",
PROJECT_ROOT / "roles/sys-svc-compose/filter_plugins/compose_rehash.py",
)
compose_rehash = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(compose_rehash)
def slurped(data):
return base64.b64encode(data).decode()
class TestHashedEntries(unittest.TestCase):
def test_only_names_ending_in_their_key_and_a_hash(self):
compose = {
"services": {"app": {"configs": [{"source": "roles_yml"}]}},
"configs": {
"roles_yml": {"file": "/c/roles.yml", "name": "wazuh_roles_yml_e614d1ac"},
"pinned": {"file": "/c/p.yml", "name": "fixed_name_deadbeef"},
"external": {"external": True, "name": "shared_x_12345678"},
},
"secrets": {"authd": {"file": "/c/authd", "name": "wazuh_authd_810ae9fb"}},
}
self.assertEqual(
compose_rehash.compose_hashed_entries(compose),
[
{"name": "wazuh_roles_yml_e614d1ac", "file": "/c/roles.yml"},
{"name": "wazuh_authd_810ae9fb", "file": "/c/authd"},
],
)
def test_no_configs_or_secrets(self):
self.assertEqual(compose_rehash.compose_hashed_entries({"services": {}}), [])
self.assertEqual(compose_rehash.compose_hashed_entries(None), [])
class TestRehashedName(unittest.TestCase):
def assert_matches_lookup(self, data):
with tempfile.NamedTemporaryFile("wb", delete=False) as handle:
handle.write(data)
try:
expected = _config_secret_name("wazuh", "roles_yml", handle.name)
finally:
Path(handle.name).unlink()
self.assertEqual(
compose_rehash.compose_rehashed_name("wazuh_roles_yml_00000000", slurped(data)),
expected,
)
def test_equals_the_compose_volumes_name(self):
self.assert_matches_lookup(b"roles:\n - admin\n")
def test_crlf_and_unicode_hash_like_the_lookup(self):
self.assert_matches_lookup("a: 1\r\nb: ä\r\n".encode())
def test_unchanged_content_keeps_the_name(self):
name = compose_rehash.compose_rehashed_name("x_k_00000000", slurped(b"v\n"))
self.assertEqual(compose_rehash.compose_rehashed_name(name, slurped(b"v\n")), name)
if __name__ == "__main__":
unittest.main()
@@ -1,14 +1,14 @@
import unittest
from utils import PROJECT_ROOT
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_TEMPL_COMPOSE
HANDLERS = PROJECT_ROOT / "roles" / "sys-svc-compose" / "handlers" / "compose.yml"
TEMPLATE_SUFFIX = "/" + ROLE_FILE_TEMPL_COMPOSE
REHASH_TASKS = "roles/sys-svc-compose/tasks/utils/rehash_configs.yml"
class TestComposeFileRerenderedAtFlush(unittest.TestCase):
class TestComposeHashesUpdatedAtFlush(unittest.TestCase):
def setUp(self):
self.handlers = load_yaml_any(str(HANDLERS))
@@ -19,21 +19,12 @@ class TestComposeFileRerenderedAtFlush(unittest.TestCase):
self.assertEqual(len(matches), 1, matches)
return matches[0]
def rerender(self):
return self.handlers[
self.index_of(
lambda h: str(h.get("stack_host_template", {}).get("src", "")).endswith(
TEMPLATE_SUFFIX
)
)
]
def test_compose_file_is_rendered_again_before_compose_pull_and_up(self):
rerender = self.index_of(
lambda h: str(h.get("stack_host_template", {}).get("src", "")).endswith(
TEMPLATE_SUFFIX
)
def rehash_index(self):
return self.index_of(
lambda h: REHASH_TASKS in str(h.get("ansible.builtin.include_tasks", ""))
)
def test_hashes_are_updated_before_compose_pull_and_up(self):
pull = self.index_of(
lambda h: (
"pull.py" in str(h.get("ansible.builtin.script", {}).get("cmd", ""))
@@ -42,30 +33,32 @@ class TestComposeFileRerenderedAtFlush(unittest.TestCase):
up = self.index_of(
lambda h: "compose up" in str(h.get("ansible.builtin.shell", ""))
)
self.assertLess(rerender, pull)
self.assertLess(rerender, up)
self.assertLess(self.rehash_index(), pull)
self.assertLess(self.rehash_index(), up)
def test_rerender_listens_where_compose_up_listens(self):
def test_rehash_listens_where_compose_up_listens(self):
up = self.handlers[
self.index_of(
lambda h: "compose up" in str(h.get("ansible.builtin.shell", ""))
)
]
self.assertEqual(sorted(self.rerender()["listen"]), sorted(up["listen"]))
def test_rerender_writes_the_file_the_tasks_write(self):
args = self.rerender()["stack_host_template"]
self.assertEqual(
args["dest"], "{{ lookup('container', application_id, 'files.compose') }}"
sorted(self.handlers[self.rehash_index()]["listen"]), sorted(up["listen"])
)
self.assertEqual(args["mode"], "0640")
self.assertIs(args["force"], True)
def test_rerender_name_is_static_so_ansible_keeps_one_instance(self):
self.assertNotIn("{{", self.rerender()["name"])
def test_no_handler_renders_a_role_template(self):
for handler in self.handlers:
self.assertNotIn("stack_host_template", handler, handler.get("name"))
self.assertNotIn("ansible.builtin.template", handler, handler.get("name"))
self.assertNotIn("template", handler, handler.get("name"))
def test_role_without_compose_template_skips_the_rerender(self):
self.assertIn("is file", self.rerender()["when"])
def test_rehash_tasks_render_no_template_and_read_no_role_variable(self):
text = read_text(str(PROJECT_ROOT / REHASH_TASKS))
self.assertNotIn("template", text)
self.assertNotIn("abs_role_path_by_application_id", text)
def test_rehash_name_is_static_so_ansible_keeps_one_instance(self):
self.assertNotIn("{{", self.handlers[self.rehash_index()]["name"])
if __name__ == "__main__":