mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 20:26:33 +00:00
New lint guard
==============
tests/lint/repository/test_no_direct_yaml_calls.py AST-walks the
production tree (utils/, cli/, plugins/, filter_plugins/,
lookup_plugins/, roles/, scripts/, tasks/) and flags any direct
`yaml.safe_load` / `yaml.safe_dump` / `yaml.load` / `yaml.dump` /
`yaml.safe_load_all` / `yaml.dump_all` call. Aliased imports are
tracked: `import yaml as Y` ⇒ `Y.safe_load(...)` is flagged;
`from yaml import safe_load` ⇒ bare `safe_load(...)` is flagged.
Per-line opt-out: `# noqa: direct-yaml` on the call's physical line
(or any line spanned by a multi-line call). No central exemption
list — every legitimate exception lives next to its call with a
human-readable rationale in the surrounding comment.
Cache module additions
======================
* `dump_yaml_str(data)` — symmetric to `safe_dump(data)` with no file
path. Used for stdout output and filter return values where the
cache cannot help but the API needs to stay consistent.
* `load_yaml_str(text)` — symmetric to `safe_load(text)`. Used when
YAML comes from an HTTP body, a mutated buffer, or a mock-patched
source whose content does not correspond to a stable on-disk file.
Migrated production call sites (33 → 0 direct-yaml violations)
==============================================================
* utils/{entity_name_utils, valid_deploy_id, invokable, service_registry,
domains/list, applications/{config, in_group_deps},
docker/image/discovery, meta/{port_bands, scan},
roles/{dependency_resolver, meta_lookup, resource_validation}}
* cli/{build/{graph, inventory/full/command}, create/inventory/{host_vars,
mirror_overrides}, fix/vars_main_files, meta/{applications/in_group_deps,
callorder}, mirror/resolver, validate/inventory}
* plugins/{filter/{compose_volumes, get_all_application_ids, get_role,
invokable_paths, role_path_by_app_id}, lookup/applications_current_play}
* roles/{svc-db-postgres/filter_plugins/split_postgres_connections,
web-app-bigbluebutton/filter_plugins/compose_mods,
web-app-dashboard/lookup_plugins/docker_cards}
Behaviour adjustments
=====================
* utils/roles/dependency_resolver — `safe_load_all` (multi-doc) →
single-doc `load_yaml_any` wrapped in a list. Ansible task files are
single-doc by convention; the downstream multi-doc loop stays intact
for forward compatibility.
* roles/sys-svc-compose-ca/files/compose_ca.py — KEEPS direct
yaml.safe_load / yaml.safe_dump (annotated with noqa). The script is
copied to the target host via `ansible.builtin.copy` and the target
has no `utils/` package on PYTHONPATH.
noqa-annotated direct-yaml call sites (with rationale)
======================================================
* utils/cache/yaml.py — IS the cache; would recurse.
* utils/handler/yaml.py — vault-aware Loader subclass that wraps
\!vault scalars in VaultScalar; cache-by-path collapses the wrapping.
* cli/encrypt/inventory/__main__.py — vault-aware Dumper subclass that
emits \!vault literal blocks; the cached dump_yaml writes plain YAML
which is the wrong output here.
* roles/sys-svc-compose-ca/files/compose_ca.py — runtime-deployed.
* tasks/utils/migrate_meta_layout.py — once-off migration script.
Validation
==========
* Unit tests: 1573 passed.
* Lint: 56 passed (incl. new test_no_direct_yaml_calls).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
247 lines
7.7 KiB
Python
247 lines
7.7 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable, Tuple
|
|
|
|
from utils.cache.yaml import load_yaml_any as _load_yaml_cached
|
|
|
|
|
|
DOCKER_HUB_PREFIXES = (
|
|
"docker.io/",
|
|
"registry-1.docker.io/",
|
|
"index.docker.io/",
|
|
)
|
|
|
|
# All registry hostname prefixes that are stripped when building the canonical name.
|
|
_ALL_REGISTRY_PREFIXES = DOCKER_HUB_PREFIXES + (
|
|
"quay.io/",
|
|
"ghcr.io/",
|
|
"mcr.microsoft.com/",
|
|
)
|
|
|
|
# Registries whose images should be mirrored to GHCR.
|
|
_MIRRORABLE_REGISTRIES = frozenset(
|
|
{
|
|
"docker.io",
|
|
"registry-1.docker.io",
|
|
"index.docker.io",
|
|
"quay.io",
|
|
"ghcr.io",
|
|
"mcr.microsoft.com",
|
|
}
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ImageRef:
|
|
role: str # e.g. svc-db-postgres
|
|
service: str # e.g. postgres
|
|
name: str # canonical image name without registry, e.g. postgis/postgis or postgres
|
|
version: str # e.g. 17-3.5
|
|
source: str # full pull ref, e.g. docker.io/library/postgres:16 or quay.io/keycloak/keycloak:latest
|
|
registry: str = (
|
|
"docker.io" # source registry hostname, e.g. docker.io, quay.io, ghcr.io
|
|
)
|
|
source_file: str = "meta/services.yml" # "meta/services.yml" or "defaults/main.yml"
|
|
|
|
|
|
def load_yaml(path: Path) -> dict:
|
|
obj = _load_yaml_cached(str(path), default_if_missing={}) or {}
|
|
return obj if isinstance(obj, dict) else {}
|
|
|
|
|
|
def _detect_registry(image: str) -> str:
|
|
"""Return the normalised registry hostname for *image*.
|
|
|
|
Implicit Docker Hub images (e.g. ``postgres``, ``postgis/postgis``) return
|
|
``"docker.io"``.
|
|
"""
|
|
image = image.strip()
|
|
first = image.split("/", 1)[0]
|
|
if first in ("docker.io", "registry-1.docker.io", "index.docker.io"):
|
|
return "docker.io"
|
|
if "." in first or ":" in first:
|
|
return first # e.g. "quay.io", "ghcr.io", "mcr.microsoft.com"
|
|
return "docker.io" # implicit Docker Hub
|
|
|
|
|
|
def _strip_registry_prefix(image: str) -> str:
|
|
"""Remove the registry prefix (if any) from *image*, return bare reference."""
|
|
image = image.strip()
|
|
for prefix in _ALL_REGISTRY_PREFIXES:
|
|
if image.startswith(prefix):
|
|
return image[len(prefix) :]
|
|
return image
|
|
|
|
|
|
def is_docker_hub_image(image: str) -> bool:
|
|
"""Return True iff *image* is hosted on Docker Hub (kept for backward compat)."""
|
|
return _detect_registry(image) == "docker.io"
|
|
|
|
|
|
def is_mirrorable_image(image: str) -> bool:
|
|
"""Return True for images from any supported public registry that we mirror."""
|
|
image = image.strip()
|
|
if not image:
|
|
return False
|
|
return _detect_registry(image) in _MIRRORABLE_REGISTRIES
|
|
|
|
|
|
def normalize_docker_hub(image: str) -> str:
|
|
"""Remove Docker Hub registry prefix if present (backward compat)."""
|
|
image = image.strip()
|
|
for prefix in DOCKER_HUB_PREFIXES:
|
|
if image.startswith(prefix):
|
|
return image[len(prefix) :]
|
|
return image
|
|
|
|
|
|
def split_name_and_suffix(image: str) -> Tuple[str, str]:
|
|
"""
|
|
Split image into base name and suffix:
|
|
repo/name:tag -> (repo/name, :tag)
|
|
repo/name@sha.. -> (repo/name, @sha..)
|
|
repo/name -> (repo/name, "")
|
|
"""
|
|
image = image.strip()
|
|
|
|
if "@sha256:" in image:
|
|
base, digest = image.split("@", 1)
|
|
return base, f"@{digest}"
|
|
|
|
last_slash = image.rfind("/")
|
|
last_colon = image.rfind(":")
|
|
if last_colon > last_slash:
|
|
return image[:last_colon], image[last_colon:]
|
|
|
|
return image, ""
|
|
|
|
|
|
def docker_hub_source(image: str, version: str) -> str:
|
|
"""Build canonical Docker Hub source ref for skopeo.
|
|
|
|
postgres + 16 -> docker.io/library/postgres:16
|
|
postgis/postgis + 17 -> docker.io/postgis/postgis:17
|
|
"""
|
|
image = normalize_docker_hub(image)
|
|
base, _suffix = split_name_and_suffix(image) # drop embedded tag/digest
|
|
|
|
if "/" not in base:
|
|
base = f"library/{base}"
|
|
|
|
return f"docker.io/{base}:{version}"
|
|
|
|
|
|
def image_source(image: str, version: str) -> str:
|
|
"""Full pull reference for skopeo (without the ``docker://`` scheme).
|
|
|
|
Examples::
|
|
|
|
postgres + 16 -> docker.io/library/postgres:16
|
|
postgis/postgis + 17-3.5 -> docker.io/postgis/postgis:17-3.5
|
|
quay.io/keycloak/keycloak + latest -> quay.io/keycloak/keycloak:latest
|
|
ghcr.io/mastodon/mastodon + latest -> ghcr.io/mastodon/mastodon:latest
|
|
"""
|
|
registry = _detect_registry(image)
|
|
bare = _strip_registry_prefix(image)
|
|
base, _suffix = split_name_and_suffix(bare) # drop embedded tag/digest
|
|
|
|
if registry == "docker.io" and "/" not in base:
|
|
base = f"library/{base}"
|
|
|
|
return f"{registry}/{base}:{version}"
|
|
|
|
|
|
def canonical_image_name(image: str) -> str:
|
|
"""Canonical image name without registry and without tag/digest.
|
|
|
|
Strips any known registry prefix so that the name can be used uniformly
|
|
regardless of the source registry:
|
|
|
|
quay.io/keycloak/keycloak -> keycloak/keycloak
|
|
ghcr.io/mastodon/mastodon -> mastodon/mastodon
|
|
docker.io/postgres -> postgres
|
|
postgres -> postgres
|
|
"""
|
|
bare = _strip_registry_prefix(image)
|
|
base, _suffix = split_name_and_suffix(bare)
|
|
return base
|
|
|
|
|
|
def iter_role_images(repo_root: Path) -> Iterable[ImageRef]:
|
|
"""
|
|
Yield all ImageRef entries discovered across all roles in *repo_root*.
|
|
|
|
Sources:
|
|
1. roles/**/meta/services.yml → <entity>.{image,version} (post-req-008)
|
|
2. roles/**/defaults/main.yml → images.<name>.{image,version}
|
|
|
|
See docs/contributing/artefact/image.md for the full format reference.
|
|
"""
|
|
roles_dir = repo_root / "roles"
|
|
|
|
# 1. Images from meta/services.yml. The file root IS the services map
|
|
# (per req-008 file-root convention) keyed by <entity_name>.
|
|
for services_file in roles_dir.glob("**/meta/services.yml"):
|
|
role_name = services_file.parent.parent.name
|
|
services = load_yaml(services_file)
|
|
|
|
if not isinstance(services, dict):
|
|
continue
|
|
|
|
for service_name, service in services.items():
|
|
if not isinstance(service, dict):
|
|
continue
|
|
|
|
image = (service.get("image") or "").strip()
|
|
version = (service.get("version") or "").strip()
|
|
|
|
if not image or not version:
|
|
continue
|
|
|
|
if not is_mirrorable_image(image):
|
|
continue
|
|
|
|
yield ImageRef(
|
|
role=role_name,
|
|
service=str(service_name),
|
|
name=canonical_image_name(image),
|
|
version=version,
|
|
source=image_source(image, version),
|
|
registry=_detect_registry(image),
|
|
source_file="meta/services.yml",
|
|
)
|
|
|
|
# 2. Images from defaults/main.yml → images.<name>.{image,version}
|
|
for vars_file in roles_dir.glob("**/defaults/main.yml"):
|
|
role_name = vars_file.parent.parent.name
|
|
data = load_yaml(vars_file)
|
|
|
|
images = data.get("images", {})
|
|
if not isinstance(images, dict):
|
|
continue
|
|
|
|
for service_name, entry in images.items():
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
|
|
image = (entry.get("image") or "").strip()
|
|
version = (entry.get("version") or "").strip()
|
|
|
|
if not image or not version:
|
|
continue
|
|
|
|
if not is_mirrorable_image(image):
|
|
continue
|
|
|
|
yield ImageRef(
|
|
role=role_name,
|
|
service=str(service_name),
|
|
name=canonical_image_name(image),
|
|
version=version,
|
|
source=image_source(image, version),
|
|
registry=_detect_registry(image),
|
|
source_file="defaults/main.yml",
|
|
)
|