feat(deploy-verify): scope required_by coverage to deploy modes (compose/swarm)

The required_by coverage verifier treated every flat 'required_by: {categories/roles}' as required in BOTH deploy modes. But sys-ctl-rpr-container-hard/soft repair compose compositions (DIR_COMPOSITIONS) and gate their body to compose; declaring required_by:[web] flat made the verifier expect them on swarm too, where they correctly skip, so web-opt-rdr-domains swarm CI failed 'required role(s) did not execute'. Let required_by nest under 'compose:'/'swarm:'; the verifier infers the deploy mode from the role set, mirroring DEPLOYMENT_MODE's own groups['svc-swarm-node'] definition, and enforces only the matching mode.

Migrate all 21 existing flat declarations to mirror under both modes (behavior unchanged) and mark sys-ctl-rpr-container-* compose-only. role_has_required_by reads both forms. New lint test_required_by_mode_keys requires the explicit compose:/swarm: form so the flat-vs-gate contradiction can't recur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.8 committed 2026-06-25 14:59:57 +02:00
1 parent 4a7009b21e
commit 48753de136
27 files changed
+246 -25

No files matched your search

@@ -8,6 +8,14 @@ A role's `meta/services.yml` MAY contain (per-service-entity):
categories: [web, web.app, ...] # category handles (cf. roles/categories.yml)
roles: [web-app-yourls, ...] # full role names incl. category prefix
A `required_by` block MAY instead nest `compose:` / `swarm:` sub-blocks to
scope the requirement to one deploy mode (a flat block applies in every mode);
the deploy mode is inferred from the role set (swarm pulls in svc-swarm-node):
<entity>:
required_by:
compose: { categories: [web] } # required only in compose deploys
Semantics: when the current deploy contains any role whose id matches a
listed `roles` entry, OR whose category set (top-level + first sub-level)
intersects the listed `categories`, the role owning the services.yml entry
@@ -67,6 +75,15 @@ def categories_of(role_id: str) -> set[str]:
return out
_SWARM_MARKER_ROLES = frozenset({"svc-swarm-node", "svc-swarm-manager"})
def _deploy_mode(deployed_role_ids: list[str]) -> str:
# No DEPLOYMENT_MODE in scope post-deploy: infer it from the role set, since a
# swarm deploy always pulls in the swarm-node/manager infra and compose never does.
return "swarm" if _SWARM_MARKER_ROLES.intersection(deployed_role_ids) else "compose"
def required_role_ids(
*,
roles_dir: Path,
@@ -77,6 +94,7 @@ def required_role_ids(
for d in deployed_role_ids:
deployed_categories.update(categories_of(d))
deployed_set = set(deployed_role_ids)
deploy_mode = _deploy_mode(deployed_role_ids)
required: set[str] = set()
if not roles_dir.is_dir():
@@ -102,6 +120,12 @@ def required_role_ids(
rb = entry.get("required_by")
if not isinstance(rb, dict):
continue
# required_by may scope to a deploy mode via 'compose'/'swarm' sub-keys;
# a flat {categories, roles} applies in every mode.
if "compose" in rb or "swarm" in rb:
rb = rb.get(deploy_mode) or {}
if not isinstance(rb, dict):
continue
rb_categories = set(rb.get("categories") or [])
rb_roles = set(rb.get("roles") or [])
if (rb_categories & deployed_categories) or (rb_roles & deployed_set):
+4 -1
View File
@@ -2,4 +2,7 @@
compose:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
+4 -1
View File
@@ -2,4 +2,7 @@
email:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
@@ -2,4 +2,7 @@
anon-volumes:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
+4 -1
View File
@@ -2,4 +2,7 @@
bkps:
lifecycle: beta
required_by:
categories: [svc-bkp]
compose:
categories: [svc-bkp]
swarm:
categories: [svc-bkp]
@@ -2,4 +2,7 @@
disc-space:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
+4 -1
View File
@@ -2,4 +2,7 @@
docker:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
@@ -2,4 +2,7 @@
faild-bkps:
lifecycle: beta
required_by:
categories: [svc-bkp]
compose:
categories: [svc-bkp]
swarm:
categories: [svc-bkp]
@@ -2,4 +2,7 @@
container:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
+4 -1
View File
@@ -2,7 +2,10 @@
csp:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
csp-checker:
image: ghcr.io/kevinveenbirkenbach/csp-checker
version: 2.1.2
@@ -2,4 +2,7 @@
disc-space:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
@@ -2,4 +2,7 @@
journalctl:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
+4 -1
View File
@@ -2,4 +2,7 @@
volumes:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
@@ -2,4 +2,7 @@
webserver:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
@@ -2,4 +2,7 @@
cert-deploy:
lifecycle: beta
required_by:
roles: [web-app-mailu]
compose:
roles: [web-app-mailu]
swarm:
roles: [web-app-mailu]
@@ -2,4 +2,5 @@
container-hard:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
@@ -2,4 +2,5 @@
container-soft:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
+4 -1
View File
@@ -2,4 +2,7 @@
all:
lifecycle: beta
required_by:
categories: [web-app, web-svc]
compose:
categories: [web-app, web-svc]
swarm:
categories: [web-app, web-svc]
+4 -1
View File
@@ -2,4 +2,7 @@
front-proxy:
lifecycle: beta
required_by:
categories: [web-app, web-svc]
compose:
categories: [web-app, web-svc]
swarm:
categories: [web-app, web-svc]
+4 -1
View File
@@ -2,4 +2,7 @@
svc-compose-ca:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
+4 -1
View File
@@ -2,4 +2,7 @@
svc-compose:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
+4 -1
View File
@@ -2,7 +2,10 @@
svc-container:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
busybox:
image: busybox
version: "1.38"
@@ -2,4 +2,7 @@
svc-webserver-core:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
@@ -2,4 +2,7 @@
svc-webserver-https:
lifecycle: beta
required_by:
categories: [web]
compose:
categories: [web]
swarm:
categories: [web]
@@ -0,0 +1,76 @@
"""Hard-fail when a `required_by` block in meta/services.yml uses the flat
`{categories, roles}` form instead of scoping to deploy modes.
Every `required_by` MUST nest its spec under `compose:` and/or `swarm:` so the
deploy-coverage verifier (cli.meta.roles.services.called) can enforce the
requirement per deploy mode. A flat block is ambiguous across modes and is
rejected; each present mode block must carry `categories` and/or `roles`.
Mirror to both modes to preserve the previous (mode-agnostic) behavior; declare
a single mode only when the role genuinely applies to that mode (e.g.
sys-ctl-rpr-container-* repair compose compositions and are compose-only).
"""
from __future__ import annotations
import unittest
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from . import PROJECT_ROOT
ROLES_DIR = PROJECT_ROOT / "roles"
_MODE_KEYS = {"compose", "swarm"}
_SPEC_KEYS = {"categories", "roles"}
class TestRequiredByModeKeys(unittest.TestCase):
def test_required_by_scopes_to_deploy_modes(self) -> None:
offenders: list[str] = []
for role_dir in sorted(p for p in ROLES_DIR.iterdir() if p.is_dir()):
services_yml = role_dir / ROLE_FILE_META_SERVICES
if not services_yml.is_file():
continue
data = load_yaml_any(str(services_yml), default_if_missing={})
if not isinstance(data, dict):
continue
rel = services_yml.relative_to(PROJECT_ROOT).as_posix()
for entity, spec in data.items():
if not isinstance(spec, dict):
continue
rb = spec.get("required_by")
if not isinstance(rb, dict):
continue
keys = set(rb.keys())
if not keys or not keys <= _MODE_KEYS:
offenders.append(
f"{rel} [{entity}]: required_by keys {sorted(keys)} "
"(expected only 'compose' and/or 'swarm')"
)
continue
for mode in sorted(keys):
sub = rb[mode]
if not isinstance(sub, dict) or not (set(sub) & _SPEC_KEYS):
offenders.append(
f"{rel} [{entity}].required_by.{mode}: expected a "
"mapping with 'categories' and/or 'roles'"
)
if not offenders:
return
body = "\n".join(f" - {o}" for o in offenders)
self.fail(
f"\n{len(offenders)} required_by block(s) not scoped to deploy "
f"modes:\n{body}\n\n"
"Fix: nest the spec under `compose:` and/or `swarm:`, e.g.\n"
" required_by:\n"
" compose: {categories: [web]}\n"
" swarm: {categories: [web]}\n"
"Mirror to both modes to keep the previous behavior; use a single "
"mode only when the role genuinely applies to that mode."
)
if __name__ == "__main__":
unittest.main()
@@ -162,6 +162,56 @@ class TestRequiredRoleIds(unittest.TestCase):
)
self.assertEqual(result, {"sys-x"})
def test_mode_scoped_required_by_compose_only(self) -> None:
_write_services_yml(
self.roles_dir / "sys-ctl-rpr-container-hard",
"""
---
container-hard:
required_by:
compose:
categories: [web]
""",
)
self.assertEqual(
required_role_ids(
roles_dir=self.roles_dir, deployed_role_ids=["web-app-yourls"]
),
{"sys-ctl-rpr-container-hard"},
)
self.assertEqual(
required_role_ids(
roles_dir=self.roles_dir,
deployed_role_ids=["web-app-yourls", "svc-swarm-node"],
),
set(),
)
def test_mode_scoped_required_by_swarm_only(self) -> None:
_write_services_yml(
self.roles_dir / "sys-x",
"""
---
x:
required_by:
swarm:
categories: [web]
""",
)
self.assertEqual(
required_role_ids(
roles_dir=self.roles_dir,
deployed_role_ids=["web-app-yourls", "svc-swarm-manager"],
),
{"sys-x"},
)
self.assertEqual(
required_role_ids(
roles_dir=self.roles_dir, deployed_role_ids=["web-app-yourls"]
),
set(),
)
def test_no_category_match(self) -> None:
_write_services_yml(
self.roles_dir / "sys-x",
+8 -2
View File
@@ -39,7 +39,8 @@ def role_is_invokable(role_id: str, roles_dir: str | Path | None = None) -> bool
def role_has_required_by(role_id: str, roles_dir: str | Path | None = None) -> bool:
"""True if any entity in `roles/<role_id>/meta/services.yml` declares
`required_by.categories` or `required_by.roles` (non-empty)."""
non-empty `required_by` categories/roles, either flat or nested under a
`compose:`/`swarm:` mode block."""
if not role_id:
return False
base = _default_roles_dir(roles_dir)
@@ -55,7 +56,12 @@ def role_has_required_by(role_id: str, roles_dir: str | Path | None = None) -> b
rb = entry.get("required_by")
if not isinstance(rb, dict):
continue
if rb.get("categories") or rb.get("roles"):
blocks = (
[rb[m] for m in ("compose", "swarm") if isinstance(rb.get(m), dict)]
if ("compose" in rb or "swarm" in rb)
else [rb]
)
if any(b.get("categories") or b.get("roles") for b in blocks):
return True
return False