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,105 @@
import unittest
import unittest.mock as mock
from pathlib import Path
from typing import Any, ClassVar
from ansible.parsing.dataloader import DataLoader
from ansible.template import Templar
from ansible.utils.display import Display
from utils.cache.base import _render_with_templar
from utils.cache.files import PROJECT_ROOT
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_SERVICES
_GENERAL_VARS = Path(PROJECT_ROOT) / "group_vars" / "all" / "00_general.yml"
_ROLES_DIR = Path(PROJECT_ROOT) / "roles"
_PROVIDERS = {"recaptcha": "RECAPTCHA", "hcaptcha": "HCAPTCHA"}
_CREDENTIALS = {"KEY": "site-key", "SECRET": "site-secret"}
def _consumers() -> list[tuple[str, str, str]]:
found = []
for role_dir in sorted(_ROLES_DIR.iterdir()):
services_file = role_dir / ROLE_FILE_META_SERVICES
if not services_file.is_file():
continue
services = load_yaml_any(services_file) or {}
for service_key, provider in _PROVIDERS.items():
entry = services.get(service_key)
enabled = entry.get("enabled") if isinstance(entry, dict) else None
if isinstance(enabled, str) and f"{provider}_ENABLED" in enabled:
found.append((role_dir.name, service_key, enabled))
return found
class TestCaptchaEnabled(unittest.TestCase):
"""``services.<provider>.enabled`` reaches its consumers through the
applications render, whose failure path hands back template text instead
of raising. Inventory ``CAPTCHA`` replaces the group_vars mapping whole,
so a host that configures one provider drops the other's sub-mapping.
"""
group_vars: ClassVar[dict[str, Any]]
consumers: ClassVar[list[tuple[str, str, str]]]
@classmethod
def setUpClass(cls):
cls.group_vars = DataLoader().load_from_file(
str(_GENERAL_VARS), trusted_as_template=True
)
cls.consumers = _consumers()
def _render(self, value: str, captcha: dict[str, Any]) -> tuple[Any, list[str]]:
variables = {**self.group_vars, "CAPTCHA": captcha}
templar = Templar(loader=DataLoader(), variables=variables)
with mock.patch.object(Display, "deprecated") as deprecated:
rendered = _render_with_templar(value, templar=templar, variables=variables)
coercions = [
str(call) for call in deprecated.call_args_list if "coerced" in str(call)
]
return rendered, coercions
def _assert_every_consumer(self, captcha_for, expected_for):
for role, service_key, value in self.consumers:
provider = _PROVIDERS[service_key]
with self.subTest(role=role, service=service_key):
rendered, coercions = self._render(value, captcha_for(provider))
self.assertIsInstance(rendered, bool, f"rendered to {rendered!r}")
self.assertEqual(rendered, expected_for(provider))
self.assertEqual(coercions, [])
def test_every_provider_has_a_consumer(self):
self.assertEqual(
{service_key for _, service_key, _ in self.consumers}, set(_PROVIDERS)
)
def test_the_group_vars_defaults_disable_every_provider(self):
self._assert_every_consumer(
lambda provider: self.group_vars["CAPTCHA"], lambda provider: False
)
def test_both_credentials_enable_the_provider(self):
self._assert_every_consumer(
lambda provider: {name: dict(_CREDENTIALS) for name in _PROVIDERS.values()},
lambda provider: True,
)
def test_a_host_that_configures_only_the_other_provider_disables_this_one(self):
def only_the_other(provider):
return {
name: dict(_CREDENTIALS)
for name in _PROVIDERS.values()
if name != provider
}
self._assert_every_consumer(only_the_other, lambda provider: False)
def test_a_host_that_configures_only_this_provider_enables_it(self):
self._assert_every_consumer(
lambda provider: {provider: dict(_CREDENTIALS)}, lambda provider: True
)
if __name__ == "__main__":
unittest.main()
@@ -21,10 +21,13 @@ if TYPE_CHECKING:
sys.path.insert(0, str(PROJECT_ROOT))
from utils import directory_batch
MODULE_PATH = PROJECT_ROOT / "library" / "directory_batch.py"
def _load_directory_batch() -> ModuleType:
sys.modules.setdefault("ansible.module_utils.directory_batch", directory_batch)
spec = importlib.util.spec_from_file_location(
"library_directory_batch", str(MODULE_PATH)
)
@@ -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()
@@ -277,6 +277,33 @@ class TestBuildDockerCmdTimeout(unittest.TestCase):
)
class TestBuildDockerCmdConcurrency(unittest.TestCase):
def test_concurrency_arg_precedes_the_url_separator(self) -> None:
cmd = script.build_docker_cmd(
image="img",
urls=["https://a.example/"],
short_mode=True,
ignore_network_blocks_from=["cdn.example"],
concurrency=4,
)
idx = cmd.index("--concurrency")
self.assertEqual(cmd[idx + 1], "4")
self.assertLess(
idx,
cmd.index("--"),
"an argument after the separator reaches the checker as a URL",
)
def test_no_concurrency_arg_without_one(self) -> None:
cmd = script.build_docker_cmd(
image="img",
urls=["https://a.example/"],
short_mode=True,
ignore_network_blocks_from=[],
)
self.assertNotIn("--concurrency", cmd)
class TestMainTimesOutOnionsOnly(unittest.TestCase):
@patch("script.run_checker")
@patch("script.build_urls_from_nginx_confs")
@@ -21,6 +21,7 @@ _VALUE = {
"--tor-confine-allow": "auth.example.org",
"--onion-timeout": "1000",
"--clearnet-timeout": "1000",
"--concurrency": "4",
}
_LIST_VALUE = {
"--ignore-network-blocks-from": "a.test",
@@ -63,6 +64,7 @@ class TestUnitFlagsAreParsable(unittest.TestCase):
self.assertIn("--accept-status", found)
self.assertIn("--onion-timeout", found)
self.assertIn("--concurrency", found)
def test_every_flag_the_unit_passes_is_accepted_by_the_script(self):
for flag in sorted(_flags_the_unit_passes()):
@@ -0,0 +1,5 @@
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[6]
__all__ = ["PROJECT_ROOT"]
@@ -0,0 +1,216 @@
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
import os
import stat
import sys
import tempfile
import unittest
from pathlib import Path
from typing import TYPE_CHECKING
from unittest import mock
from ansible.module_utils.testing import patch_module_args
from . import PROJECT_ROOT
if TYPE_CHECKING:
from types import ModuleType
sys.path.insert(0, str(PROJECT_ROOT))
from utils import directory_batch
MODULE_PATH = (
PROJECT_ROOT
/ "roles"
/ "sys-svc-compose"
/ "library"
/ "compose_instance_prepare.py"
)
FAKE_COMPOSE = """#!/bin/sh
pwd -P > "{record}"
printf '%s\\n' "$@" >> "{record}"
printf '{stdout}'
printf '{stderr}' >&2
exit {rc}
"""
def _load_module() -> ModuleType:
sys.modules.setdefault("ansible.module_utils.directory_batch", directory_batch)
spec = importlib.util.spec_from_file_location(
"sys_svc_compose_compose_instance_prepare", str(MODULE_PATH)
)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load: {MODULE_PATH}")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[attr-defined]
return mod
_MOD = _load_module()
class ComposeInstancePrepareTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.root = Path(self._tmp.name).resolve()
self.bin = self.root / "bin"
self.bin.mkdir()
self.record = self.root / "compose.record"
self.instance = self.root / "compose" / "app"
self.marker = self.instance / ".compose-up-pending"
self._fake_compose(stdout="", stderr="", rc=0)
def tearDown(self):
self._tmp.cleanup()
def _fake_compose(self, *, stdout, stderr, rc):
script = self.bin / "compose"
script.write_text(
FAKE_COMPOSE.format(
record=self.record, stdout=stdout, stderr=stderr, rc=rc
),
encoding="utf-8",
)
script.chmod(0o755)
def _args(self, **overrides):
args = {
"directories": [str(self.instance), str(self.instance / "volumes")],
"mode": "0755",
"instance": str(self.instance),
"pending_marker": str(self.marker),
"running_check": True,
}
args.update(overrides)
return args
def _run(self, args, *, check_mode=False, diff=False, path=None):
buffer = io.StringIO()
module_args = {**args, "_ansible_check_mode": check_mode, "_ansible_diff": diff}
with (
mock.patch.dict(os.environ, {"PATH": str(path or self.bin)}),
patch_module_args(module_args),
contextlib.redirect_stdout(buffer),
self.assertRaises(SystemExit),
):
_MOD.main()
return json.loads(buffer.getvalue())
def _recorded(self):
return self.record.read_text( # nocheck: cache-read tempdir record the fake compose rewrites on every call
encoding="utf-8"
).splitlines()
def test_creates_missing_directories_with_the_mode(self):
result = self._run(self._args())
self.assertTrue(result["changed"])
self.assertEqual(
result["changed_paths"],
[str(self.instance), str(self.instance / "volumes")],
)
self.assertEqual(stat.S_IMODE(self.instance.stat().st_mode), 0o755)
def test_existing_directories_are_unchanged(self):
self._run(self._args())
result = self._run(self._args())
self.assertFalse(result["changed"])
self.assertEqual(result["changed_paths"], [])
def test_probe_never_reports_changed_on_its_own(self):
self._run(self._args())
self.marker.write_text("", encoding="utf-8")
result = self._run(self._args())
self.assertFalse(result["changed"])
def test_pending_is_true_when_the_marker_exists(self):
self.instance.mkdir(parents=True)
self.marker.write_text("", encoding="utf-8")
self.assertTrue(self._run(self._args())["pending"])
def test_pending_is_false_without_the_marker(self):
self.assertFalse(self._run(self._args())["pending"])
def test_pending_counts_a_dangling_symlink_like_stat(self):
self.instance.mkdir(parents=True)
self.marker.symlink_to(self.root / "gone")
self.assertTrue(self._run(self._args())["pending"])
def test_running_reports_compose_ps_output_without_trailing_newlines(self):
self._fake_compose(stdout="abc\\ndef\\n", stderr="warn\\n", rc=0)
running = self._run(self._args())["running"]
self.assertEqual(running, {"rc": 0, "stdout": "abc\ndef", "stderr": "warn"})
def test_running_runs_ps_with_the_running_filter_in_the_instance(self):
self._run(self._args())
self.assertEqual(
self._recorded(),
[str(self.instance), "ps", "-q", "--filter", "status=running"],
)
def test_running_reports_a_failing_ps_without_failing_the_module(self):
self._fake_compose(stdout="", stderr="no configuration file provided", rc=1)
result = self._run(self._args())
self.assertNotIn("failed", result)
self.assertEqual(result["running"]["rc"], 1)
self.assertEqual(result["running"]["stderr"], "no configuration file provided")
def test_running_is_null_and_ps_skipped_without_running_check(self):
result = self._run(self._args(running_check=False))
self.assertIsNone(result["running"])
self.assertFalse(self.record.exists())
def test_pending_is_reported_without_running_check(self):
self.instance.mkdir(parents=True)
self.marker.write_text("", encoding="utf-8")
self.assertTrue(self._run(self._args(running_check=False))["pending"])
def test_check_mode_creates_nothing_and_skips_ps(self):
result = self._run(self._args(), check_mode=True)
self.assertTrue(result["changed"])
self.assertIsNone(result["running"])
self.assertFalse(self.instance.exists())
self.assertFalse(self.record.exists())
def test_directory_failure_fails_before_ps(self):
self.instance.parent.mkdir(parents=True)
self.instance.write_text("", encoding="utf-8")
result = self._run(self._args())
self.assertTrue(result["failed"])
self.assertIn("already exists as a file", result["msg"])
self.assertFalse(self.record.exists())
def test_missing_compose_fails_loudly(self):
empty = self.root / "empty"
empty.mkdir()
result = self._run(self._args(), path=empty)
self.assertTrue(result["failed"])
self.assertIn("compose", result["msg"])
def test_missing_compose_is_irrelevant_without_running_check(self):
empty = self.root / "empty"
empty.mkdir()
result = self._run(self._args(running_check=False), path=empty)
self.assertNotIn("failed", result)
def test_diff_mode_lists_each_created_directory(self):
result = self._run(self._args(), diff=True)
self.assertEqual(len(result["diff"]), 2)
self.assertEqual(result["diff"][0]["after"]["state"], "directory")
def test_running_check_is_required(self):
args = self._args()
del args["running_check"]
result = self._run(args)
self.assertTrue(result["failed"])
self.assertIn("running_check", result["msg"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,38 @@
from __future__ import annotations
import unittest
from utils.cache.files import PROJECT_ROOT, read_text
from utils.cache.yaml import load_yaml_str
LAUNCHER = PROJECT_ROOT / "roles/web-app-discourse/tasks/03_launcher.yml"
CLEANUP = "launcher/legacy_checkout.yml"
GIT_MODULES = {"git", "ansible.builtin.git"}
def _included_file(task: dict) -> str | None:
include = task.get("include_tasks")
return include.get("file") if isinstance(include, dict) else include
class TestDiscourseLegacyCheckoutOrder(unittest.TestCase):
def test_the_url_named_checkout_is_retired_before_the_clone(self) -> None:
tasks = load_yaml_str(read_text(str(LAUNCHER)))
cleanup = next(
index for index, task in enumerate(tasks) if _included_file(task) == CLEANUP
)
clone = next(
index for index, task in enumerate(tasks) if GIT_MODULES & task.keys()
)
self.assertLess(
cleanup,
clone,
"the git module clones from the services directory, where git takes "
"https://github.com/... for the old checkout's relative path "
"https:/github.com/... and aborts with 'protocol ... is not supported' "
"while that checkout exists",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,214 @@
from __future__ import annotations
import importlib.util
import sys
import unittest
from ansible.template import Templar, trust_as_template
from . import PROJECT_ROOT
def _load_module(rel_path: str, name: str):
path = PROJECT_ROOT / rel_path
spec = importlib.util.spec_from_file_location(name, str(path))
mod = importlib.util.module_from_spec(spec)
assert spec and spec.loader
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
config_mod = _load_module(
"roles/web-app-nextcloud/filter_plugins/nextcloud_plugin_config.py",
"nextcloud_plugin_config",
)
nextcloud_plugin_config_pending = config_mod.nextcloud_plugin_config_pending
nextcloud_occ_value = config_mod.nextcloud_occ_value
PREVIOUS_VALUE_EXPRESSION = (
"{{ ( (item.configvalue | to_json) if (item.configvalue is mapping or "
"(item.configvalue is sequence and item.configvalue is not string)) "
"else (item.configvalue | string) ) }}"
)
def _spec(*entries):
return {"config": {"plugin_configuration": list(entries)}}
def _entry(appid, configkey, configvalue, **extra):
return {"appid": appid, "configkey": configkey, "configvalue": configvalue, **extra}
def _apps(**apps):
return {"system": {}, "apps": apps}
def _keys(pending):
return [(item["appid"], item["configkey"]) for item in pending]
class TestNextcloudOccValue(unittest.TestCase):
def test_matches_the_previous_jinja_rendering(self):
values = [
43200,
0,
"yes",
"",
"groupOfUniqueNames\nposixGroup",
"it's",
None,
True,
1.5,
[{"name": "XWiki", "url": "https://wiki.example/"}],
{"custom_oidc": [{"name": "kc", "groupMapping": {"/a": "admin"}}]},
{"ümlaut": "é", "nested": [1, 2.0, None, False]},
'{"servers": []}',
]
for value in values:
with self.subTest(value=value):
rendered = Templar(variables={"item": {"configvalue": value}}).template(
trust_as_template("=" + PREVIOUS_VALUE_EXPRESSION)
)
self.assertEqual("=" + nextcloud_occ_value(value), rendered)
class TestNextcloudPluginConfigPending(unittest.TestCase):
def test_key_nextcloud_lacks_is_pending(self):
pending = nextcloud_plugin_config_pending(
[_spec(_entry("user_ldap", "background_sync_interval", 43200))],
_apps(user_ldap={"enabled": "yes"}),
)
self.assertEqual(
pending,
[
{
"appid": "user_ldap",
"configkey": "background_sync_interval",
"type": None,
"value": "43200",
}
],
)
def test_held_string_value_is_dropped(self):
pending = nextcloud_plugin_config_pending(
[_spec(_entry("user_ldap", "s01ldap_host", "ldap"))],
_apps(user_ldap={"s01ldap_host": "ldap"}),
)
self.assertEqual(pending, [])
def test_held_integer_value_is_dropped(self):
pending = nextcloud_plugin_config_pending(
[_spec(_entry("user_ldap", "s01ldap_port", 389))],
_apps(user_ldap={"s01ldap_port": 389}),
)
self.assertEqual(pending, [])
def test_negative_control_near_miss_values_stay_pending(self):
pending = nextcloud_plugin_config_pending(
[
_spec(
_entry("a", "case", "Value"),
_entry("a", "space", "v"),
_entry("a", "number", 389),
_entry("a", "padded", "7"),
)
],
_apps(a={"case": "value", "space": "v ", "number": 390, "padded": 7.0}),
)
self.assertEqual(
_keys(pending),
[("a", "case"), ("a", "space"), ("a", "number"), ("a", "padded")],
)
def test_boolean_value_is_always_pending(self):
pending = nextcloud_plugin_config_pending(
[_spec(_entry("a", "flag", "1"), _entry("a", "other", "True"))],
_apps(a={"flag": True, "other": True}),
)
self.assertEqual(_keys(pending), [("a", "flag"), ("a", "other")])
def test_masked_sensitive_value_is_always_pending(self):
masked = config_mod.SENSITIVE_VALUE
pending = nextcloud_plugin_config_pending(
[_spec(_entry("onlyoffice", "jwt_secret", masked))],
_apps(onlyoffice={"jwt_secret": masked}),
)
self.assertEqual(_keys(pending), [("onlyoffice", "jwt_secret")])
def test_typed_entry_is_always_pending_with_its_type(self):
pending = nextcloud_plugin_config_pending(
[_spec(_entry("spreed", "stun_servers", "[]", type="array"))],
_apps(spreed={"stun_servers": "[]"}),
)
self.assertEqual(pending[0]["type"], "array")
self.assertEqual(pending[0]["value"], "[]")
def test_structured_value_held_as_its_json_string_is_dropped(self):
value = [{"name": "XWiki", "url": "https://wiki.example/"}]
pending = nextcloud_plugin_config_pending(
[_spec(_entry("xwiki", "instances", value))],
_apps(xwiki={"instances": nextcloud_occ_value(value)}),
)
self.assertEqual(pending, [])
def test_structured_value_held_decoded_is_pending(self):
value = [{"name": "XWiki"}]
pending = nextcloud_plugin_config_pending(
[_spec(_entry("xwiki", "instances", value))],
_apps(xwiki={"instances": value}),
)
self.assertEqual(_keys(pending), [("xwiki", "instances")])
def test_order_follows_specs_and_entries(self):
pending = nextcloud_plugin_config_pending(
[
_spec(
_entry("onlyoffice", "jwt_secret", "s"),
_entry("onlyoffice", "DocumentServerUrl", "u"),
),
{"enabled": True},
_spec(_entry("bbb", "api.url", "b")),
],
_apps(),
)
self.assertEqual(
_keys(pending),
[
("onlyoffice", "jwt_secret"),
("onlyoffice", "DocumentServerUrl"),
("bbb", "api.url"),
],
)
def test_spec_without_configuration_adds_nothing(self):
pending = nextcloud_plugin_config_pending(
[{"enabled": True}, {"config": None}, {"config": {"archive": "x"}}],
_apps(),
)
self.assertEqual(pending, [])
def test_empty_php_arrays_count_as_no_configuration(self):
pending = nextcloud_plugin_config_pending(
[_spec(_entry("deck", "k", "v"), _entry("news", "k", "v"))],
{"system": [], "apps": {"deck": []}},
)
self.assertEqual(_keys(pending), [("deck", "k"), ("news", "k")])
pending = nextcloud_plugin_config_pending(
[_spec(_entry("deck", "k", "v"))], {"system": [], "apps": []}
)
self.assertEqual(_keys(pending), [("deck", "k")])
class TestFilterModule(unittest.TestCase):
def test_filter_is_registered(self):
self.assertEqual(
config_mod.FilterModule().filters(),
{"nextcloud_plugin_config_pending": nextcloud_plugin_config_pending},
)
if __name__ == "__main__":
unittest.main()
@@ -210,5 +210,198 @@ class TestNextcloudStatus(unittest.TestCase):
MODULE.nextcloud_status("Nextcloud is not installed")
class TestNextcloudNeedsPostUpgrade(unittest.TestCase):
def test_unchanged_redeploy_skips_the_pass(self):
self.assertFalse(
MODULE.nextcloud_needs_post_upgrade(STATUS_JSON, "32.0.1.2", "32.0.1.2")
)
def test_minor_upgrade_in_this_deploy_runs_the_pass(self):
self.assertTrue(
MODULE.nextcloud_needs_post_upgrade(STATUS_JSON, "32.0.0.13", "32.0.0.13")
)
def test_major_upgrade_in_this_deploy_runs_the_pass(self):
self.assertTrue(
MODULE.nextcloud_needs_post_upgrade(STATUS_JSON, "31.0.11.1", "31.0.11.1")
)
def test_fresh_install_runs_the_pass(self):
self.assertTrue(MODULE.nextcloud_needs_post_upgrade(STATUS_JSON, "", ""))
def test_deploy_that_stopped_before_the_repair_runs_the_pass_again(self):
self.assertTrue(
MODULE.nextcloud_needs_post_upgrade(STATUS_JSON, "32.0.1.2", "32.0.0.13")
)
def test_version_change_runs_the_pass_even_when_the_marker_already_matches(self):
self.assertTrue(
MODULE.nextcloud_needs_post_upgrade(STATUS_JSON, "32.0.0.13", "32.0.1.2")
)
def test_missing_local_data_volume_falls_back_to_the_marker(self):
self.assertFalse(
MODULE.nextcloud_needs_post_upgrade(STATUS_JSON, "", "32.0.1.2")
)
def test_php_notice_before_the_marker_is_skipped(self):
output = "PHP Deprecated: something in /var/www/html/lib/x.php\n32.0.1.2\n"
self.assertFalse(
MODULE.nextcloud_needs_post_upgrade(STATUS_JSON, "32.0.1.2", output)
)
def test_php_notice_without_a_marker_runs_the_pass(self):
output = "PHP Deprecated: something in /var/www/html/lib/x.php\n\n"
self.assertTrue(
MODULE.nextcloud_needs_post_upgrade(STATUS_JSON, "32.0.1.2", output)
)
def test_null_marker_runs_the_pass(self):
self.assertTrue(
MODULE.nextcloud_needs_post_upgrade(STATUS_JSON, "32.0.1.2", "null")
)
def test_status_without_version_is_rejected(self):
status = (
'{"installed":true,"versionstring":"32.0.1","maintenance":false,'
'"needsDbUpgrade":false}'
)
with self.assertRaises(AnsibleFilterError):
MODULE.nextcloud_needs_post_upgrade(status, "32.0.1.2", "32.0.1.2")
class TestPhpVersionCompare(unittest.TestCase):
def test_newer_patch_is_greater(self):
self.assertEqual(MODULE._php_version_compare("1.17.0", "1.16.2"), 1)
def test_identical_versions_are_equal(self):
self.assertEqual(MODULE._php_version_compare("10.2.1", "10.2.1"), 0)
def test_numeric_parts_compare_as_numbers(self):
self.assertEqual(MODULE._php_version_compare("1.10.0", "1.9.9"), 1)
def test_shorter_version_is_older(self):
self.assertEqual(MODULE._php_version_compare("5.2", "5.2.0"), -1)
def test_release_is_newer_than_its_beta(self):
self.assertEqual(MODULE._php_version_compare("2.0.0", "2.0.0-beta.1"), 1)
def test_release_candidate_is_older_than_the_release(self):
self.assertEqual(MODULE._php_version_compare("1.0rc1", "1.0"), -1)
def test_alpha_is_older_than_beta(self):
self.assertEqual(MODULE._php_version_compare("1.0.0-alpha", "1.0.0-beta"), -1)
def test_patch_level_is_newer_than_the_release(self):
self.assertEqual(MODULE._php_version_compare("1.0pl1", "1.0"), 1)
def test_empty_version_is_older(self):
self.assertEqual(MODULE._php_version_compare("", "1.0"), -1)
def _update_check(found):
return '{"apps":{"updatenotification":' + found + "}}"
INSTALLED_APPS_JSON = (
'{"enabled":{"deck":"1.16.2","onlyoffice":"10.2.1","files":"2.4.0",'
'"calendar":"6.0.1"}}'
)
class TestNextcloudCachedAppUpdates(unittest.TestCase):
def test_newer_release_of_an_unpinned_app_is_updated(self):
output = _update_check('{"deck":"1.17.0","enabled":"yes"}')
self.assertEqual(
MODULE.nextcloud_cached_app_updates(
output, INSTALLED_APPS_JSON, ["onlyoffice"]
),
["deck"],
)
def test_newer_release_of_a_pinned_app_is_left_alone(self):
output = _update_check('{"onlyoffice":"10.3.0"}')
self.assertEqual(
MODULE.nextcloud_cached_app_updates(
output, INSTALLED_APPS_JSON, ["onlyoffice"]
),
[],
)
def test_release_already_installed_is_not_updated(self):
output = _update_check('{"deck":"1.16.2"}')
self.assertEqual(
MODULE.nextcloud_cached_app_updates(output, INSTALLED_APPS_JSON, []), []
)
def test_release_older_than_the_installed_one_is_not_updated(self):
output = _update_check('{"calendar":"5.5.0"}')
self.assertEqual(
MODULE.nextcloud_cached_app_updates(output, INSTALLED_APPS_JSON, []), []
)
def test_empty_update_check_means_no_update(self):
self.assertEqual(
MODULE.nextcloud_cached_app_updates(
_update_check("[]"), INSTALLED_APPS_JSON, []
),
[],
)
def test_update_check_without_its_app_entry_means_no_update(self):
self.assertEqual(
MODULE.nextcloud_cached_app_updates('{"apps":[]}', INSTALLED_APPS_JSON, []),
[],
)
def test_own_settings_of_the_update_check_are_not_apps(self):
output = _update_check(
'{"core":"34.0.6.1","enabled":"yes","installed_version":"1.22.0",'
'"update_check_errors":0}'
)
self.assertEqual(
MODULE.nextcloud_cached_app_updates(output, INSTALLED_APPS_JSON, []), []
)
def test_release_of_an_app_that_is_not_enabled_is_ignored(self):
output = _update_check('{"news":"27.0.0"}')
self.assertEqual(
MODULE.nextcloud_cached_app_updates(output, INSTALLED_APPS_JSON, []), []
)
def test_app_without_an_installed_version_is_skipped(self):
output = _update_check('{"deck":"1.17.0"}')
self.assertEqual(
MODULE.nextcloud_cached_app_updates(
output, '{"enabled":{"deck":true}}', []
),
[],
)
def test_empty_cached_release_means_no_update(self):
output = _update_check('{"deck":""}')
self.assertEqual(
MODULE.nextcloud_cached_app_updates(output, INSTALLED_APPS_JSON, []), []
)
def test_several_updates_are_sorted(self):
output = _update_check('{"deck":"1.17.0","calendar":"6.1.0"}')
self.assertEqual(
MODULE.nextcloud_cached_app_updates(output, INSTALLED_APPS_JSON, []),
["calendar", "deck"],
)
def test_php_notice_before_the_json_is_skipped(self):
output = "PHP Deprecated: x in y.php\n" + _update_check('{"deck":"1.17.0"}')
self.assertEqual(
MODULE.nextcloud_cached_app_updates(output, INSTALLED_APPS_JSON, []),
["deck"],
)
def test_update_check_output_without_json_is_rejected(self):
with self.assertRaises(AnsibleFilterError):
MODULE.nextcloud_cached_app_updates("broken", INSTALLED_APPS_JSON, [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,46 @@
"""Unit tests for ``utils/roles/applications/dashboard.py``."""
from __future__ import annotations
import unittest
from ansible.errors import AnsibleError
from utils.roles.applications.dashboard import card_target_key
class TestCardTargetKey(unittest.TestCase):
def test_a_binding_without_the_key_targets_the_primary_domain(self):
self.assertEqual(
"", card_target_key("web-app-foo", {"enabled": True, "shared": True})
)
def test_a_missing_binding_targets_the_primary_domain(self):
self.assertEqual("", card_target_key("web-app-foo", None))
def test_the_named_key_is_returned(self):
self.assertEqual(
"element",
card_target_key("web-app-matrix", {"card": "element"}),
)
def test_surrounding_whitespace_is_dropped(self):
self.assertEqual(
"webmail",
card_target_key("web-app-stalwart", {"card": " webmail "}),
)
def test_an_alias_canonical_key_is_not_a_card_target(self):
self.assertEqual("", card_target_key("web-svc-cdn", {"canonical": "cdn"}))
def test_an_empty_key_fails_loudly(self):
with self.assertRaisesRegex(AnsibleError, "web-app-foo"):
card_target_key("web-app-foo", {"card": " "})
def test_a_non_string_key_fails_loudly(self):
with self.assertRaises(AnsibleError):
card_target_key("web-app-foo", {"card": ["element"]})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,73 @@
from __future__ import annotations
import contextlib
import io
import json
import tempfile
import unittest
from pathlib import Path
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.testing import patch_module_args
from utils.directory_batch import ensure_all
def _entry(path, mode="0755"):
return {"path": str(path), "mode": mode, "owner": None, "group": None}
class EnsureAllTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.root = Path(self._tmp.name)
def tearDown(self):
self._tmp.cleanup()
def _ensure(self, entries, *, check_mode=False):
with patch_module_args({"_ansible_check_mode": check_mode}):
module = AnsibleModule(argument_spec={}, supports_check_mode=True)
return ensure_all(module, entries)
def test_returns_the_changed_paths_in_order_with_one_diff_each(self):
first = self.root / "a"
second = self.root / "b"
changed_paths, diffs = self._ensure([_entry(first), _entry(second)])
self.assertEqual(changed_paths, [str(first), str(second)])
self.assertEqual(
[diff["after"]["path"] for diff in diffs], [str(first), str(second)]
)
def test_unchanged_paths_are_left_out(self):
existing = self.root / "kept"
existing.mkdir()
existing.chmod(0o755)
self.assertEqual(self._ensure([_entry(existing)]), ([], []))
def test_empty_entries_change_nothing(self):
self.assertEqual(self._ensure([]), ([], []))
def test_check_mode_reports_without_creating(self):
target = self.root / "new"
changed_paths, _ = self._ensure([_entry(target)], check_mode=True)
self.assertEqual(changed_paths, [str(target)])
self.assertFalse(target.exists())
def test_first_error_fails_with_the_paths_changed_before_it(self):
created = self.root / "created"
blocker = self.root / "blocker"
never = self.root / "never"
blocker.write_text("", encoding="utf-8")
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer), self.assertRaises(SystemExit):
self._ensure([_entry(created), _entry(blocker), _entry(never)])
result = json.loads(buffer.getvalue())
self.assertTrue(result["failed"])
self.assertEqual(result["path"], str(blocker))
self.assertEqual(result["changed_paths"], [str(created)])
self.assertFalse(never.exists())
if __name__ == "__main__":
unittest.main()