"""Configuration loading and validation.""" from __future__ import annotations from dataclasses import dataclass import os from typing import Any import tomllib @dataclass class MainConfig: base_dir: str templates_prefix: str containers_prefix: str jail_conf_dir: str log_dir: str @dataclass class Mount: host: str jail: str readonly: bool = False @dataclass class JailConfig: name: str template: str cmd: str overlay: str | None mounts: list[Mount] env: dict[str, str] extra_jail_conf: str | None class ConfigError(RuntimeError): pass def load_main_config() -> MainConfig: """Load main config from /usr/local/etc/jprov.conf or /etc/jprov.conf.""" candidates = ["/usr/local/etc/jprov.conf", "/etc/jprov.conf"] path = next((p for p in candidates if os.path.exists(p)), None) if not path: raise ConfigError("main config not found (expected /usr/local/etc/jprov.conf or /etc/jprov.conf)") with open(path, "rb") as fh: data = tomllib.load(fh) try: base_dir = data["base_dir"] datasets = data["datasets"] templates_prefix = datasets["templates_prefix"] containers_prefix = datasets["containers_prefix"] jail_conf_dir = data["jail_conf_dir"] log_dir = data["log_dir"] except KeyError as exc: raise ConfigError(f"missing main config key: {exc}") from exc for key, val in [ ("base_dir", base_dir), ("templates_prefix", templates_prefix), ("containers_prefix", containers_prefix), ("jail_conf_dir", jail_conf_dir), ("log_dir", log_dir), ]: if not isinstance(val, str) or not val: raise ConfigError(f"invalid main config value for {key}") if not templates_prefix.endswith("/"): raise ConfigError("datasets.templates_prefix must end with '/'") if not containers_prefix.endswith("/"): raise ConfigError("datasets.containers_prefix must end with '/'") return MainConfig( base_dir=base_dir, templates_prefix=templates_prefix, containers_prefix=containers_prefix, jail_conf_dir=jail_conf_dir, log_dir=log_dir, ) def load_jail_config(main: MainConfig, jailname: str) -> JailConfig: """Load per-jail TOML config for the given jail name.""" jail_dir = os.path.join(main.base_dir, "defs", jailname) path = os.path.join(jail_dir, "jprov.conf") if not os.path.exists(path): raise ConfigError(f"jail config not found: {path}") with open(path, "rb") as fh: data = tomllib.load(fh) try: template = data["template"] cmd = data["cmd"] except KeyError as exc: raise ConfigError(f"missing jail config key: {exc}") from exc overlay = data.get("overlay") env = data.get("env", {}) extra_jail_conf = data.get("extra_jail_conf") mounts_raw = data.get("mounts", []) if not isinstance(template, str) or not template: raise ConfigError("invalid jail config template") if not isinstance(cmd, str) or not cmd: raise ConfigError("invalid jail config cmd") if overlay is not None and not isinstance(overlay, str): raise ConfigError("invalid jail config overlay") if env and not isinstance(env, dict): raise ConfigError("invalid jail config env") if extra_jail_conf is not None and not isinstance(extra_jail_conf, str): raise ConfigError("invalid jail config extra_jail_conf") if mounts_raw and not isinstance(mounts_raw, list): raise ConfigError("invalid jail config mounts") mounts: list[Mount] = [] for item in mounts_raw: if not isinstance(item, dict): raise ConfigError("invalid mount entry") try: host = item["host"] jail = item["jail"] except KeyError as exc: raise ConfigError(f"missing mount key: {exc}") from exc readonly = bool(item.get("readonly", False)) if not isinstance(host, str) or not isinstance(jail, str): raise ConfigError("invalid mount host/jail") mounts.append(Mount(host=host, jail=jail, readonly=readonly)) return JailConfig( name=jailname, template=template, cmd=cmd, overlay=overlay, mounts=mounts, env={str(k): str(v) for k, v in env.items()} if env else {}, extra_jail_conf=extra_jail_conf, ) def validate_configs(main: MainConfig, jail: JailConfig) -> None: """Validate referenced paths and dataset names exist.""" if not os.path.isdir(main.base_dir): raise ConfigError(f"base_dir does not exist: {main.base_dir}") jail_dir = os.path.join(main.base_dir, "defs", jail.name) if not os.path.isdir(jail_dir): raise ConfigError(f"jail defs dir does not exist: {jail_dir}") overlay_path = resolve_overlay_path(main, jail) if overlay_path and not os.path.isdir(overlay_path): raise ConfigError(f"overlay dir does not exist: {overlay_path}") for mnt in jail.mounts: if not os.path.exists(mnt.host): raise ConfigError(f"mount host path does not exist: {mnt.host}") def resolve_overlay_path(main: MainConfig, jail: JailConfig) -> str: """Return resolved overlay path for the jail.""" if jail.overlay is None or jail.overlay == "": return os.path.join(main.base_dir, "defs", jail.name, "overlay") if os.path.isabs(jail.overlay): return jail.overlay return os.path.join(main.base_dir, "defs", jail.name, jail.overlay) def jail_conf_path(main: MainConfig, jailname: str) -> str: """Return path to the generated jail.conf.d file for this jail.""" return os.path.join(main.jail_conf_dir, f"{jailname}.conf")