mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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:
1 parent
9430b25006
commit
e41508f48b
9 files changed
+242
-9
No files matched your search
@@ -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}}}}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user