mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
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:
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,
|
||||
}
|
||||
@@ -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__":
|
||||
|
||||
Reference in new issue
Block a user