"""Installation intent model and validation.""" from __future__ import annotations from dataclasses import asdict, dataclass import ipaddress import json from pathlib import Path import re from typing import Any, Mapping from urllib.parse import urlsplit SCHEMA_VERSION = 1 INSTALLATION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]{1,47}$") ENV_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$") SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") IMAGE_DIGEST_PATTERN = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$") RFC1918_NETWORKS = tuple( ipaddress.ip_network(value) for value in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16") ) BASE_MODULES = ( "tenancy", "organizations", "identity", "idm", "access", "admin", "dashboard", "policy", "audit", "docs", "ops", ) FULL_MODULES = ( *BASE_MODULES, "addresses", "dist_lists", "files", "mail", "campaigns", "calendar", "poll", "scheduling", "connectors", "datasources", "dataflow", "workflow", "views", "search", "risk_compliance", "postbox", "evaluation", ) class SpecError(ValueError): """Raised when an installation specification is malformed.""" @dataclass(frozen=True, slots=True) class ListenConfig: address: str port: int @dataclass(frozen=True, slots=True) class ReleaseConfig: channel: str version: str manifest_url: str manifest_sha256: str api_image: str web_image: str @dataclass(frozen=True, slots=True) class ServiceConfig: mode: str image: str = "" url_env: str = "" @dataclass(frozen=True, slots=True) class StorageConfig: mode: str @dataclass(frozen=True, slots=True) class ComponentConfig: postgres: ServiceConfig redis: ServiceConfig mail: ServiceConfig storage: StorageConfig @dataclass(frozen=True, slots=True) class InstallationSpec: schema_version: int installation_id: str profile: str public_url: str listen: ListenConfig network_subnet: str release: ReleaseConfig components: ComponentConfig enabled_modules: tuple[str, ...] def to_dict(self) -> dict[str, Any]: value = asdict(self) value["enabled_modules"] = list(self.enabled_modules) return value def default_spec( *, installation_id: str = "govoplan-local", profile: str = "evaluation", public_url: str = "http://127.0.0.1:8080", listen_address: str = "127.0.0.1", listen_port: int = 8080, postgres_mode: str = "managed", redis_mode: str = "managed", mail_mode: str = "disabled", storage_mode: str = "local", module_set: str = "base", api_image: str = "govoplan-api:unpublished", web_image: str = "govoplan-web:unpublished", manifest_url: str = "", manifest_sha256: str = "", version: str = "unpublished", channel: str = "stable", ) -> InstallationSpec: modules = { "core": (), "base": BASE_MODULES, "full": FULL_MODULES, }.get(module_set) if modules is None: raise SpecError("module_set must be core, base, or full") subnet_octet = 32 + (sum(installation_id.encode("utf-8")) % 192) raw = { "schema_version": SCHEMA_VERSION, "installation_id": installation_id, "profile": profile, "public_url": public_url, "listen": {"address": listen_address, "port": listen_port}, "network_subnet": f"172.30.{subnet_octet}.0/24", "release": { "channel": channel, "version": version, "manifest_url": manifest_url, "manifest_sha256": manifest_sha256, "api_image": api_image, "web_image": web_image, }, "components": { "postgres": { "mode": postgres_mode, "image": "postgres:16-alpine", "url_env": "DATABASE_URL", }, "redis": { "mode": redis_mode, "image": "redis:7-alpine", "url_env": "REDIS_URL", }, "mail": { "mode": mail_mode, "image": "greenmail/standalone:2.1.9", "url_env": "", }, "storage": {"mode": storage_mode}, }, "enabled_modules": list(modules), } return parse_spec(raw) def load_spec(path: Path) -> InstallationSpec: try: raw = json.loads(path.read_text(encoding="utf-8")) except FileNotFoundError as exc: raise SpecError(f"installation specification does not exist: {path}") from exc except json.JSONDecodeError as exc: raise SpecError(f"installation specification is not valid JSON: {exc}") from exc return parse_spec(raw) def parse_spec(raw: object) -> InstallationSpec: root = _mapping(raw, "installation") _only_keys( root, { "schema_version", "installation_id", "profile", "public_url", "listen", "network_subnet", "release", "components", "enabled_modules", }, "installation", ) schema_version = _integer(root, "schema_version") if schema_version != SCHEMA_VERSION: raise SpecError( f"schema_version must be {SCHEMA_VERSION}; found {schema_version}" ) installation_id = _string(root, "installation_id") if not INSTALLATION_ID_PATTERN.fullmatch(installation_id): raise SpecError( "installation_id must start with a lowercase letter and contain only " "lowercase letters, digits, or hyphens (2-48 characters)" ) profile = _choice(root, "profile", {"evaluation", "self-hosted"}) public_url = _http_url(_string(root, "public_url"), "public_url") public_parts = urlsplit(public_url) if profile == "self-hosted" and public_parts.scheme != "https": raise SpecError("self-hosted public_url must use HTTPS") listen_raw = _mapping(root.get("listen"), "listen") _only_keys(listen_raw, {"address", "port"}, "listen") listen = ListenConfig( address=_ip_address(_string(listen_raw, "address"), "listen.address"), port=_port(_integer(listen_raw, "port"), "listen.port"), ) network_subnet = _network( _string(root, "network_subnet"), "network_subnet" ) release = _release(root.get("release")) components = _components(root.get("components"), profile=profile) enabled_raw = root.get("enabled_modules") if not isinstance(enabled_raw, list): raise SpecError("enabled_modules must be an array") enabled_modules: list[str] = [] seen_modules: set[str] = set() for index, value in enumerate(enabled_raw): if not isinstance(value, str) or not re.fullmatch( r"[a-z][a-z0-9_]{1,63}", value ): raise SpecError( f"enabled_modules[{index}] must be a canonical module id" ) if value in seen_modules: raise SpecError(f"enabled_modules contains duplicate module {value!r}") enabled_modules.append(value) seen_modules.add(value) return InstallationSpec( schema_version=schema_version, installation_id=installation_id, profile=profile, public_url=public_url, listen=listen, network_subnet=network_subnet, release=release, components=components, enabled_modules=tuple(enabled_modules), ) def _release(raw: object) -> ReleaseConfig: value = _mapping(raw, "release") _only_keys( value, { "channel", "version", "manifest_url", "manifest_sha256", "api_image", "web_image", }, "release", ) channel = _string(value, "channel") if not re.fullmatch(r"[a-z][a-z0-9-]{1,31}", channel): raise SpecError("release.channel must be a canonical channel name") version = _string(value, "version") if not version or len(version) > 80 or any(char.isspace() for char in version): raise SpecError("release.version must be a non-empty version token") manifest_url = _optional_string(value, "manifest_url") if manifest_url: manifest_url = _http_url(manifest_url, "release.manifest_url") if urlsplit(manifest_url).scheme != "https": raise SpecError("release.manifest_url must use HTTPS") manifest_sha256 = _optional_string(value, "manifest_sha256").lower() if manifest_sha256 and not SHA256_PATTERN.fullmatch(manifest_sha256): raise SpecError("release.manifest_sha256 must be a lowercase SHA-256 hex digest") api_image = _image(_string(value, "api_image"), "release.api_image") web_image = _image(_string(value, "web_image"), "release.web_image") return ReleaseConfig( channel=channel, version=version, manifest_url=manifest_url, manifest_sha256=manifest_sha256, api_image=api_image, web_image=web_image, ) def _components(raw: object, *, profile: str) -> ComponentConfig: value = _mapping(raw, "components") _only_keys(value, {"postgres", "redis", "mail", "storage"}, "components") postgres = _service( value.get("postgres"), "components.postgres", modes={"managed", "external"}, image_required_for={"managed"}, url_env_required_for={"external"}, ) redis = _service( value.get("redis"), "components.redis", modes={"managed", "external", "disabled"}, image_required_for={"managed"}, url_env_required_for={"external"}, ) mail = _service( value.get("mail"), "components.mail", modes={"disabled", "external-relay", "test-mail"}, image_required_for={"test-mail"}, url_env_required_for=set(), ) storage_raw = _mapping(value.get("storage"), "components.storage") _only_keys(storage_raw, {"mode"}, "components.storage") storage = StorageConfig(mode=_choice(storage_raw, "mode", {"local", "s3"})) if postgres.url_env != "DATABASE_URL": raise SpecError("components.postgres.url_env must be DATABASE_URL") if redis.url_env != "REDIS_URL": raise SpecError("components.redis.url_env must be REDIS_URL") if mail.url_env: raise SpecError("components.mail.url_env must be empty") if profile == "self-hosted" and redis.mode == "disabled": raise SpecError("self-hosted installations require managed or external Redis") if profile == "self-hosted" and mail.mode == "test-mail": raise SpecError("the test-mail component is restricted to evaluation installs") return ComponentConfig( postgres=postgres, redis=redis, mail=mail, storage=storage, ) def _service( raw: object, label: str, *, modes: set[str], image_required_for: set[str], url_env_required_for: set[str], ) -> ServiceConfig: value = _mapping(raw, label) _only_keys(value, {"mode", "image", "url_env"}, label) mode = _choice(value, "mode", modes) image = _optional_string(value, "image") url_env = _optional_string(value, "url_env") if mode in image_required_for: image = _image(image, f"{label}.image") if mode in url_env_required_for: if not ENV_NAME_PATTERN.fullmatch(url_env): raise SpecError(f"{label}.url_env must name an environment variable") return ServiceConfig(mode=mode, image=image, url_env=url_env) def image_is_digest_pinned(value: str) -> bool: return bool(IMAGE_DIGEST_PATTERN.fullmatch(value)) def image_is_unpublished(value: str) -> bool: return value.endswith(":unpublished") def _mapping(value: object, label: str) -> Mapping[str, Any]: if not isinstance(value, dict): raise SpecError(f"{label} must be an object") if not all(isinstance(key, str) for key in value): raise SpecError(f"{label} keys must be strings") return value def _only_keys(value: Mapping[str, Any], allowed: set[str], label: str) -> None: unknown = sorted(set(value) - allowed) if unknown: raise SpecError(f"{label} contains unknown fields: {', '.join(unknown)}") def _string(value: Mapping[str, Any], key: str) -> str: result = value.get(key) if not isinstance(result, str): raise SpecError(f"{key} must be a string") return result.strip() def _optional_string(value: Mapping[str, Any], key: str) -> str: result = value.get(key, "") if not isinstance(result, str): raise SpecError(f"{key} must be a string") return result.strip() def _integer(value: Mapping[str, Any], key: str) -> int: result = value.get(key) if isinstance(result, bool) or not isinstance(result, int): raise SpecError(f"{key} must be an integer") return result def _choice(value: Mapping[str, Any], key: str, choices: set[str]) -> str: result = _string(value, key) if result not in choices: raise SpecError(f"{key} must be one of: {', '.join(sorted(choices))}") return result def _http_url(value: str, label: str) -> str: try: parsed = urlsplit(value) parsed.port except ValueError as exc: raise SpecError(f"{label} contains an invalid host or port") from exc if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise SpecError(f"{label} must be an absolute HTTP(S) URL") if parsed.username or parsed.password or parsed.fragment or parsed.query: raise SpecError( f"{label} must not contain credentials, a query, or a fragment" ) if label == "public_url" and parsed.path not in {"", "/"}: raise SpecError("public_url must address the site root without a path") return value.rstrip("/") def _image(value: str, label: str) -> str: if ( not value or len(value) > 300 or any(character.isspace() for character in value) or value.startswith("-") ): raise SpecError(f"{label} must be a valid non-empty OCI image reference") return value def _ip_address(value: str, label: str) -> str: try: return str(ipaddress.ip_address(value)) except ValueError as exc: raise SpecError(f"{label} must be an IPv4 or IPv6 address") from exc def _network(value: str, label: str) -> str: try: network = ipaddress.ip_network(value, strict=True) except ValueError as exc: raise SpecError(f"{label} must be a canonical private IP network") from exc if ( network.version != 4 or not any(network.subnet_of(parent) for parent in RFC1918_NETWORKS) or not 24 <= network.prefixlen <= 28 ): raise SpecError( f"{label} must be an RFC1918 IPv4 /24 to /28 network" ) return str(network) def _port(value: int, label: str) -> int: if value < 1 or value > 65535: raise SpecError(f"{label} must be between 1 and 65535") return value