feat(deploy): add declarative installation workflow
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Deployment Installer / deployment-installer (push) Has been cancelled
Security Audit / security-audit (push) Has been cancelled

This commit is contained in:
2026-07-30 15:36:37 +02:00
parent fcb8296812
commit 82e836b720
15 changed files with 3654 additions and 0 deletions

View File

@@ -0,0 +1,482 @@
"""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