mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 12:17:13 +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>
266 lines
9.1 KiB
Python
266 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import OrderedDict
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from utils.roles.entity.name import entity_name
|
|
from utils.roles.mapping import (
|
|
ROLE_FILE_META_MAIN,
|
|
ROLE_FILE_META_RBAC,
|
|
ROLE_FILE_META_SECRETS,
|
|
ROLE_FILE_META_SERVER,
|
|
ROLE_FILE_META_SERVICES,
|
|
ROLE_FILE_META_USERS,
|
|
ROLE_FILE_META_VOLUMES,
|
|
)
|
|
|
|
from . import yaml_io
|
|
from .credentials import convert_runtime_to_schema, detect_collision
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
ALLOWED_LIFECYCLES = {
|
|
"planned",
|
|
"pre-alpha",
|
|
"alpha",
|
|
"beta",
|
|
"stable",
|
|
"deprecated",
|
|
}
|
|
|
|
_EXCLUDED_TOP_LEVEL_KEYS = {"compose", "server", "rbac", "credentials"}
|
|
|
|
|
|
def build(
|
|
role_dir: Path,
|
|
networks_for_role: dict[str, Any] | None,
|
|
ports_for_role: dict[str, dict[str, Any]],
|
|
relays_for_role: dict[str, dict[str, int]],
|
|
) -> None:
|
|
role_name = role_dir.name
|
|
primary_entity = entity_name(role_name) or role_name
|
|
|
|
config = _load_mapping(role_dir / "config" / "main.yml", role_name)
|
|
schema = _load_mapping(role_dir / "schema" / "main.yml", role_name)
|
|
users_data = _load_mapping(role_dir / "users" / "main.yml", role_name)
|
|
meta_main = _load_mapping(role_dir / ROLE_FILE_META_MAIN, role_name)
|
|
|
|
_emit_credentials(role_dir, role_name, schema, config)
|
|
_emit_users(role_dir, users_data)
|
|
_emit_server(role_dir, config, networks_for_role)
|
|
_emit_rbac(role_dir, config)
|
|
_emit_volumes(role_dir, config)
|
|
|
|
services = _build_services(config, primary_entity, role_name)
|
|
_apply_centralised_ports(services, ports_for_role, role_name)
|
|
_apply_relays(services, relays_for_role, role_name)
|
|
galaxy_changed = _migrate_run_after_lifecycle(
|
|
meta_main, services, primary_entity, role_name
|
|
)
|
|
|
|
if services:
|
|
yaml_io.dump(role_dir / ROLE_FILE_META_SERVICES, dict(services))
|
|
if galaxy_changed:
|
|
yaml_io.dump(role_dir / ROLE_FILE_META_MAIN, meta_main)
|
|
|
|
for legacy in ("config", "schema", "users"):
|
|
yaml_io.empty_dir(role_dir / legacy)
|
|
|
|
|
|
def _load_mapping(path: Path, role_name: str) -> dict[str, Any]:
|
|
data = yaml_io.load(path) or {}
|
|
if not isinstance(data, dict):
|
|
raise SystemExit(f"{role_name}: {path} is not a mapping")
|
|
return data
|
|
|
|
|
|
def _emit_credentials(
|
|
role_dir: Path,
|
|
role_name: str,
|
|
schema: dict[str, Any],
|
|
config: dict[str, Any],
|
|
) -> None:
|
|
schema_creds = (
|
|
dict(schema.get("credentials") or {})
|
|
if isinstance(schema.get("credentials"), dict)
|
|
else {}
|
|
)
|
|
config_creds_raw = config.get("credentials")
|
|
config_creds = dict(config_creds_raw) if isinstance(config_creds_raw, dict) else {}
|
|
converted = convert_runtime_to_schema(config_creds)
|
|
detect_collision(schema_creds, converted, role_name)
|
|
merged = yaml_io.deep_merge(schema_creds, converted)
|
|
if merged:
|
|
yaml_io.dump(role_dir / ROLE_FILE_META_SECRETS, {"credentials": merged})
|
|
|
|
|
|
def _emit_users(role_dir: Path, users_data: dict[str, Any]) -> None:
|
|
users_block = users_data.get("users")
|
|
if isinstance(users_block, dict) and users_block:
|
|
yaml_io.dump(role_dir / ROLE_FILE_META_USERS, users_block)
|
|
|
|
|
|
def _emit_server(
|
|
role_dir: Path,
|
|
config: dict[str, Any],
|
|
networks_for_role: dict[str, Any] | None,
|
|
) -> None:
|
|
server_block = config.get("server")
|
|
if isinstance(server_block, dict) and server_block:
|
|
payload = dict(server_block)
|
|
if networks_for_role:
|
|
payload["networks"] = {"local": networks_for_role}
|
|
yaml_io.dump(role_dir / ROLE_FILE_META_SERVER, payload)
|
|
elif networks_for_role:
|
|
yaml_io.dump(
|
|
role_dir / ROLE_FILE_META_SERVER,
|
|
{"networks": {"local": networks_for_role}},
|
|
)
|
|
|
|
|
|
def _emit_rbac(role_dir: Path, config: dict[str, Any]) -> None:
|
|
rbac_block = config.get("rbac")
|
|
if isinstance(rbac_block, dict) and rbac_block:
|
|
yaml_io.dump(role_dir / ROLE_FILE_META_RBAC, rbac_block)
|
|
|
|
|
|
def _emit_volumes(role_dir: Path, config: dict[str, Any]) -> None:
|
|
compose = config.get("compose")
|
|
if not isinstance(compose, dict):
|
|
return
|
|
volumes_block = compose.get("volumes")
|
|
if isinstance(volumes_block, dict) and volumes_block:
|
|
yaml_io.dump(role_dir / ROLE_FILE_META_VOLUMES, volumes_block)
|
|
|
|
|
|
def _build_services(
|
|
config: dict[str, Any], primary_entity: str, role_name: str
|
|
) -> OrderedDict[str, dict[str, Any]]:
|
|
compose = config.get("compose") if isinstance(config.get("compose"), dict) else {}
|
|
services: OrderedDict[str, dict[str, Any]] = OrderedDict()
|
|
raw_services = compose.get("services") if isinstance(compose, dict) else None
|
|
if isinstance(raw_services, dict):
|
|
for key, value in raw_services.items():
|
|
services[key] = _normalise_service_entry(value)
|
|
|
|
inlined = {k: v for k, v in config.items() if k not in _EXCLUDED_TOP_LEVEL_KEYS}
|
|
if inlined:
|
|
primary_node = services.setdefault(primary_entity, {})
|
|
if not isinstance(primary_node, dict):
|
|
raise SystemExit(
|
|
f"{role_name}: cannot inline keys under non-dict primary entity "
|
|
f"'{primary_entity}'."
|
|
)
|
|
for key, value in inlined.items():
|
|
primary_node[key] = (
|
|
yaml_io.deep_merge(value, primary_node[key])
|
|
if key in primary_node
|
|
else value
|
|
)
|
|
services[primary_entity] = primary_node
|
|
return services
|
|
|
|
|
|
def _normalise_service_entry(value: Any) -> Any:
|
|
if not isinstance(value, dict):
|
|
return value
|
|
entry = dict(value)
|
|
if "port" in entry:
|
|
raw_port = entry.pop("port")
|
|
try:
|
|
internal_port: Any = int(str(raw_port).strip())
|
|
except (TypeError, ValueError):
|
|
internal_port = raw_port
|
|
ports_field = entry.get("ports")
|
|
if not isinstance(ports_field, dict):
|
|
ports_field = {}
|
|
internal_map = ports_field.get("internal")
|
|
if not isinstance(internal_map, dict):
|
|
internal_map = {}
|
|
internal_map["http"] = internal_port
|
|
ports_field["internal"] = internal_map
|
|
entry["ports"] = ports_field
|
|
return entry
|
|
|
|
|
|
def _apply_centralised_ports(
|
|
services: OrderedDict[str, dict[str, Any]],
|
|
ports_for_role: dict[str, dict[str, Any]],
|
|
role_name: str,
|
|
) -> None:
|
|
for entity_key, ports_payload in ports_for_role.items():
|
|
node = services.setdefault(entity_key, {})
|
|
if not isinstance(node, dict):
|
|
raise SystemExit(
|
|
f"{role_name}: cannot attach ports to non-dict entity '{entity_key}'."
|
|
)
|
|
node_ports = node.get("ports") if isinstance(node.get("ports"), dict) else {}
|
|
for scope_name, scope_map in ports_payload.items():
|
|
existing = node_ports.get(scope_name)
|
|
if isinstance(existing, dict):
|
|
merged = dict(existing)
|
|
merged.update(scope_map)
|
|
node_ports[scope_name] = merged
|
|
else:
|
|
node_ports[scope_name] = dict(scope_map)
|
|
node["ports"] = node_ports
|
|
services[entity_key] = node
|
|
|
|
|
|
def _apply_relays(
|
|
services: OrderedDict[str, dict[str, Any]],
|
|
relays_for_role: dict[str, dict[str, int]],
|
|
role_name: str,
|
|
) -> None:
|
|
for entity_key, relay in relays_for_role.items():
|
|
node = services.setdefault(entity_key, {})
|
|
if not isinstance(node, dict):
|
|
raise SystemExit(
|
|
f"{role_name}: cannot attach relay range to non-dict entity "
|
|
f"'{entity_key}'."
|
|
)
|
|
node_ports = node.get("ports") if isinstance(node.get("ports"), dict) else {}
|
|
public_scope = (
|
|
node_ports.get("public")
|
|
if isinstance(node_ports.get("public"), dict)
|
|
else {}
|
|
)
|
|
public_scope["relay"] = {"start": relay["start"], "end": relay["end"]}
|
|
node_ports["public"] = public_scope
|
|
node["ports"] = node_ports
|
|
services[entity_key] = node
|
|
|
|
|
|
def _migrate_run_after_lifecycle(
|
|
meta_main: dict[str, Any],
|
|
services: OrderedDict[str, dict[str, Any]],
|
|
primary_entity: str,
|
|
role_name: str,
|
|
) -> bool:
|
|
galaxy_info = meta_main.get("galaxy_info")
|
|
if not isinstance(galaxy_info, dict):
|
|
return False
|
|
run_after = galaxy_info.pop("run_after", None)
|
|
lifecycle = galaxy_info.pop("lifecycle", None)
|
|
if run_after is None and lifecycle is None:
|
|
return False
|
|
primary_node = services.setdefault(primary_entity, {})
|
|
if not isinstance(primary_node, dict):
|
|
raise SystemExit(
|
|
f"{role_name}: cannot attach run_after/lifecycle to non-dict primary "
|
|
f"entity '{primary_entity}'."
|
|
)
|
|
if isinstance(run_after, list) and run_after:
|
|
primary_node["run_after"] = list(run_after)
|
|
if isinstance(lifecycle, str) and lifecycle.strip():
|
|
stripped = lifecycle.strip().lower()
|
|
if stripped not in ALLOWED_LIFECYCLES:
|
|
raise SystemExit(
|
|
f"{role_name}: meta/main.yml.galaxy_info.lifecycle has unknown "
|
|
f"value {lifecycle!r}. Allowed: {sorted(ALLOWED_LIFECYCLES)}."
|
|
)
|
|
primary_node["lifecycle"] = stripped
|
|
services[primary_entity] = primary_node
|
|
return True
|