Merge branch 'feature/updater' into feature/store-i18n

16 conflicts, resolved per hunk:

- sys-stk-front-proxy: tasks/main.yml keeps the network-sibling loop;
  the single stack_host_contents write of http.conf plus vhost and the
  front_proxy_vhost_changed gate move into tasks/vhost.yml, with the
  http.conf decided per sibling domain so an onion vhost gets no HTTPS
  redirect.
- sys-front-tls: 01_http.yml is gone as the updater intends; the
  cert-renew include in main.yml gates on the vhost domain's TLS.
- sys-svc-compose: the updater's resolve_host_cid without the remote
  echo and the prepared pending flag in up.yml, written with
  IS_SWARM_MODE and the repeated-when block grouping.
- web-app-nextcloud: the updater's post-upgrade gate and pending-only
  plugin configuration, which already carry the pinned-plugin handling.
- web-app-xwiki: one XWIKI_CORE_VERSION for the readiness probe and
  style.css.j2; OIDC/LDAP follow xwiki_extensions_ready at bootstrap,
  the S3 blob store stays off there as TestBlobstoreRenderOrder demands.
- roles_with_service: onion swap and dashboard card key combined, the
  canonical domain is the canonical URL's host in both branches.
- sys-ctl-hlth-csp: --clearnet-timeout and --concurrency together;
  run_checker forwards its options to build_docker_cmd, keeping the
  script under the 500-line limit.
- web-app-matrix: Element branding and web_client_location side by side.
- web-app-stalwart/README.md: the generated Cosmos section is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-25 23:47:07 +01:00
commit 99123762df
72 files changed
+2844 -345

No files matched your search

@@ -0,0 +1,324 @@
from __future__ import annotations
import base64
import io
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from baudolo.databases import DatabasesCsvError, validate_database
from baudolo.seed.__main__ import check_and_add_entry
from plugins.filter.baudolo_seed import (
FilterModule,
_database,
baudolo_seed_apply,
baudolo_seed_current,
baudolo_seeded,
)
HEADER = "instance;database;username;password\n"
def _b64(text: str) -> str:
return base64.b64encode(text.encode("utf-8")).decode("ascii")
def _seeded(text: str) -> dict:
return baudolo_seeded(_b64(text))
STABLE = [
("central-postgres", "nextcloud", "nextcloud", "pw-1"),
("central-mariadb", "wordpress", "wordpress", "pa;ss"),
("central-postgres", "", "postgres", 'quo"te'),
("central-mariadb", "matomo", "matomo", "multi\nline"),
("central-postgres", " keycloak ", "keycloak", " spaced "),
("central-postgres", "gitea", "gitea", ""),
("central-postgres", "listmonk", "listmonk", "ünïcode"),
]
ENTRIES = [
*STABLE[:5],
("central-postgres", "nextcloud", "nextcloud", "pw-2"),
("central-mariadb", "wordpress", "wordpress", "pa;ss"),
*STABLE[5:],
]
def _argv(entry) -> tuple[str, str, str, str]:
instance, name, username, password = entry
return instance, name or "*", username, password
def _bytes(path: Path) -> bytes:
if not path.exists():
return b""
return path.read_bytes() # nocheck: cache-read baudolo-seed rewrites the tempfile
def _b64_of(path: Path) -> str:
return base64.b64encode(_bytes(path)).decode("ascii")
def _seed(path: Path, argv) -> None:
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
check_and_add_entry(str(path), *argv)
class TestBaudoloSeedEquivalence(unittest.TestCase):
def _per_entry(self, path: Path, entries) -> int:
for entry in entries:
_seed(path, _argv(entry))
return len(entries)
def _skipping(self, path: Path, entries) -> int:
state = baudolo_seeded(_b64_of(path))
calls = 0
for entry in entries:
argv = _argv(entry)
current = baudolo_seed_current(state, *argv)
state = baudolo_seed_apply(state, *argv)
if not current:
_seed(path, argv)
calls += 1
return calls
def _run_both(self, start: bytes | None, entries):
with tempfile.TemporaryDirectory() as tmp:
old = Path(tmp) / "old.csv"
new = Path(tmp) / "new.csv"
if start is not None:
old.write_bytes(start)
new.write_bytes(start)
old_calls = self._per_entry(old, entries)
new_calls = self._skipping(new, entries)
return _bytes(old), _bytes(new), old_calls, new_calls
def test_fresh_file_matches_and_skips_only_repeats(self):
old, new, old_calls, new_calls = self._run_both(None, ENTRIES)
self.assertEqual(old, new)
self.assertEqual(old_calls, len(ENTRIES))
self.assertEqual(new_calls, len(ENTRIES) - 1)
def test_empty_file_from_touch_matches(self):
old, new, _, _ = self._run_both(b"", ENTRIES)
self.assertEqual(old, new)
def _converged(self, entries) -> bytes:
with tempfile.TemporaryDirectory() as tmp:
first = Path(tmp) / "first.csv"
self._per_entry(first, entries)
return _bytes(first)
def test_converged_file_needs_no_call_and_keeps_its_bytes(self):
converged = self._converged(STABLE)
old, new, _, new_calls = self._run_both(converged, STABLE)
self.assertEqual(old, new)
self.assertEqual(new, converged)
self.assertEqual(new_calls, 0)
def test_entries_that_overwrite_each_other_are_seeded_every_time(self):
converged = self._converged(ENTRIES)
old, new, _, new_calls = self._run_both(converged, ENTRIES)
self.assertEqual(old, new)
self.assertEqual(new_calls, 2)
def test_rotated_password_is_seeded_again(self):
converged = self._converged(STABLE)
rotated = [*STABLE[:-1], ("central-postgres", "listmonk", "listmonk", "rot")]
old, new, _, new_calls = self._run_both(converged, rotated)
self.assertEqual(old, new)
self.assertEqual(new_calls, 1)
def test_new_consumer_is_appended_after_the_converged_rows(self):
converged = self._converged(STABLE)
grown = [*STABLE, ("central-mariadb", "pixelfed", "pixelfed", "x")]
old, new, _, new_calls = self._run_both(converged, grown)
self.assertEqual(old, new)
self.assertEqual(new_calls, 1)
def test_invalid_database_name_still_reaches_baudolo(self):
entry = ("central-postgres", "bad name", "u", "p")
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "databases.csv"
self._per_entry(path, STABLE)
with self.assertRaises(DatabasesCsvError):
self._skipping(path, [entry])
def test_hand_edited_file_falls_back_to_one_call_per_entry(self):
hand = (HEADER + "central-postgres;nextcloud;nextcloud;pw-2\r\n").encode()
old, new, old_calls, new_calls = self._run_both(hand, ENTRIES)
self.assertEqual(old, new)
self.assertEqual(new_calls, old_calls)
def test_short_row_falls_back_to_one_call_per_entry(self):
short = (HEADER + "central-postgres;nextcloud\n").encode()
old, new, old_calls, new_calls = self._run_both(short, ENTRIES)
self.assertEqual(old, new)
self.assertEqual(new_calls, old_calls)
def test_negative_control_a_state_older_than_the_file_breaks_equivalence(self):
converged = self._converged(STABLE)
with tempfile.TemporaryDirectory() as tmp:
stale = Path(tmp) / "stale.csv"
stale.write_bytes(b"")
state = baudolo_seeded(base64.b64encode(converged).decode("ascii"))
for entry in STABLE:
if not baudolo_seed_current(state, *_argv(entry)):
_seed(stale, _argv(entry))
self.assertNotEqual(_bytes(stale), converged)
class TestBaudoloSeeded(unittest.TestCase):
def test_absent_or_empty_file_is_canonical_without_rows(self):
self.assertEqual(baudolo_seeded(""), {"canonical": True, "rows": []})
def test_header_only_is_canonical_without_rows(self):
self.assertEqual(_seeded(HEADER), {"canonical": True, "rows": []})
def test_rows_are_read_in_file_order(self):
text = HEADER + "a;db1;u1;p1\nb;*;u2;p2\n"
self.assertEqual(
_seeded(text),
{
"canonical": True,
"rows": [["a", "db1", "u1", "p1"], ["b", "*", "u2", "p2"]],
},
)
def test_quoted_fields_stay_canonical(self):
text = HEADER + 'a;db;u;"pa;ss"\nb;db;u;"q""t"\n'
state = _seeded(text)
self.assertTrue(state["canonical"])
self.assertEqual(state["rows"][0][3], "pa;ss")
self.assertEqual(state["rows"][1][3], 'q"t')
def test_crlf_is_not_canonical(self):
self.assertFalse(_seeded(HEADER.replace("\n", "\r\n"))["canonical"])
def test_missing_trailing_newline_is_not_canonical(self):
self.assertFalse(_seeded(HEADER + "a;db;u;p")["canonical"])
def test_blank_line_is_not_canonical(self):
self.assertFalse(_seeded(HEADER + "\na;db;u;p\n")["canonical"])
def test_needless_quotes_are_not_canonical(self):
self.assertFalse(_seeded(HEADER + 'a;db;u;"p"\n')["canonical"])
def test_foreign_header_is_not_canonical(self):
self.assertFalse(_seeded("instance;database;user;password\n")["canonical"])
def test_undecodable_content_is_not_canonical(self):
self.assertFalse(baudolo_seeded("not base64!")["canonical"])
self.assertFalse(
baudolo_seeded(base64.b64encode(b"\xff\xfe").decode())["canonical"]
)
class TestBaudoloSeedCurrent(unittest.TestCase):
def setUp(self):
self.state = _seeded(HEADER + "a;db;u;p\na;*;su;sp\nb;db;u;p\nb;db;u;q\n")
def test_same_credentials_are_current(self):
self.assertTrue(baudolo_seed_current(self.state, "a", "db", "u", "p"))
def test_cluster_row_is_current(self):
self.assertTrue(baudolo_seed_current(self.state, "a", "*", "su", "sp"))
def test_padded_database_name_matches_the_stripped_row(self):
self.assertTrue(baudolo_seed_current(self.state, "a", " db ", "u", "p"))
def test_negative_control_other_password_is_not_current(self):
self.assertFalse(baudolo_seed_current(self.state, "a", "db", "u", "p2"))
def test_negative_control_other_username_is_not_current(self):
self.assertFalse(baudolo_seed_current(self.state, "a", "db", "u2", "p"))
def test_missing_row_is_not_current(self):
self.assertFalse(baudolo_seed_current(self.state, "c", "db", "u", "p"))
def test_duplicate_rows_with_other_credentials_are_not_current(self):
self.assertFalse(baudolo_seed_current(self.state, "b", "db", "u", "p"))
def test_invalid_database_name_is_never_current(self):
state = _seeded(HEADER + "a;bad name;u;p\n")
self.assertFalse(baudolo_seed_current(state, "a", "bad name", "u", "p"))
def test_non_canonical_state_is_never_current(self):
state = _seeded(HEADER + 'a;db;u;"p"\n')
self.assertFalse(baudolo_seed_current(state, "a", "db", "u", "p"))
class TestBaudoloSeedApply(unittest.TestCase):
def test_new_definition_is_appended(self):
state = baudolo_seed_apply(_seeded(HEADER + "a;db;u;p\n"), "b", "*", "u", "p")
self.assertEqual(state["rows"], [["a", "db", "u", "p"], ["b", "*", "u", "p"]])
def test_every_matching_row_takes_the_credentials(self):
state = baudolo_seed_apply(
_seeded(HEADER + "a;db;u;p\nx;db;u;p\na;db;v;q\n"), "a", "db", "n", "m"
)
self.assertEqual(
state["rows"],
[["a", "db", "n", "m"], ["x", "db", "u", "p"], ["a", "db", "n", "m"]],
)
def test_database_name_is_stored_stripped(self):
state = baudolo_seed_apply(baudolo_seeded(""), "a", " db ", "u", "p")
self.assertEqual(state["rows"], [["a", "db", "u", "p"]])
def test_input_state_is_not_mutated(self):
state = _seeded(HEADER + "a;db;u;p\n")
baudolo_seed_apply(state, "a", "db", "u", "q")
self.assertEqual(state["rows"], [["a", "db", "u", "p"]])
def test_non_canonical_state_stays_non_canonical(self):
state = baudolo_seed_apply(
_seeded(HEADER + 'a;db;u;"p"\n'), "a", "db", "u", "p"
)
self.assertEqual(state, {"canonical": False, "rows": []})
def test_invalid_database_name_makes_the_state_non_canonical(self):
state = baudolo_seed_apply(baudolo_seeded(""), "a", "nan", "u", "p")
self.assertFalse(state["canonical"])
class TestDatabaseNameParity(unittest.TestCase):
def test_validation_matches_baudolo(self):
for value in (
"db",
" db ",
"*",
" * ",
"",
" ",
"nan",
"NaN",
"a-b_c",
"-a",
"a b",
"ü",
):
with self.subTest(value=value):
try:
expected = validate_database(value, instance="i")
except DatabasesCsvError:
expected = None
self.assertEqual(_database(value), expected)
class TestFilterModule(unittest.TestCase):
def test_filters_are_registered(self):
self.assertEqual(
FilterModule().filters(),
{
"baudolo_seeded": baudolo_seeded,
"baudolo_seed_current": baudolo_seed_current,
"baudolo_seed_apply": baudolo_seed_apply,
},
)
if __name__ == "__main__":
unittest.main()
@@ -125,6 +125,26 @@ class DummyTlsResolveLookup:
return [base_url]
class DummyCanonicalUrlLookup:
"""
Deterministic canonical_url stub.
Emulates: lookup('canonical_url', app_id, key) -> 'http(s)://<domains[app_id][key]>'
"""
def run(self, terms, variables=None, **kwargs):
variables = variables or {}
app_id, key = terms
app_domains = variables.get("domains", {}).get(app_id)
if not isinstance(app_domains, dict) or key not in app_domains:
raise AnsibleError(
f"dummy canonical_url: '{app_id}' has no canonical '{key}'"
)
scheme = (
"https" if _ansible_bool(variables.get("TLS_ENABLED", True)) else "http"
)
return [f"{scheme}://{app_domains[key]}"]
class TestDockerCardsLookup(unittest.TestCase):
def setUp(self):
self.test_roles_dir = tempfile.mkdtemp(prefix="test_roles_")
@@ -174,6 +194,8 @@ logo:
return _VarsLookup("domains")
if name == "tls":
return DummyTlsResolveLookup(templar)
if name == "canonical_url":
return DummyCanonicalUrlLookup()
raise AnsibleError(f"Unexpected lookup requested: {name}")
docker_cards_module.lookup_loader.get = _patched_get
@@ -304,6 +326,39 @@ logo:
self.assertEqual(result[0][0]["url"], "http://myportfolio.com")
def _card_target_variables(self, dashboard):
fake_variables = self._base_fake_variables()
fake_variables["domains"] = {
"portfolio": {
"www": "myportfolio.com",
"client": "client.myportfolio.com",
}
}
fake_variables["applications"]["portfolio"]["services"]["dashboard"] = dashboard
return fake_variables
def test_card_url_targets_the_card_key(self):
fake_variables = self._card_target_variables(
{"enabled": True, "card": "client"}
)
result = self._run_lookup(LookupModule(), fake_variables)
self.assertEqual(result[0][0]["url"], "https://client.myportfolio.com")
def test_card_url_without_card_key_targets_the_primary_domain(self):
fake_variables = self._card_target_variables({"enabled": True})
result = self._run_lookup(LookupModule(), fake_variables)
self.assertEqual(result[0][0]["url"], "https://myportfolio.com")
def test_unknown_card_key_fails_loudly(self):
fake_variables = self._card_target_variables({"enabled": True, "card": "nope"})
with self.assertRaisesRegex(AnsibleError, "nope"):
self._run_lookup(LookupModule(), fake_variables)
if __name__ == "__main__":
unittest.main()
@@ -72,13 +72,15 @@ class RolesWithServiceLookupTests(unittest.TestCase):
handover: frozenset = frozenset(),
**kwargs,
):
"""Run the lookup with the ``applications``, ``tls`` and
``network_handover`` sub-lookups patched to stay hermetic. The stubbed
tls answers ``[role_id, "url.base"]`` with the role's own configured
domain, which is what a clearnet deployment resolves; pass ``url_host``
(every role) or ``url_hosts`` (per role) to model a deployment whose URL
host differs, such as an onion one. A role in ``handover`` hands every
sibling outside its canonical network over."""
"""Run the lookup with the ``applications``, ``tls``,
``canonical_url`` and ``network_handover`` sub-lookups patched to stay
hermetic; any other sub-lookup raises ``KeyError``. The stubbed tls
answers ``[role_id, "url.base"]`` and the stubbed canonical_url
``[role_id, key]`` with the role's own configured domain, which is what
a clearnet deployment resolves; pass ``url_host`` (every role) or
``url_hosts`` (per role) to model a deployment whose URL host differs,
such as an onion one. A role in ``handover`` hands every sibling outside
its canonical network over."""
lookup = self._make_lookup(vars_ or {})
resolve = self.mod._resolve_canonical_domain
@@ -100,16 +102,31 @@ class RolesWithServiceLookupTests(unittest.TestCase):
role in handover and network_of(domain) != network_of(canonical)
]
named = self.mod._resolve_named_canonical_domain
class _StubCanonicalUrl:
def run(self, terms_, variables=None, **kwargs):
role, key = str(terms_[0]), str(terms_[1])
host = (
(url_hosts or {}).get(role)
or url_host
or named(role, applications.get(role, {}), key)
)
return [f"https://{host}"]
class _StubApplications:
def run(self, terms_, variables=None, **kwargs):
return [applications]
stubs = {
"applications": _StubApplications(),
"tls": _StubTls(),
"canonical_url": _StubCanonicalUrl(),
"network_handover": _StubHandover(),
}
def _dispatch(name, **_kwargs):
if name == "applications":
return _StubApplications()
if name == "network_handover":
return _StubHandover()
return _StubTls()
return stubs[name]
with patch.object(self.mod.lookup_loader, "get", side_effect=_dispatch):
return lookup.run(terms, variables=vars_ or {}, **kwargs)
@@ -683,6 +700,60 @@ class RolesWithServiceLookupTests(unittest.TestCase):
with self.assertRaises(AnsibleError):
self._network_ids({}, network="onion")
def _card_target_role(self, service: str, **block_extra) -> dict:
block = {"enabled": True, "shared": True}
block.update(block_extra)
return {
"web-app-chat": {
"services": {service: block},
"domains": {
"canonical": {
"synapse": "matrix.example.com",
"element": "element.example.com",
}
},
},
}
def test_dashboard_card_key_targets_that_domain(self):
applications = self._card_target_role("dashboard", card="element")
entry = self._run(["dashboard"], applications)[0][0]
self.assertEqual(
(entry["canonical_domain"], entry["canonical_url"]),
("element.example.com", "https://element.example.com"),
)
def test_dashboard_card_domain_follows_the_onion_url(self):
applications = self._card_target_role("dashboard", card="element")
entry = self._run(
["dashboard"], applications, url_hosts={"web-app-chat": f"element.{ONION}"}
)[0][0]
self.assertEqual(
(entry["canonical_domain"], entry["canonical_url"]),
(f"element.{ONION}", f"https://element.{ONION}"),
)
def test_dashboard_without_card_key_targets_the_primary_domain(self):
applications = self._card_target_role("dashboard")
entry = self._run(["dashboard"], applications)[0][0]
self.assertEqual(
(entry["canonical_domain"], entry["canonical_url"]),
("matrix.example.com", "https://matrix.example.com"),
)
def test_dashboard_unknown_card_key_fails_loudly(self):
applications = self._card_target_role("dashboard", card="nope")
with self.assertRaisesRegex(AnsibleError, "nope"):
self._run(["dashboard"], applications)
def test_other_services_ignore_a_card_key(self):
applications = self._card_target_role("prometheus", card="element")
entry = self._run(["prometheus"], applications)[0][0]
self.assertEqual(
(entry["canonical_domain"], entry["canonical_url"]),
("matrix.example.com", "https://matrix.example.com"),
)
if __name__ == "__main__":
unittest.main()