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

Brings in feature/updater accb22da75, which carries main 5995766f58.

Conflict resolutions:
- web-app-fider: main's tenant bootstrap (01_tenant.yml) runs first on
  the stack host, OIDC setup moves to 02_setup_oidc.yml, design to
  03_design.yml; flat includes so main's gate test reads them.
- sys-front-tls test_nginx_ssl_header: the tls fake maps the domain term
  store-i18n passes back to its app.
- group_vars 04_webserver.yml, sys-svc-proxy CSP header: store-i18n's
  webserver_resource lookup plus #1256's WEBSERVER_SECURITY_HEADERS and
  the security_headers.lua include after the CSP block.
- web-app-nextcloud: updater's decoupled Talk stack (Janus, signaling,
  NATS, recording builds) replaces aio-talk; store-i18n's per-network
  Talk backends move from the wrapper script into signaling.conf.j2 urls
  (NEXTCLOUD_HPB_SIGNALING_BACKEND_URLS from network_urls, canonical
  first); IS_COMPOSE_MODE/IS_SWARM_MODE booleans kept; the integration
  OpenProject digest stays delegated to STACK_HOST.
- users lookup docs, token-store write_many, cache README, meta info
  lint: both sides combined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-30 00:40:05 +01:00
commit 5e8890d208
130 files changed
+4303 -898

No files matched your search

+187
View File
@@ -14,12 +14,14 @@ from __future__ import annotations
import tempfile
import textwrap
import unittest
import unittest.mock as mock
from collections import OrderedDict
from pathlib import Path
from utils.cache import _reset_cache_for_tests
from utils.cache import base as cache_base
from utils.cache import users as cache_users
from utils.cache.carrier import USERS_RENDERED_FACT, merged_users_cache_key
from utils.cache.users import store as cache_users_store
from utils.cache.yaml import dump_yaml_str
from utils.domains.default_primary import default_domain_primary
@@ -426,6 +428,191 @@ class TestGetMergedUsersTokenSource(unittest.TestCase):
self.assertEqual(after["foo"]["tokens"]["web-app-foo"], "minted")
class _Templar:
def __init__(self) -> None:
self.available_variables: dict = {}
def template(self, value, fail_on_undefined=False):
return value
class TestGetMergedUsersCarrierFact(unittest.TestCase):
def setUp(self) -> None:
_reset_cache_for_tests()
@staticmethod
def _variables(host: str = "h1") -> dict:
return {
"inventory_hostname": host,
"users": {"foo": {"email": "foo@example.org"}},
cache_users_store.STORE_FACT: {
"foo": {"tokens": {"web-app-foo": "minted"}}
},
}
@staticmethod
def _park(roles: Path, variables: dict, payload: dict) -> dict:
key = merged_users_cache_key(variables, roles_dir=roles)
variables[USERS_RENDERED_FACT] = {
"key": [key[0], list(key[1])],
"users": payload,
}
return payload
@staticmethod
def _merged(roles: Path, variables: dict, templar=None) -> tuple:
with mock.patch.object(
cache_users,
"_render_with_templar",
side_effect=lambda value, **_: {"rendered": value},
) as render:
merged = cache_users.get_merged_users(
variables=variables,
roles_dir=roles,
templar=_Templar() if templar is None else templar,
)
return merged, render.call_count
def test_matching_key_serves_the_carried_payload_without_rendering(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_user_role(Path(tmp))
variables = self._variables()
carried = self._park(roles, variables, {"foo": {"carried": True}})
merged, renders = self._merged(roles, variables)
self.assertIs(merged, carried)
self.assertEqual(renders, 0)
def test_the_carried_payload_answers_the_rest_of_the_process(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_user_role(Path(tmp))
variables = self._variables()
carried = self._park(roles, variables, {"foo": {"carried": True}})
self._merged(roles, variables)
del variables[USERS_RENDERED_FACT]
merged, renders = self._merged(roles, variables)
self.assertIs(merged, carried)
self.assertEqual(renders, 0)
def test_stale_key_falls_back_to_a_fresh_render(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_user_role(Path(tmp))
variables = self._variables()
key = merged_users_cache_key(variables, roles_dir=roles)
variables[USERS_RENDERED_FACT] = {
"key": [key[0], ["stale"] * len(key[1])],
"users": {"foo": {"carried": True}},
}
merged, renders = self._merged(roles, variables)
self.assertEqual(renders, 1)
self.assertEqual(merged["rendered"]["foo"]["email"], "foo@example.org")
def test_another_hosts_carrier_is_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_user_role(Path(tmp))
variables = self._variables("h1")
self._park(roles, variables, {"foo": {"carried": True}})
variables["inventory_hostname"] = "h2"
merged, renders = self._merged(roles, variables)
self.assertEqual(renders, 1)
self.assertNotIn("carried", merged["rendered"]["foo"])
def test_a_rotated_store_token_rejects_the_carrier(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_user_role(Path(tmp))
variables = self._variables()
self._park(roles, variables, {"foo": {"carried": True}})
variables[cache_users_store.STORE_FACT] = {
"foo": {"tokens": {"web-app-foo": "rotated"}}
}
merged, renders = self._merged(roles, variables)
self.assertEqual(renders, 1)
self.assertEqual(
merged["rendered"]["foo"]["tokens"]["web-app-foo"], "rotated"
)
def _park_from_the_tokens_file(self, tmp: Path, token: str) -> tuple:
roles = _seed_minimal_user_role(tmp)
tokens = tmp / "tokens.yml"
tokens.write_text(
f"users: {{foo: {{tokens: {{web-app-foo: {token}}}}}}}\n",
encoding="utf-8",
)
variables = self._variables()
del variables[cache_users_store.STORE_FACT]
variables["FILE_TOKENS"] = str(tokens)
carried = self._park(roles, variables, {"foo": {"carried": True}})
return roles, variables, tokens, carried
def test_a_changed_tokens_file_rejects_the_carrier(self):
with tempfile.TemporaryDirectory() as tmp:
roles, variables, tokens, _ = self._park_from_the_tokens_file(
Path(tmp), "minted"
)
tokens.write_text(
"users: {foo: {tokens: {web-app-foo: rotated-token}}}\n",
encoding="utf-8",
)
merged, renders = self._merged(roles, variables)
self.assertEqual(renders, 1)
self.assertEqual(
merged["rendered"]["foo"]["tokens"]["web-app-foo"], "rotated-token"
)
def test_the_store_fact_with_the_file_tokens_keeps_the_carrier(self):
with tempfile.TemporaryDirectory() as tmp:
roles, variables, _, carried = self._park_from_the_tokens_file(
Path(tmp), "minted"
)
variables[cache_users_store.STORE_FACT] = {
"foo": {"tokens": {"web-app-foo": "minted"}}
}
merged, renders = self._merged(roles, variables)
self.assertIs(merged, carried)
self.assertEqual(renders, 0)
def test_a_rotated_inventory_password_rejects_the_carrier(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_user_role(Path(tmp))
variables = self._variables()
variables["users"]["foo"]["password"] = "before"
self._park(roles, variables, {"foo": {"carried": True}})
variables["users"] = {"foo": {"password": "after"}}
merged, renders = self._merged(roles, variables)
self.assertEqual(renders, 1)
self.assertEqual(merged["rendered"]["foo"]["password"], "after")
def test_without_a_templar_the_carrier_is_ignored(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_user_role(Path(tmp))
variables = self._variables()
self._park(roles, variables, {"foo": {"carried": True}})
merged = cache_users.get_merged_users(
variables=variables, roles_dir=roles, templar=None
)
self.assertNotIn("carried", merged["foo"])
self.assertEqual(merged["foo"]["email"], "foo@example.org")
def test_malformed_carrier_is_ignored(self):
with tempfile.TemporaryDirectory() as tmp:
roles = _seed_minimal_user_role(Path(tmp))
key = merged_users_cache_key(self._variables(), roles_dir=roles)
for carrier in (
"not-a-mapping",
{"key": "x", "users": {}},
{"key": [key[0], list(key[1])], "users": "not-a-mapping"},
{},
):
with self.subTest(carrier=carrier):
_reset_cache_for_tests()
variables = self._variables()
variables[USERS_RENDERED_FACT] = carrier
merged, renders = self._merged(roles, variables)
self.assertEqual(renders, 1)
self.assertEqual(
merged["rendered"]["foo"]["email"], "foo@example.org"
)
class TestStoreSignature(unittest.TestCase):
def setUp(self) -> None:
_reset_cache_for_tests()