mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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:
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):
|
||||
|
||||
@@ -2,4 +2,7 @@
|
||||
compose:
|
||||
lifecycle: beta
|
||||
required_by:
|
||||
categories: [web]
|
||||
compose:
|
||||
categories: [web]
|
||||
swarm:
|
||||
categories: [web]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -2,4 +2,7 @@
|
||||
svc-compose-ca:
|
||||
lifecycle: beta
|
||||
required_by:
|
||||
categories: [web]
|
||||
compose:
|
||||
categories: [web]
|
||||
swarm:
|
||||
categories: [web]
|
||||
@@ -2,4 +2,7 @@
|
||||
svc-compose:
|
||||
lifecycle: beta
|
||||
required_by:
|
||||
categories: [web]
|
||||
compose:
|
||||
categories: [web]
|
||||
swarm:
|
||||
categories: [web]
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user