mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
meta/schema.yml held credential definitions and their runtime values, but the name said 'schema' and read like structural metadata next to services.yml and volumes.yml. Nothing in the file is a schema in the sense the other meta files use the word, and a contributor had to open it to learn it holds secrets. 62 role files renamed. ROLE_FILE_META_SCHEMA becomes ROLE_FILE_META_SECRETS and keeps its role as the SPOT the path is built from. Two readers bypassed that constant and spelled the name themselves: utils/cache/applications.py built meta_dir / 'schema.yml' twice, and plugins/lookup/application_gid.py carries the filename in the marker list it detects application roles by. Left behind, the rename would not have raised — credentials would have resolved empty and the role would have stopped counting as an application. 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 get_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 = get_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
|