fix(swarm-test): honour a role's pinned local database

force_shared_db flipped every DB service to shared with no exemption, so
web-app-magento's deliberate embedded MariaDB 11.4 - declared shared:
false with a comment warning that no bot may move the major underneath
it - became dead config and setup:install hit the central engine's 12.3.2,
outside Magento's supported range. The run log names the override:
'swarm force-shared: web-app-magento.services.mariadb -> shared=true'.

Exempts a DB service whose role meta declares a literal shared: false AND
whose role is manager-pinned. The second condition is not decoration:
utils/storage/nfs.py keeps a manager-pinned role's volumes node-local, and
InnoDB over NFS is why the harness force-shares in the first place. Of the
two roles with a literal local DB, magento qualifies and bigbluebutton
(unpinned) does not, so its behaviour is unchanged.

derive_includes._force_shared_db_view documents itself as mirroring
force_shared_db, so it takes the same exemption - without it the dep walk
would compute magento's include closure against the central engine while
the deploy runs a local sidecar.

The sidecar carried no deploy block at all, so an exempted service would
have been free-floating with one task per node over a single-writer
datadir. The block now comes from a container_deploy lookup shared with
sys-svc-container/templates/deploy.yml.j2, which replaces the replicas
ternary and the duplicated placement constraint there. Its unit test
caught a real defect while being written: with compose_mode_force=swarm
over DEPLOYMENT_MODE=compose the delegated compose_replicas returned
empty, emitting a deploy: key with no child.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-12 01:36:18 +02:00
1 parent 9430b25006
commit e41508f48b
9 files changed
+242 -9

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
"""Lookup ``container_deploy``: emit the swarm ``deploy:`` head — the replica
count plus, for a role whose primary entity declares one, the placement
constraint. Renders nothing outside swarm mode, so callers need no mode guard
of their own.
{{ lookup('container_deploy') | indent(4) }} # topology default
{{ lookup('container_deploy', replicas=1) | indent(4) }} # single-writer sidecar
{{ lookup('container_deploy', replicas=n) | indent(4) }} # explicit; '' = default
Callers that also need ``update_config``, ``restart_policy`` or ``resources``
append them under the same key.
"""
from __future__ import annotations
import contextlib
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
from utils.roles.meta_lookup import get_role_placement
class LookupModule(LookupBase):
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[str]:
if terms:
raise AnsibleError(
"container_deploy lookup takes no positional terms; "
"pass the replica count as replicas=<n>"
)
vars_ = variables or getattr(self._templar, "available_variables", {}) or {}
templar = getattr(self, "_templar", None)
mode = vars_.get("compose_mode") or vars_.get("compose_mode_force") or vars_.get(
"DEPLOYMENT_MODE", "compose"
)
if templar is not None:
with contextlib.suppress(Exception):
mode = templar.template(mode)
if str(mode).strip() != "swarm":
return [""]
application_id = vars_.get("application_id")
if templar is not None and application_id is not None:
with contextlib.suppress(Exception):
application_id = templar.template(application_id)
if not application_id:
raise AnsibleError(
"container_deploy lookup: application_id is required in variables"
)
replicas_override = kwargs.get("replicas", "")
override = [replicas_override] if str(replicas_override) != "" else []
replicas = lookup_loader.get(
"compose_replicas", loader=self._loader, templar=templar
).run(override, variables={**vars_, "DEPLOYMENT_MODE": "swarm"})[0]
lines = ["deploy:", f" {replicas}"]
placement = get_role_placement(str(application_id))
if placement:
lines += [" placement:", " constraints:", f" - node.role == {placement}"]
return ["\n".join(lines)]
@@ -1,13 +1,7 @@
{% if (compose_mode_force | default('') or DEPLOYMENT_MODE) == 'swarm' %}
{% if compose_mode == 'swarm' %}
{{ "\n" }}
{% set _replicas = lookup('applications') | resource_filter(application_id, 'replicas', service_name | default(''), '') %}
deploy:
{{ lookup('compose_replicas', _replicas) if _replicas != '' else lookup('compose_replicas') }}
{% if application_id in lookup('roles_with_placement', 'manager') %}
placement:
constraints:
- node.role == manager
{% endif %}
{{ lookup('container_deploy', replicas=_replicas) | indent(4) }}
update_config:
parallelism: {{ service_update_parallelism | default(1) }}
delay: {{ service_update_delay | default('10s') }}
@@ -2,6 +2,7 @@
{% if lookup('database', application_id, 'local') %}
{{ lookup('database', application_id, 'host') }}:
{{ lookup('container_deploy', replicas=1) | indent(4) }}
{{ lookup('compose_only', 'container_name', ((application_id | get_entity_name) ~ '-database')) }}
logging:
driver: journald
@@ -4,6 +4,7 @@
{% set _extensions = lookup('service_extensions', 'postgres').get(application_id, []) %}
{% set _libraries = lookup('postgres_libraries', _extensions) %}
{{ lookup('database', application_id, 'host') }}:
{{ lookup('container_deploy', replicas=1) | indent(4) }}
{{ lookup('compose_only', 'container_name', ((application_id | get_entity_name) ~ '-database')) }}
{% if _libraries | length > 0 %}
{{ lookup('container_image', 'svc-db-postgres', 'postgres', image=(application_id | get_entity_name ~ '-database'), version=lookup('database', application_id, 'version')) }}
@@ -0,0 +1,112 @@
"""Unit tests for the container_deploy lookup plugin.
Pins the swarm ``deploy:`` head contract:
* non-swarm mode emits empty string, so callers need no mode guard.
* ``compose_mode_force`` overrides ``DEPLOYMENT_MODE``.
* the replica count is delegated to the compose_replicas SPOT: an explicit
``replicas=`` wins, an empty one falls back to the topology default.
* a role whose primary entity declares a placement gains the constraint
block; one that declares none gains nothing.
* children are indented two spaces under ``deploy:`` so the caller's
``| indent(N)`` lands the whole block at N.
"""
from __future__ import annotations
import importlib.util
import unittest
from ansible.errors import AnsibleError
from . import PROJECT_ROOT
SWARM = {
"DEPLOYMENT_MODE": "swarm",
"application_id": "web-app-x",
"groups": {"web-app-x": ["mgr-01", "wrk-01", "wrk-02"]},
}
def _load_lookup():
spec = importlib.util.spec_from_file_location(
"lookup_container_deploy",
str(PROJECT_ROOT / "plugins/lookup/container_deploy.py"),
)
mod = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(mod)
return mod
class _DummyTemplar:
def __init__(self, available_variables=None):
self.available_variables = available_variables or {}
def template(self, value):
return value
class TestContainerDeployLookup(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mod = _load_lookup()
def setUp(self):
self._real_placement = self.mod.get_role_placement
self.placement = None
self.mod.get_role_placement = lambda _role: self.placement
def tearDown(self):
self.mod.get_role_placement = self._real_placement
def _run(self, variables, **kwargs):
lm = self.mod.LookupModule()
lm._templar = _DummyTemplar(variables)
lm._loader = None
return lm.run([], variables=variables, **kwargs)[0]
def test_compose_mode_emits_nothing(self):
self.assertEqual(self._run({**SWARM, "DEPLOYMENT_MODE": "compose"}), "")
def test_missing_deployment_mode_emits_nothing(self):
self.assertEqual(self._run({"application_id": "web-app-x"}), "")
def test_compose_mode_force_overrides_deployment_mode(self):
vars_ = {**SWARM, "DEPLOYMENT_MODE": "compose", "compose_mode_force": "swarm"}
self.assertEqual(self._run(vars_), "deploy:\n replicas: 3")
def test_topology_default_without_an_override(self):
self.assertEqual(self._run(SWARM), "deploy:\n replicas: 3")
def test_empty_override_falls_back_to_the_topology_default(self):
self.assertEqual(self._run(SWARM, replicas=""), "deploy:\n replicas: 3")
def test_explicit_override_wins(self):
self.assertEqual(self._run(SWARM, replicas=1), "deploy:\n replicas: 1")
def test_placement_is_appended_when_the_role_declares_one(self):
self.placement = "manager"
self.assertEqual(
self._run(SWARM, replicas=1),
"deploy:\n"
" replicas: 1\n"
" placement:\n"
" constraints:\n"
" - node.role == manager",
)
def test_a_positional_term_is_rejected(self):
lm = self.mod.LookupModule()
lm._templar = _DummyTemplar(SWARM)
lm._loader = None
with self.assertRaises(AnsibleError):
lm.run([1], variables=SWARM)
def test_missing_application_id_is_rejected(self):
with self.assertRaises(AnsibleError):
self._run({"DEPLOYMENT_MODE": "swarm"})
if __name__ == "__main__":
unittest.main()
@@ -55,6 +55,14 @@ class TestForceSharedDbView(unittest.TestCase):
self.assertTrue(result["web-app-x"]["services"]["postgres"]["shared"])
self.assertFalse(result["web-app-x"]["services"]["sso"]["shared"])
def test_a_pinned_local_db_is_left_unshared(self):
apps = {"web-app-x": {"services": {"postgres": {"enabled": True, "shared": False}}}}
with self._keys({"postgres"}), unittest.mock.patch.object(
mod, "pinned_local_db", return_value=True
):
result = mod._force_shared_db_view(apps)
self.assertFalse(result["web-app-x"]["services"]["postgres"]["shared"])
def test_missing_entries_are_not_created(self):
apps = {"web-app-x": {"services": {"sso": {"enabled": True}}}}
with self._keys({"mariadb"}):
@@ -82,6 +82,20 @@ class TestForceSharedTrue(unittest.TestCase):
self.assertTrue(svc["mariadb"]["shared"])
self.assertNotIn("shared", svc["matomo"])
def test_keeps_manager_pinned_role_local_db(self):
self._write(
{
"applications": {
"web-app-magento": {
"services": {"mariadb": {"enabled": True, "shared": False}}
}
}
}
)
self.assertFalse(force_shared_true(self.host_vars, {"mariadb"}))
svc = self._read()["applications"]["web-app-magento"]["services"]
self.assertFalse(svc["mariadb"]["shared"])
def test_no_change_when_already_shared(self):
self._write(
{"applications": {"a": {"services": {"mariadb": {"shared": True}}}}}
+2 -1
View File
@@ -42,7 +42,7 @@ from utils.roles.applications.in_group_deps import applications_if_group_and_all
from utils.roles.applications.services.registry import (
build_service_registry_from_applications,
)
from utils.tests.swarm.force_shared_db import db_provider_service_keys
from utils.tests.swarm.force_shared_db import db_provider_service_keys, pinned_local_db
_ROLES_DIR = PROJECT_ROOT / "roles"
@@ -130,6 +130,7 @@ def _force_shared_db_view(applications: dict[str, Any]) -> dict[str, Any]:
and isinstance(entry, dict)
and entry.get("enabled") is not False
and entry.get("shared") is not True
and not pinned_local_db(app_id, key)
}
if not flipped:
continue
+32
View File
@@ -10,6 +10,11 @@ compose-only pattern. Flipping every consumer's DB service to ``shared: true``
routes it to the central svc-db-* provider (as the baseline variant does), so
no per-app DB is scheduled in swarm.
Exception: a manager-pinned role keeps its volumes node-local
(``swarm_nfs_backed``) and its DB sidecar carries the matching placement
constraint, so a literal ``shared: false`` there is honoured instead of
overridden — Magento hard-fails ``setup:install`` on the central MariaDB major.
Inputs (env): ``INV_DIR`` — inventory dir holding ``host_vars/*.yml``
(default ``/tmp/inv``).
"""
@@ -25,6 +30,7 @@ from ruamel.yaml.comments import CommentedMap
from utils import PROJECT_ROOT
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from utils.roles.meta_lookup import get_role_placement
_ROLES_DIR = PROJECT_ROOT / "roles"
@@ -47,6 +53,31 @@ def db_provider_service_keys(roles_dir: Path) -> set[str]:
return keys
def pinned_local_db(app_id: str, svc_name: str) -> bool:
"""True when the role owns this DB on purpose and swarm can host it.
A literal ``shared: false`` (not a Jinja expression) is the role author
pinning an engine the central provider cannot serve. Honouring it is only
safe on a manager-pinned role, where the volume stays node-local and the
sidecar carries the matching placement constraint.
Args:
app_id: consumer role name, e.g. ``web-app-magento``.
svc_name: DB service key under the role's ``meta/services.yml``.
"""
services_file = _ROLES_DIR / app_id / ROLE_FILE_META_SERVICES
if not services_file.exists():
return False
services = load_yaml_any(services_file)
if not isinstance(services, dict):
return False
entry = services.get(svc_name)
if not isinstance(entry, dict) or entry.get("shared") is not False:
return False
placement = get_role_placement(_ROLES_DIR / app_id, role_name=app_id)
return str(placement or "").strip() == "manager"
def force_shared_true(host_vars_file: Path, db_keys: set[str]) -> bool:
"""Set ``shared: true`` on any DB-provider service already present under
``applications.<app>.services.<key>`` in host_vars_file. Only existing
@@ -78,6 +109,7 @@ def force_shared_true(host_vars_file: Path, db_keys: set[str]) -> bool:
and isinstance(svc, CommentedMap)
and svc.get("enabled") is not False
and svc.get("shared") is not True
and not pinned_local_db(str(app_id), str(svc_name))
):
svc["shared"] = True
changed = True