feat(deploy): add declarative installation workflow
This commit is contained in:
482
tools/deployment/govoplan_deploy/model.py
Normal file
482
tools/deployment/govoplan_deploy/model.py
Normal 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
|
||||
Reference in New Issue
Block a user