mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-12 05:36:40 +00:00
- resolver: output both applications: (config/main.yml compose images) and images: (defaults/main.yml images) keyed by source_file - image_discovery: scan defaults/main.yml instead of vars/main.yml for images: - mirror_overrides: write mirror URLs to applications.*.compose.services.* (mirror_policy-aware) and images_overrides.*.* (always merged, if_missing) - roles/sys-ctl-hlth-csp, roles/test-e2e-playwright: move images: from vars/main.yml to defaults/main.yml for correct Ansible variable precedence - plugins/lookup/image.py: new Ansible lookup plugin that prefers images_overrides over role-local images defaults - docs: add artefact/image.md and artefact/mirror.md as SPOTs; add code/role/image.md contributor guide; link from ci.md - tests: update test_util, test_mirror_overrides; add test_resolver, test_publish (404/401 coverage), test_image (lookup plugin) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
179 lines
5.8 KiB
Python
179 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from ruamel.yaml import YAML
|
|
from ruamel.yaml.comments import CommentedMap
|
|
|
|
|
|
def _ensure_ruamel_map(node: CommentedMap, key: str) -> CommentedMap:
|
|
if key not in node or not isinstance(node.get(key), CommentedMap):
|
|
node[key] = CommentedMap()
|
|
return node[key]
|
|
|
|
|
|
def _is_blank(val: object) -> bool:
|
|
if val is None:
|
|
return True
|
|
if isinstance(val, str):
|
|
return not val.strip()
|
|
return False
|
|
|
|
|
|
def _get_policy(svc_doc: CommentedMap) -> str:
|
|
"""
|
|
Read mirror policy from existing host_vars service node.
|
|
Allowed: force | skip | if_missing
|
|
Default: if_missing
|
|
"""
|
|
raw = svc_doc.get("mirror_policy")
|
|
if raw is None:
|
|
return "if_missing"
|
|
if not isinstance(raw, str):
|
|
return "if_missing"
|
|
policy = raw.strip().lower()
|
|
if policy in {"force", "skip", "if_missing"}:
|
|
return policy
|
|
return "if_missing"
|
|
|
|
|
|
def apply_mirror_overrides(host_vars_file: Path, mirrors_file: Path) -> None:
|
|
"""
|
|
Apply image mirror overrides to host_vars.
|
|
|
|
See docs/contributing/artefact/mirror.md for the full architecture, format,
|
|
and mirror_policy documentation.
|
|
"""
|
|
if not mirrors_file.exists():
|
|
raise SystemExit(f"Mirrors file not found: {mirrors_file}")
|
|
|
|
try:
|
|
mirrors_raw = yaml.safe_load(mirrors_file.read_text(encoding="utf-8")) or {}
|
|
except Exception as exc:
|
|
raise SystemExit(f"Failed to load mirrors file {mirrors_file}: {exc}") from exc
|
|
|
|
if not isinstance(mirrors_raw, dict):
|
|
raise SystemExit(
|
|
f"Mirrors file must contain a mapping at top-level: {mirrors_file}"
|
|
)
|
|
|
|
mirrors_apps = mirrors_raw.get("applications", {}) or {}
|
|
mirrors_images = mirrors_raw.get("images", {}) or {}
|
|
has_applications = isinstance(mirrors_apps, dict) and bool(mirrors_apps)
|
|
has_images = isinstance(mirrors_images, dict) and bool(mirrors_images)
|
|
if not has_applications and not has_images:
|
|
return # no-op
|
|
|
|
yaml_rt = YAML(typ="rt")
|
|
yaml_rt.preserve_quotes = True
|
|
|
|
if host_vars_file.exists():
|
|
with host_vars_file.open("r", encoding="utf-8") as f:
|
|
doc = yaml_rt.load(f)
|
|
if doc is None:
|
|
doc = CommentedMap()
|
|
else:
|
|
doc = CommentedMap()
|
|
|
|
if not isinstance(doc, CommentedMap):
|
|
tmp = CommentedMap()
|
|
for k, v in dict(doc).items():
|
|
tmp[k] = v
|
|
doc = tmp
|
|
|
|
changed = False
|
|
|
|
if has_applications:
|
|
apps_doc = _ensure_ruamel_map(doc, "applications")
|
|
for app_id, app_block in mirrors_apps.items():
|
|
if not isinstance(app_block, dict):
|
|
continue
|
|
|
|
docker = app_block.get("compose") or {}
|
|
if not isinstance(docker, dict):
|
|
continue
|
|
|
|
services = docker.get("services") or {}
|
|
if not isinstance(services, dict):
|
|
continue
|
|
|
|
app_doc = _ensure_ruamel_map(apps_doc, str(app_id))
|
|
docker_doc = _ensure_ruamel_map(app_doc, "compose")
|
|
services_doc = _ensure_ruamel_map(docker_doc, "services")
|
|
|
|
for svc_name, svc_block in services.items():
|
|
if not isinstance(svc_block, dict):
|
|
continue
|
|
|
|
image = svc_block.get("image")
|
|
version = svc_block.get("version")
|
|
|
|
if not isinstance(image, str) or _is_blank(image):
|
|
continue
|
|
if not isinstance(version, str) or _is_blank(version):
|
|
continue
|
|
|
|
image = image.strip()
|
|
version = version.strip()
|
|
|
|
svc_doc = _ensure_ruamel_map(services_doc, str(svc_name))
|
|
policy = _get_policy(svc_doc)
|
|
|
|
if policy == "skip":
|
|
continue
|
|
|
|
if policy == "force":
|
|
if svc_doc.get("image") != image:
|
|
svc_doc["image"] = image
|
|
changed = True
|
|
if svc_doc.get("version") != version:
|
|
svc_doc["version"] = version
|
|
changed = True
|
|
continue
|
|
|
|
# if_missing (default)
|
|
if _is_blank(svc_doc.get("image")):
|
|
svc_doc["image"] = image
|
|
changed = True
|
|
if _is_blank(svc_doc.get("version")):
|
|
svc_doc["version"] = version
|
|
changed = True
|
|
|
|
if has_images:
|
|
images_overrides_doc = _ensure_ruamel_map(doc, "images_overrides")
|
|
for role_id, role_svcs in mirrors_images.items():
|
|
if not isinstance(role_svcs, dict):
|
|
continue
|
|
|
|
role_images_doc = _ensure_ruamel_map(images_overrides_doc, str(role_id))
|
|
for svc_name, svc_block in role_svcs.items():
|
|
if not isinstance(svc_block, dict):
|
|
continue
|
|
|
|
image = svc_block.get("image")
|
|
version = svc_block.get("version")
|
|
|
|
if not isinstance(image, str) or _is_blank(image):
|
|
continue
|
|
if not isinstance(version, str) or _is_blank(version):
|
|
continue
|
|
|
|
image = image.strip()
|
|
version = version.strip()
|
|
|
|
svc_images_doc = _ensure_ruamel_map(role_images_doc, str(svc_name))
|
|
if _is_blank(svc_images_doc.get("image")):
|
|
svc_images_doc["image"] = image
|
|
changed = True
|
|
if _is_blank(svc_images_doc.get("version")):
|
|
svc_images_doc["version"] = version
|
|
changed = True
|
|
|
|
if not changed:
|
|
return
|
|
|
|
host_vars_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with host_vars_file.open("w", encoding="utf-8") as f:
|
|
yaml_rt.dump(doc, f)
|