mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
The entity name keys a role's instance directory, its compose project and its container name, and was derived only by stripping the longest matching category prefix. Two roles under different categories whose names share a suffix therefore collapse onto one entity and overwrite each other's rendered stack, which no provides: can repair. A literal entity_name in vars/main.yml now wins over the derivation. web-app-seaweedfs-console takes its shorter name back and declares the entity seaweedfs-console, which frees the seaweedfs key in its own services map for the engine consumer and removes the provides:, covers: and alias entries that stood in for it. A new lint pairs the declaration with the primary key it has to match, because a drift between them would drop the role from the provider set in silence. Compared across all 288 roles, exactly one entity changes, and it restores what main already keeps on disk, so no instance directory moves. The function and the filter lose their get_ prefix. Two lints read that prefix as the discriminator between the variable and the filter that fills it; both discriminate on the pipe now. Ansible's templar resolves a filter and a same-named variable independently, verified against ansible-core 2.20.2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
137 lines
4.1 KiB
Python
137 lines
4.1 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from humanfriendly import parse_size
|
|
|
|
from utils.annotations.message import warning
|
|
from utils.cache.yaml import load_yaml_any
|
|
from utils.roles.entity.name import entity_name
|
|
from utils.roles.mapping import ROLE_FILE_META_SERVICES
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
from pathlib import Path
|
|
|
|
|
|
def _roles_root() -> Path:
|
|
return (PROJECT_ROOT / "roles").resolve()
|
|
|
|
|
|
def _deep_get(dct: dict, path: list[str]) -> Any:
|
|
cur: Any = dct
|
|
for key in path:
|
|
if not isinstance(cur, dict) or key not in cur:
|
|
return None
|
|
cur = cur[key]
|
|
return cur
|
|
|
|
|
|
def _load_yaml_file(path: Path) -> dict:
|
|
data = load_yaml_any(str(path), default_if_missing={})
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def _parse_storage_to_gb(value: Any) -> float:
|
|
if isinstance(value, (int, float)):
|
|
return float(value)
|
|
size_bytes = parse_size(str(value))
|
|
return size_bytes / (1000**3)
|
|
|
|
|
|
def filter_roles_by_min_storage(
|
|
*,
|
|
role_names: Iterable[str],
|
|
required_storage: str | float,
|
|
emit_warnings: bool = False,
|
|
) -> list[str]:
|
|
roles_root_path = _roles_root()
|
|
out: list[str] = []
|
|
|
|
try:
|
|
required_gb = _parse_storage_to_gb(required_storage)
|
|
except Exception as exc:
|
|
raise ValueError(
|
|
f"Invalid required_storage value: {required_storage!r}"
|
|
) from exc
|
|
|
|
for role_name in role_names:
|
|
role_dir = (roles_root_path / role_name).resolve()
|
|
if not role_dir.is_dir():
|
|
if emit_warnings:
|
|
warning(
|
|
f"Role directory not found: {role_dir}",
|
|
title="min_storage validation",
|
|
)
|
|
continue
|
|
|
|
entity = entity_name(role_name)
|
|
if not entity:
|
|
cfg_path_early = role_dir / ROLE_FILE_META_SERVICES
|
|
if cfg_path_early.is_file():
|
|
try:
|
|
early_cfg = _load_yaml_file(cfg_path_early)
|
|
if isinstance(early_cfg, dict) and role_name in early_cfg:
|
|
entity = role_name
|
|
except Exception: # noqa: S110 - non-fatal: malformed services.yml just skips the role
|
|
pass
|
|
if not entity:
|
|
if emit_warnings:
|
|
warning(
|
|
f"Could not derive entity_name from role_name '{role_name}'.",
|
|
title="min_storage validation",
|
|
)
|
|
continue
|
|
|
|
cfg_path = role_dir / ROLE_FILE_META_SERVICES
|
|
if not cfg_path.is_file():
|
|
continue
|
|
|
|
try:
|
|
cfg = _load_yaml_file(cfg_path)
|
|
except Exception as exc:
|
|
if emit_warnings:
|
|
warning(
|
|
f"Failed to parse YAML: {cfg_path} ({exc})",
|
|
title="min_storage validation",
|
|
)
|
|
continue
|
|
|
|
service_cfg = _deep_get(cfg, [entity])
|
|
if service_cfg is None or not isinstance(service_cfg, dict):
|
|
out.append(role_name)
|
|
continue
|
|
|
|
min_storage_val = service_cfg.get("min_storage")
|
|
|
|
if min_storage_val is None:
|
|
if emit_warnings:
|
|
warning(
|
|
f"Missing key services.{entity}.min_storage in {cfg_path} (treating as 0GB)",
|
|
title="min_storage validation",
|
|
)
|
|
out.append(role_name)
|
|
continue
|
|
|
|
try:
|
|
min_storage_gb = _parse_storage_to_gb(min_storage_val)
|
|
except Exception as exc:
|
|
if emit_warnings:
|
|
warning(
|
|
f"Invalid min_storage value in {cfg_path}: {min_storage_val!r} ({exc})",
|
|
title="min_storage validation",
|
|
)
|
|
continue
|
|
|
|
if min_storage_gb <= required_gb:
|
|
out.append(role_name)
|
|
elif emit_warnings:
|
|
warning(
|
|
f"{role_name} requires {min_storage_gb:.1f}GB but runner provides only {required_gb:.1f}GB",
|
|
title="min_storage validation",
|
|
)
|
|
|
|
return out
|