mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
fix(inventory): bake only variants.yml overrides into matrix-deploy --vars
Since 576fad84f (2026-05-10) moved role images from
`defaults/main.yml` into `meta/services.yml`, matrix-deploy's variant
baker has been writing the FULL effective config (services.yml +
variants.yml deep-merged via `get_variants`) into host_vars through
the `--vars` JSON consumed by `apply_vars_overrides`. That populates
`applications.{role}.services.{svc}.image` (and `.version`, .cpus,
etc.) with the upstream value, which then silently disables
`apply_mirror_overrides` for every role with an explicit image
declaration: the default `if_missing` policy short-circuits when the
field is already non-blank, so the rendered compose / Dockerfile FROM
keeps pulling from the upstream registry rather than the GHCR mirror.
The regression went unnoticed because Docker Hub usually serves the
pull, but it eventually surfaced as a BuildKit 429 on
`docker.io/library/php:8.2-apache` during the suitecrm Dockerfile
build (job 77602110531 on run 26362676803). Nextcloud, moodle,
mariadb consumers, postgres consumers, simpleicons, phpmyadmin,
phpldapadmin, jenkins, baserow, fider, ... all share the same latent
defect — only suitecrm happened to hit the rate limit first.
Fix the root cause instead of papering over per-role with
`mirror_policy: force`: introduce `get_variant_overrides_only` next
to `get_variants` and switch payload.py to it. The new function
returns the raw `meta/variants.yml` entries per role — no merge with
`meta/services.yml`. matrix-deploy now bakes only the variant-
specific overrides into host_vars; the role's services.yml fields
flow through the normal Ansible lookup chain at deploy time, leaving
`image`/`version` blank in host_vars so `apply_mirror_overrides`
populates them from `mirrors.yml` under its default `if_missing`
policy.
Behavior preserved for the other `get_variants` consumers:
* utils/manager/inventory.py:104 (schema-driven credentials) keeps
the full merged variant payload.
* planner.py:58 (variant-count only) is unaffected.
* legacy_resolver.py:42 (CombinedResolver runtime services_overrides)
keeps the full merged view.
Coverage:
* 3 new tests in test_applications.py for the new helper (single
empty variant when no meta/variants.yml, raw override without base
merge, cache invariance).
* test_payload.py + test_builder.py mocks repointed from `get_variants`
to `get_variant_overrides_only`.
* `make test` green across all 5 targets (lint, unit, integration,
external, test-lint) — 188 tests total.
Side-effects assessed:
* Repo-wide grep confirms no template / task accesses
`applications.X.services.Y.image` directly via hostvars — every
consumer goes through `lookup('config', …)` which still deep-merges.
* Roles previously pulling upstream now resolve via the GHCR mirror
whenever the image is already synced (the `images-mirror-missing.yml`
workflow keeps the destination populated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ba676cf354
commit
10cb02461e
5 files changed
+104
-24
No files matched your search
@@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from utils.cache.applications import get_variants
|
||||
from utils.cache.applications import get_variant_overrides_only
|
||||
|
||||
|
||||
def _resolve_variant_payloads(
|
||||
@@ -22,16 +22,21 @@ def _resolve_variant_payloads(
|
||||
include: Sequence[str],
|
||||
active_variants: Mapping[str, int],
|
||||
) -> dict[str, Any]:
|
||||
"""Return ``{app_id: variant_payload}`` for the requested round.
|
||||
"""Return ``{app_id: variant_override}`` for the requested round.
|
||||
|
||||
Apps without `meta/variants.yml` collapse to a single empty variant
|
||||
in the loader, so this just picks variant 0 (= `meta/services.yml`
|
||||
unchanged) for them. Out-of-range indices clamp to 0.
|
||||
Each value is the raw `meta/variants.yml` entry — NOT the
|
||||
deep-merge of variants.yml on top of `meta/services.yml`. Baking
|
||||
only the override keeps host_vars sparse so downstream passes
|
||||
(notably `apply_mirror_overrides`) can populate fields like
|
||||
`image`/`version` that the role declares in `meta/services.yml`.
|
||||
|
||||
Apps without `meta/variants.yml` collapse to a single empty
|
||||
override `{}`. Out-of-range indices clamp to 0.
|
||||
"""
|
||||
variants_per_app = get_variants(roles_dir=roles_dir)
|
||||
overrides_per_app = get_variant_overrides_only(roles_dir=roles_dir)
|
||||
resolved: dict[str, Any] = {}
|
||||
for app_id in include:
|
||||
variant_list = variants_per_app.get(app_id) or [{}]
|
||||
variant_list = overrides_per_app.get(app_id) or [{}]
|
||||
if not variant_list:
|
||||
continue
|
||||
index = active_variants.get(app_id, 0)
|
||||
|
||||
@@ -23,7 +23,7 @@ class TestBuildDevInventory(unittest.TestCase):
|
||||
self.compose.repo_root = Path("/tmp/infinito-nexus")
|
||||
|
||||
@patch(
|
||||
"cli.administration.deploy.development.inventory.payload.get_variants",
|
||||
"cli.administration.deploy.development.inventory.payload.get_variant_overrides_only",
|
||||
autospec=True,
|
||||
return_value={
|
||||
"web-app-keycloak": [{}],
|
||||
@@ -58,7 +58,7 @@ class TestBuildDevInventory(unittest.TestCase):
|
||||
self.assertEqual(first_cmd[include_index], "web-app-keycloak,web-app-nextcloud")
|
||||
|
||||
@patch(
|
||||
"cli.administration.deploy.development.inventory.payload.get_variants",
|
||||
"cli.administration.deploy.development.inventory.payload.get_variant_overrides_only",
|
||||
autospec=True,
|
||||
return_value={
|
||||
"web-app-multi": [
|
||||
@@ -112,7 +112,7 @@ class TestBuildDevInventory(unittest.TestCase):
|
||||
return_value="/etc/mirrors.yml",
|
||||
)
|
||||
@patch(
|
||||
"cli.administration.deploy.development.inventory.payload.get_variants",
|
||||
"cli.administration.deploy.development.inventory.payload.get_variant_overrides_only",
|
||||
autospec=True,
|
||||
return_value={"web-app-keycloak": [{}], "web-app-nextcloud": [{}]},
|
||||
)
|
||||
@@ -137,7 +137,7 @@ class TestBuildDevInventory(unittest.TestCase):
|
||||
)
|
||||
|
||||
@patch(
|
||||
"cli.administration.deploy.development.inventory.payload.get_variants",
|
||||
"cli.administration.deploy.development.inventory.payload.get_variant_overrides_only",
|
||||
autospec=True,
|
||||
return_value={"web-app-keycloak": [{}], "web-app-nextcloud": [{}]},
|
||||
)
|
||||
@@ -162,7 +162,7 @@ class TestBuildDevInventory(unittest.TestCase):
|
||||
)
|
||||
|
||||
@patch(
|
||||
"cli.administration.deploy.development.inventory.payload.get_variants",
|
||||
"cli.administration.deploy.development.inventory.payload.get_variant_overrides_only",
|
||||
autospec=True,
|
||||
return_value={"web-app-keycloak": [{}], "web-app-nextcloud": [{}]},
|
||||
)
|
||||
@@ -182,7 +182,7 @@ class TestBuildDevInventory(unittest.TestCase):
|
||||
self.assertIsNone(first_kwargs.get("extra_env"))
|
||||
|
||||
@patch(
|
||||
"cli.administration.deploy.development.inventory.payload.get_variants",
|
||||
"cli.administration.deploy.development.inventory.payload.get_variant_overrides_only",
|
||||
autospec=True,
|
||||
return_value={
|
||||
"svc-bkp-container-2-local": [{}, {}, {}],
|
||||
@@ -213,7 +213,7 @@ class TestBuildDevInventory(unittest.TestCase):
|
||||
)
|
||||
|
||||
@patch(
|
||||
"cli.administration.deploy.development.inventory.payload.get_variants",
|
||||
"cli.administration.deploy.development.inventory.payload.get_variant_overrides_only",
|
||||
autospec=True,
|
||||
return_value={"web-app-keycloak": [{}], "web-app-nextcloud": [{}]},
|
||||
)
|
||||
@@ -232,7 +232,7 @@ class TestBuildDevInventory(unittest.TestCase):
|
||||
self.assertNotIn("--app-variants", first_cmd)
|
||||
|
||||
@patch(
|
||||
"cli.administration.deploy.development.inventory.payload.get_variants",
|
||||
"cli.administration.deploy.development.inventory.payload.get_variant_overrides_only",
|
||||
autospec=True,
|
||||
return_value={"web-app-keycloak": [{}], "web-app-nextcloud": [{}]},
|
||||
)
|
||||
|
||||
@@ -13,16 +13,16 @@ from cli.administration.deploy.development.inventory import (
|
||||
|
||||
class TestResolveVariantPayloads(unittest.TestCase):
|
||||
@patch(
|
||||
"cli.administration.deploy.development.inventory.payload.get_variants",
|
||||
"cli.administration.deploy.development.inventory.payload.get_variant_overrides_only",
|
||||
autospec=True,
|
||||
)
|
||||
def test_app_without_meta_collapses_to_variant_zero_payload(
|
||||
self, get_variants_mock: MagicMock
|
||||
self, get_overrides_mock: MagicMock
|
||||
) -> None:
|
||||
# No `meta/variants.yml` -> loader exposes a single empty variant,
|
||||
# i.e. `[{}]`. The 1st entry MUST be picked even though no variant
|
||||
# was authored, so the per-app bake stays uniform.
|
||||
get_variants_mock.return_value = {"web-app-foo": [{}]}
|
||||
get_overrides_mock.return_value = {"web-app-foo": [{}]}
|
||||
|
||||
resolved = _resolve_variant_payloads(
|
||||
roles_dir="/roles",
|
||||
@@ -32,13 +32,13 @@ class TestResolveVariantPayloads(unittest.TestCase):
|
||||
self.assertEqual(resolved, {"web-app-foo": {}})
|
||||
|
||||
@patch(
|
||||
"cli.administration.deploy.development.inventory.payload.get_variants",
|
||||
"cli.administration.deploy.development.inventory.payload.get_variant_overrides_only",
|
||||
autospec=True,
|
||||
)
|
||||
def test_active_variants_picks_named_variant(
|
||||
self, get_variants_mock: MagicMock
|
||||
self, get_overrides_mock: MagicMock
|
||||
) -> None:
|
||||
get_variants_mock.return_value = {
|
||||
get_overrides_mock.return_value = {
|
||||
"web-app-multi": [{"variant": 0}, {"variant": 1}],
|
||||
}
|
||||
resolved = _resolve_variant_payloads(
|
||||
@@ -49,13 +49,13 @@ class TestResolveVariantPayloads(unittest.TestCase):
|
||||
self.assertEqual(resolved, {"web-app-multi": {"variant": 1}})
|
||||
|
||||
@patch(
|
||||
"cli.administration.deploy.development.inventory.payload.get_variants",
|
||||
"cli.administration.deploy.development.inventory.payload.get_variant_overrides_only",
|
||||
autospec=True,
|
||||
)
|
||||
def test_out_of_range_index_falls_back_to_variant_zero(
|
||||
self, get_variants_mock: MagicMock
|
||||
self, get_overrides_mock: MagicMock
|
||||
) -> None:
|
||||
get_variants_mock.return_value = {
|
||||
get_overrides_mock.return_value = {
|
||||
"web-app-multi": [{"variant": 0}, {"variant": 1}],
|
||||
}
|
||||
resolved = _resolve_variant_payloads(
|
||||
|
||||
+41
@@ -132,6 +132,47 @@ class TestGetVariants(unittest.TestCase):
|
||||
self.assertNotIn("mutated", second["web-app-foo"][0])
|
||||
|
||||
|
||||
class TestGetVariantOverridesOnly(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_reset_cache_for_tests()
|
||||
|
||||
def test_returns_single_empty_override_when_no_meta_variants_yml(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
roles = _seed_minimal_roles(Path(tmp))
|
||||
overrides = cache_apps.get_variant_overrides_only(roles_dir=roles)
|
||||
self.assertEqual(overrides["web-app-foo"], [{}])
|
||||
|
||||
def test_returns_raw_overrides_without_base_merge(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
roles = _seed_minimal_roles(Path(tmp))
|
||||
_write(
|
||||
roles / "web-app-foo" / ROLE_FILE_META_VARIANTS,
|
||||
"""
|
||||
- {}
|
||||
- services:
|
||||
foo:
|
||||
image: foo-alt
|
||||
""",
|
||||
)
|
||||
overrides = cache_apps.get_variant_overrides_only(roles_dir=roles)
|
||||
# Variant 0 stays empty (no override) — the role's
|
||||
# `meta/services.yml` baseline (image=foo) is NOT mixed in.
|
||||
self.assertEqual(overrides["web-app-foo"][0], {})
|
||||
# Variant 1 carries only the override fields.
|
||||
self.assertEqual(
|
||||
overrides["web-app-foo"][1],
|
||||
{"services": {"foo": {"image": "foo-alt"}}},
|
||||
)
|
||||
|
||||
def test_caches_per_roles_dir(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
roles = _seed_minimal_roles(Path(tmp))
|
||||
first = cache_apps.get_variant_overrides_only(roles_dir=roles)
|
||||
first["web-app-foo"][0]["mutated"] = True
|
||||
second = cache_apps.get_variant_overrides_only(roles_dir=roles)
|
||||
self.assertNotIn("mutated", second["web-app-foo"][0])
|
||||
|
||||
|
||||
class TestBuildRoleBaseConfig(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_reset_cache_for_tests()
|
||||
|
||||
Vendored
+34
@@ -35,6 +35,7 @@ if TYPE_CHECKING:
|
||||
|
||||
_APPLICATIONS_DEFAULTS_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_VARIANTS_CACHE: dict[str, dict[str, list[Any]]] = {}
|
||||
_VARIANT_OVERRIDES_ONLY_CACHE: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
||||
_MERGED_APPLICATIONS_CACHE: dict[tuple, dict[str, Any]] = {}
|
||||
|
||||
# Every role's metadata lives under these `meta/<topic>.yml` files.
|
||||
@@ -284,6 +285,38 @@ def get_variants(
|
||||
return copy.deepcopy(cached)
|
||||
|
||||
|
||||
def _build_variant_overrides_only(
|
||||
roles_dir: Path,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
overrides: dict[str, list[dict[str, Any]]] = {}
|
||||
for role_dir in _iter_application_role_dirs(roles_dir):
|
||||
meta_path = role_dir / ROLE_FILE_META_VARIANTS
|
||||
overrides[role_dir.name] = _load_variants_overrides(meta_path)
|
||||
return {key: overrides[key] for key in sorted(overrides)}
|
||||
|
||||
|
||||
def get_variant_overrides_only(
|
||||
*, roles_dir: str | os.PathLike[str] | None = None
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Return ``{application_id: [override_0, ...]}`` — the raw
|
||||
`meta/variants.yml` entries per role WITHOUT deep-merging
|
||||
`meta/services.yml`.
|
||||
|
||||
Used by matrix-deploy's `--vars` bake so the host_vars payload
|
||||
stays sparse: only the variant-specific overrides land there,
|
||||
leaving `meta/services.yml` fields (notably `image`/`version`)
|
||||
blank in host_vars so `apply_mirror_overrides` can populate them
|
||||
from `mirrors.yml`.
|
||||
"""
|
||||
resolved_roles_dir = _resolve_roles_dir(roles_dir=roles_dir)
|
||||
key = _cache_key(resolved_roles_dir)
|
||||
cached = _VARIANT_OVERRIDES_ONLY_CACHE.get(key)
|
||||
if cached is None:
|
||||
cached = _build_variant_overrides_only(resolved_roles_dir)
|
||||
_VARIANT_OVERRIDES_ONLY_CACHE[key] = cached
|
||||
return copy.deepcopy(cached)
|
||||
|
||||
|
||||
def get_merged_applications(
|
||||
*,
|
||||
variables: dict[str, Any] | None = None,
|
||||
@@ -343,4 +376,5 @@ def get_merged_applications(
|
||||
def _reset() -> None:
|
||||
_APPLICATIONS_DEFAULTS_CACHE.clear()
|
||||
_VARIANTS_CACHE.clear()
|
||||
_VARIANT_OVERRIDES_ONLY_CACHE.clear()
|
||||
_MERGED_APPLICATIONS_CACHE.clear()
|
||||
Reference in new issue
Block a user