fix(deploy): honor variants in include resolution

Inventory include sets ignored the active variant: swarm
derive_includes walked the variant-free base config (dynamic service
flags count as enabled) and the compose CombinedResolver followed
run_after as an include edge. Providers a variant pins to false
(e.g. mobilizon variant 1 sso) were still deployed.

- derive_includes: swap every app listed in INFINITO_APP_VARIANTS to
  its variant config, index 0 included (nextcloud variant 0 disables
  11 partner services)
- CombinedResolver: follow_run_after flag; include resolution passes
  False so run_after stays a pure ordering hint
- drop dead deps.py chain (resolve_deploy_ids_for_*) and unused
  resolve_with_edges; rewiring them would resurrect variant-blind
  run_after-following includes
- inventory.md: document the real include pipeline and
  is_explicit_truth semantics

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.8 committed 2026-07-04 03:21:56 +02:00
1 parent 756146ad06
commit 2f9a54cea7
8 files changed
+235 -116

No files matched your search

@@ -1,17 +1,21 @@
"""Shared helpers for the development deploy CLI.
``DEV_INVENTORY_VARS_FILE`` mirrors ``INFINITO_INVENTORY_VARS_FILE``
from ``default.env``; the pairing is drift-guarded.
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from . import PROJECT_ROOT
from .deps import apps_with_deps, resolve_run_after
from .env import resolve_distro
if TYPE_CHECKING:
from .compose import Compose
# Mirrors INFINITO_INVENTORY_VARS_FILE from default.env; drift-guarded.
DEV_INVENTORY_VARS_FILE: str = (
os.environ.get("INFINITO_INVENTORY_VARS_FILE")
or "inventories/development/default.yml"
@@ -33,22 +37,5 @@ def make_compose() -> Compose:
from .compose import Compose
distro = resolve_distro()
# Surface env-script gap here rather than as a cryptic compose error later.
resolve_container()
return Compose(repo_root=PROJECT_ROOT, distro=distro)
def resolve_deploy_ids_for_app(compose: Compose, app_id: str) -> list[str]:
deps = resolve_run_after(compose, app_id)
return apps_with_deps(app_id, deps_role_names=deps)
def resolve_deploy_ids_for_apps(compose: Compose, app_spec: str) -> list[str]:
"""Resolve deploy ids for one or more space- or comma-separated app ids."""
app_ids = [a.strip() for a in app_spec.replace(",", " ").split() if a.strip()]
result: list[str] = []
for app_id in app_ids:
for dep in resolve_deploy_ids_for_app(compose, app_id):
if dep not in result:
result.append(dep)
return result
@@ -1,63 +0,0 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING
if TYPE_CHECKING:
# Import only for type checking to avoid runtime import cycles.
from .compose import Compose
def resolve_run_after(
compose: Compose,
role_name: str,
*,
services_overrides_container_path: str | None = None,
) -> list[str]:
"""
Calls resolver inside the infinito container:
python -m cli.meta.roles.applications.resolution.combined <role_name>
[--services-overrides <path-inside-container>]
`services_overrides_container_path` is the in-container path to a
JSON file `{role_name: services_map}` whose entries replace each
listed role's `meta/services.yml` for the resolver. Callers
(variant-aware planner) write the file to a path that is shared
with the container before calling.
"""
cmd = [
"python3",
"-m",
"cli.meta.roles.applications.resolution.combined",
role_name,
]
if services_overrides_container_path:
cmd.extend(["--services-overrides", services_overrides_container_path])
r = compose.exec(
cmd, check=False, workdir=os.environ["INFINITO_SRC_DIR"], capture=True
)
if r.returncode != 0:
raise RuntimeError(
f"resolution.combined failed for {role_name} (rc={r.returncode})\n"
f"STDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}"
)
txt = (r.stdout or "").strip()
if not txt:
return []
return [t for t in txt.split() if t.strip()]
def apps_with_deps(app_id: str, deps_role_names: list[str]) -> list[str]:
"""
The resolver returns role folder names. In your current inventory groups,
group names == application_ids (folder names). So we can include them directly.
"""
out: list[str] = []
for d in deps_role_names:
if d != app_id and d not in out:
out.append(d)
if app_id not in out:
out.append(app_id)
return out
@@ -1,11 +1,13 @@
"""Default (non-variant) include resolution for the matrix planner.
When NO primary app ships a `meta/variants.yml`, the planner falls back
to this path — the `CombinedResolver` walks both `run_after` and
to this path — the `CombinedResolver` walks `dependencies` and
`shared:`-edges in the variant-merged services map and Jinja flags are
evaluated at deploy time against `group_names`. This module owns the
two helpers that path needs and nothing else; the variant-only path
lives in `.variants`.
evaluated at deploy time against `group_names`. `run_after` is NOT
followed for inclusion (it is a pure ordering hint), so a variant that
disables a service does not get the provider re-added through the
ordering edge. This module owns the two helpers that path needs and
nothing else; the variant-only path lives in `.variants`.
"""
from __future__ import annotations
@@ -82,14 +84,18 @@ def _resolve_round_include(
round's variant-merged services map. Returns the full include set
in stable order (deps first per primary, primary last; primaries
iterated in the user-provided order).
The resolver import is deferred so the host-side import surface
stays lean for callers that never need the variant-aware planner
(e.g. lint/validation).
"""
# Late import keeps the host-side import surface lean for callers
# that never need the variant-aware planner (e.g. lint/validation).
from cli.meta.roles.applications.resolution.combined.resolver import (
CombinedResolver,
)
resolver = CombinedResolver(services_overrides=services_overrides)
resolver = CombinedResolver(
services_overrides=services_overrides, follow_run_after=False
)
out: list[str] = []
seen: set[str] = set()
for app_id in primary_apps:
@@ -23,7 +23,10 @@ class CombinedResolver:
prerequisites(role) = run_after(role) + dependencies(role) + services(role)
Notes:
- run_after edges are always followed
- run_after edges are followed only when `follow_run_after` is True.
Inclusion callers pass False so run_after stays a pure ordering hint
(its documented contract) and a variant that disables a service does
not get its provider re-added through the ordering edge.
- dependency edges are followed only for application roles (filtered in loader)
- services edges are derived from app config flags (filtered in loader)
- Cycles do NOT raise; traversal stops expanding the cyclic edge
@@ -40,9 +43,12 @@ class CombinedResolver:
def __init__(
self,
services_overrides: dict[str, dict] | None = None,
*,
follow_run_after: bool = True,
) -> None:
self._cache: dict[str, RoleEdges] = {}
self._services_overrides: dict[str, dict] = dict(services_overrides or {})
self._follow_run_after = follow_run_after
def edges_for(self, role_name: str) -> RoleEdges:
if role_name in self._cache:
@@ -57,7 +63,6 @@ class CombinedResolver:
services_override=self._services_overrides.get(role_name),
)
# Validate referenced roles exist for run_after (deps/services validate internally too)
for r in ra:
require_role_exists(r)
@@ -80,7 +85,6 @@ class CombinedResolver:
def dfs(node: str) -> None:
if node in stack:
# cycle edge -> stop expansion, do not raise
return
if node in visited:
return
@@ -90,9 +94,9 @@ class CombinedResolver:
edges = self.edges_for(node)
# Keep stable traversal order
for dep in edges.run_after:
dfs(dep)
if self._follow_run_after:
for dep in edges.run_after:
dfs(dep)
for dep in edges.dependencies:
dfs(dep)
for dep in edges.services:
@@ -105,9 +109,3 @@ class CombinedResolver:
dfs(start_role)
return out
def resolve_with_edges(
self, start_role: str
) -> tuple[list[str], dict[str, RoleEdges]]:
resolved = self.resolve(start_role)
return resolved, dict(self._cache)
+4 -5
View File
@@ -18,15 +18,14 @@ After stage 3 the inventory is fully variant-resolved on disk. Ansible loads it
## What decides which roles enter the inventory 🎯
The include set passed to stage 2 is built by the deploy CLI in [deploy.py](../../../cli/administration/deploy/development/deploy.py) via `resolve_deploy_ids_for_apps`. It expands the operator's `--apps` selection along three transitive paths, all walked together:
The include set passed to stage 2 is built per round by the matrix planner: [deploy.py](../../../cli/administration/deploy/development/deploy.py) and [init.py](../../../cli/administration/deploy/development/init.py) call `plan_dev_inventory_matrix` in [planner.py](../../../cli/administration/deploy/development/inventory/planner.py), which resolves each round's include via `_resolve_round_include` in [legacy_resolver.py](../../../cli/administration/deploy/development/inventory/legacy_resolver.py) using the [CombinedResolver](../../../cli/meta/roles/applications/resolution/combined/resolver.py) with `follow_run_after=False`. The swarm test path computes its include separately via [derive_includes.py](../../../utils/tests/swarm/derive_includes.py), reading the active variant map from `INFINITO_APP_VARIANTS`. Both expand the selection along two transitive paths:
1. **Ansible meta `dependencies:`** from each role's `meta/main.yml`.
2. **Per-role `run_after:`** from each role's `meta/services.yml.<entity>.run_after`.
3. **Shared-service auto-include** from each role's `meta/services.yml.<entity>.<service>` block, when both `enabled is True` AND `shared is True`. The check lives in [service_registry.py](../../../utils/roles/applications/services/registry.py) `resolve_service_dependency_roles_from_config`.
2. **Shared-service auto-include** from each role's `meta/services.yml.<entity>.<service>` block, when both `enabled` AND `shared` pass `is_explicit_truth` in [registry.py](../../../utils/roles/applications/services/registry.py): the literal `True` or the dynamic form `"{{ '<role>' in group_names }}"`. A literal `false` (as baked by a variant override) does NOT qualify.
The auto-include uses **strict identity** with Python's `True`. A Jinja-templated string such as `"{{ 'web-app-discourse' in group_names }}"` is NOT the value `True`, so it does NOT trigger auto-include at build time. Only literal booleans (or boolean-typed variant overrides that bake one in) qualify.
`run_after:` from `meta/services.yml.<entity>.run_after` is an ordering hint only (see [base.md](role/services/base.md)); it MUST NOT pull roles into the include set.
This is the mechanism that lets variants control whether a shared service is pulled in: a variant override `services.<service>.enabled: true` pins a real boolean; the build-time auto-include picks it up; the dependent role's group materialises in the inventory.
This is the mechanism that lets variants control whether a shared service is pulled in: a variant override pins `services.<service>.{enabled,shared}` to a literal boolean; the round's include resolution reads the variant-merged services map; the provider role's group materialises in (or drops out of) the inventory accordingly.
## What lives in the inventory file vs. host_vars 📋
@@ -9,6 +9,7 @@ from pathlib import Path
from unittest.mock import patch
from cli.meta.roles.applications.resolution.combined import repo_paths
from cli.meta.roles.applications.resolution.combined.resolver import CombinedResolver
from cli.meta.roles.applications.resolution.combined.tree import print_tree
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_VARS_MAIN
@@ -32,10 +33,6 @@ class TestCombinedTree(unittest.TestCase):
root = Path(td)
(root / "roles").mkdir()
# start app role with dashboard enabled.
# Per run_after lives at
# meta/services.yml.<primary_entity>.run_after.
# For "start" (no category prefix) the entity name = role name.
_write(
root / "roles" / "start" / ROLE_FILE_VARS_MAIN,
"application_id: start\n",
@@ -52,14 +49,11 @@ class TestCombinedTree(unittest.TestCase):
),
)
# keycloak exists, and points back to start to force a visible cycle
# entity name for web-app-keycloak is "keycloak"
_write(
root / "roles" / "web-app-keycloak" / ROLE_FILE_META_SERVICES,
"keycloak:\n run_after:\n - start\n",
)
# required folders exist
(root / "roles" / "start").mkdir(parents=True, exist_ok=True)
(root / "roles" / "web-app-keycloak").mkdir(parents=True, exist_ok=True)
(root / "roles" / "web-app-dashboard").mkdir(parents=True, exist_ok=True)
@@ -75,5 +69,34 @@ class TestCombinedTree(unittest.TestCase):
self.assertIn("↩︎ (cycle)", out)
class TestFollowRunAfter(unittest.TestCase):
def test_run_after_only_role_gated_by_flag(self) -> None:
"""A role reachable ONLY via run_after (no dependency/service edge)
is included when run_after is followed and excluded when it is not."""
with tempfile.TemporaryDirectory() as td:
root = Path(td)
(root / "roles").mkdir()
_write(
root / "roles" / "start" / ROLE_FILE_VARS_MAIN,
"application_id: start\n",
)
_write(
root / "roles" / "start" / ROLE_FILE_META_SERVICES,
"start:\n run_after:\n - web-app-extra\n",
)
(root / "roles" / "web-app-extra").mkdir(parents=True, exist_ok=True)
_write(
root / "roles" / "web-app-extra" / ROLE_FILE_META_SERVICES,
"extra: {}\n",
)
with patch.object(repo_paths, "PROJECT_ROOT", root):
followed = CombinedResolver(follow_run_after=True).resolve("start")
gated = CombinedResolver(follow_run_after=False).resolve("start")
self.assertIn("web-app-extra", followed)
self.assertNotIn("web-app-extra", gated)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,110 @@
from __future__ import annotations
import unittest
import unittest.mock
from utils.tests.swarm import derive_includes as mod
class TestActiveVariantMap(unittest.TestCase):
def _with_env(self, value):
return unittest.mock.patch.dict(
mod.os.environ, {"INFINITO_APP_VARIANTS": value}, clear=False
)
def test_absent_env_returns_empty(self):
with unittest.mock.patch.dict(mod.os.environ, {}, clear=True):
self.assertEqual(mod._active_variant_map(), {})
def test_full_map_read(self):
with self._with_env('{"web-app-x": 1, "web-app-y": 0}'):
self.assertEqual(
mod._active_variant_map(), {"web-app-x": 1, "web-app-y": 0}
)
def test_malformed_json_returns_empty(self):
with self._with_env("not-json"):
self.assertEqual(mod._active_variant_map(), {})
def test_non_dict_returns_empty(self):
with self._with_env("[1, 2]"):
self.assertEqual(mod._active_variant_map(), {})
def test_non_int_and_bool_indices_dropped(self):
with self._with_env('{"web-app-x": "1", "web-app-y": true, "web-app-z": 2}'):
self.assertEqual(mod._active_variant_map(), {"web-app-z": 2})
class TestApplicationsForActiveVariants(unittest.TestCase):
def setUp(self):
self.base = {
"web-app-x": {"services": {"sso": {"enabled": "{{ 'k' in group_names }}"}}},
"web-app-k": {"services": {}},
}
def _map(self, value):
return unittest.mock.patch.object(
mod, "_active_variant_map", return_value=value
)
def test_empty_map_keeps_base(self):
with self._map({}):
self.assertIs(
mod._applications_for_active_variants(self.base),
self.base,
)
def test_variant_zero_is_swapped(self):
"""Variant 0 MAY disable flags the base's dynamic-Jinja form counts
as enabled (web-app-nextcloud), so index 0 must swap too."""
v0 = {"services": {"sso": {"enabled": False}}}
with (
self._map({"web-app-x": 0}),
unittest.mock.patch.object(
mod, "get_variants", return_value={"web-app-x": [v0]}
),
):
result = mod._applications_for_active_variants(self.base)
self.assertEqual(result["web-app-x"], v0)
def test_all_map_entries_swapped_not_only_primary(self):
vx = {"services": {"sso": {"enabled": False}}}
vk = {"services": {"postgres": {"enabled": True}}}
with (
self._map({"web-app-x": 1, "web-app-k": 1}),
unittest.mock.patch.object(
mod,
"get_variants",
return_value={"web-app-x": [{}, vx], "web-app-k": [{}, vk]},
),
):
result = mod._applications_for_active_variants(self.base)
self.assertEqual(result["web-app-x"], vx)
self.assertEqual(result["web-app-k"], vk)
def test_out_of_range_entry_keeps_base_others_swap(self):
vk = {"services": {"postgres": {"enabled": True}}}
with (
self._map({"web-app-x": 9, "web-app-k": 1}),
unittest.mock.patch.object(
mod,
"get_variants",
return_value={"web-app-x": [{}], "web-app-k": [{}, vk]},
),
):
result = mod._applications_for_active_variants(self.base)
self.assertIs(result["web-app-x"], self.base["web-app-x"])
self.assertEqual(result["web-app-k"], vk)
def test_unknown_app_in_map_ignored(self):
with (
self._map({"web-app-ghost": 1}),
unittest.mock.patch.object(mod, "get_variants", return_value={}),
):
result = mod._applications_for_active_variants(self.base)
self.assertEqual(result, self.base)
self.assertNotIn("web-app-ghost", result)
if __name__ == "__main__":
unittest.main()
+62 -3
View File
@@ -10,17 +10,26 @@ locally-built images, but not declared as a meta-dep so it stays
out of compose-mode deploys) are added explicitly because the
dep-walker only knows roles that are in the ``applications`` dict.
Input (env): ``APP_ID``.
Input (env): ``APP_ID``. Optional ``INFINITO_APP_VARIANTS`` (JSON
``{app_id: variant_index}``, set per round by the matrix orchestrator)
selects the active variant so a variant that pins ``services.*`` flags
to ``false`` prunes those providers from the closure instead of the
provision step pulling them in from the variant-free base config.
"""
from __future__ import annotations
import json
import os
import sys
from typing import Any
from utils import PROJECT_ROOT
from utils.cache.applications import get_merged_applications
from utils.cache.applications import get_merged_applications, get_variants
from utils.roles.applications.in_group_deps import applications_if_group_and_all_deps
from utils.roles.applications.services.registry import (
build_service_registry_from_applications,
)
_ROLES_DIR = PROJECT_ROOT / "roles"
@@ -31,13 +40,63 @@ _EXPLICIT_INCLUDES: tuple[str, ...] = (
)
def _active_variant_map() -> dict[str, int]:
raw = os.environ.get("INFINITO_APP_VARIANTS")
if not raw:
return {}
try:
mapping = json.loads(raw)
except json.JSONDecodeError:
return {}
if not isinstance(mapping, dict):
return {}
return {
app_id: index
for app_id, index in mapping.items()
if isinstance(app_id, str)
and isinstance(index, int)
and not isinstance(index, bool)
}
def _applications_for_active_variants(
base_applications: dict[str, Any],
) -> dict[str, Any]:
"""Swap every app listed in ``INFINITO_APP_VARIANTS`` to its variant
config. Index 0 is swapped too: a variant-0 override MAY disable flags
the base config's dynamic-Jinja form counts as enabled (web-app-nextcloud
pins 11 partner services off in variant 0), so "variant 0 == base" does
not hold. Every entry in the map is swapped, not just the primary app,
because host_vars baking honours the dep roles' indices as well and the
include closure must match that topology."""
overrides = _active_variant_map()
if not overrides:
return base_applications
variants_per_app = get_variants(roles_dir=str(_ROLES_DIR))
applications = dict(base_applications)
for app_id, index in overrides.items():
app_variants = variants_per_app.get(app_id) or []
if 0 <= index < len(app_variants):
applications[app_id] = app_variants[index]
return applications
def derive_includes(app_id: str) -> list[str]:
applications = get_merged_applications(roles_dir=str(_ROLES_DIR))
"""Resolve APP_ID's transitive include set under the active variants.
The service registry (service key -> provider role) stays built from
the full rendered base set so provider discovery is unaffected by the
active variants; only the dep-walk sees the variant configs.
"""
base_applications = get_merged_applications(roles_dir=str(_ROLES_DIR))
service_registry = build_service_registry_from_applications(base_applications)
applications = _applications_for_active_variants(base_applications)
transitive = applications_if_group_and_all_deps(
applications,
[app_id],
project_root=str(PROJECT_ROOT),
roles_dir=str(_ROLES_DIR),
service_registry=service_registry,
)
found = set(transitive) | set(_EXPLICIT_INCLUDES) | {app_id}
return sorted(found)