"""Strict, secret-free contracts for managed Unraid deployments. This module intentionally models a small deployment vocabulary. It is not a Compose parser, a Docker API wrapper, or a generic remote-command interface. The controller derives filesystem paths and runtime arguments from this plan after it has accepted the immutable repository revision. """ from __future__ import annotations import posixpath import re from typing import Any, Literal import yaml from pydantic import ( BaseModel, ConfigDict, Field, field_validator, model_validator, ) _APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$") _REPOSITORY = re.compile(r"^jens/[a-z0-9][a-z0-9_.-]{0,96}$") _REVISION = re.compile(r"^[0-9a-f]{40}$") _VERSION = re.compile(r"^v?(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){2}(?:-[0-9A-Za-z.-]+)?$") _NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$") _DOMAIN = re.compile( r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$" ) _SECRET_REFERENCE = re.compile(r"^[A-Z][A-Z0-9_]{2,120}$") _SAFE_PATH = re.compile( r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$))(?:[A-Za-z0-9]|\.[A-Za-z0-9])[A-Za-z0-9_./-]{0,239}$" ) TRUSTED_APPCONFIG_ROOT = "/srv/example/appdata" class _StrictModel(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) class DeploymentTopologyV1(_StrictModel): """The only runtime shape accepted by V1. V1 is deliberately compose-only and always private behind the existing proxy network. A public host binding, Docker socket or host networking is outside this contract. """ kind: Literal["compose"] = "compose" compose_file: str = ".itworx/deployment.yml" compose_project: str = Field(min_length=2, max_length=63) containers: tuple[str, ...] = Field(min_length=1, max_length=8) proxy_network: str = Field(min_length=2, max_length=63) internal_port: int = Field(ge=1, le=65535) restart_policy: Literal["unless-stopped"] = "unless-stopped" build: RepositoryBuildV1 | None = None @field_validator("compose_file") @classmethod def safe_compose_file(cls, value: str) -> str: if not _SAFE_PATH.fullmatch(value) or not value.endswith((".yml", ".yaml")): raise ValueError("compose_file must be a safe repository-relative YAML path") return value @field_validator("compose_project", "proxy_network", "containers") @classmethod def safe_runtime_name(cls, value: str | tuple[str, ...]) -> str | tuple[str, ...]: values = value if isinstance(value, tuple) else (value,) if any(not _NAME.fullmatch(item) for item in values): raise ValueError("runtime names must use the bounded deployment identifier format") if len(set(values)) != len(values): raise ValueError("container names must be unique") return value class RepositoryBuildV1(_StrictModel): """A controller-revalidated repository build, never a caller shell step.""" context: str = "." dockerfile: str = "Dockerfile" @field_validator("context") @classmethod def safe_context(cls, value: str) -> str: if value != "." and not _SAFE_PATH.fullmatch(value): raise ValueError("build context must be a safe repository-relative path") return value @field_validator("dockerfile") @classmethod def safe_dockerfile(cls, value: str) -> str: if not _SAFE_PATH.fullmatch(value) or not value.endswith("Dockerfile"): raise ValueError("dockerfile must be a safe repository-relative Dockerfile path") return value DeploymentTopologyV1.model_rebuild() class ProxyHostV1(_StrictModel): hostname: str certificate_mode: Literal["existing", "managed"] = "managed" force_ssl: bool = True http2: bool = True upload_limit_mb: int = Field(default=256, ge=1, le=2048) read_timeout_seconds: int = Field(default=180, ge=15, le=900) @field_validator("hostname") @classmethod def safe_hostname(cls, value: str) -> str: normalized = value.lower().rstrip(".") if not _DOMAIN.fullmatch(normalized) or normalized.startswith("-"): raise ValueError("hostname must be a canonical DNS hostname") return normalized @model_validator(mode="after") def require_https_redirect(self) -> ProxyHostV1: if not self.force_ssl: raise ValueError("managed proxy hosts must force HTTPS") return self class OidcRoleMappingV1(_StrictModel): administrator_group: str = Field(min_length=1, max_length=120) support_agent_group: str = Field(min_length=1, max_length=120) read_only_group: str = Field(min_length=1, max_length=120) @field_validator("administrator_group", "support_agent_group", "read_only_group") @classmethod def safe_group_name(cls, value: str) -> str: if any(character in value for character in "\r\n\x00"): raise ValueError("role group names may not contain control characters") return value class OidcProvisioningV1(_StrictModel): provider: Literal["authentik"] = "authentik" application_slug: str = Field(min_length=2, max_length=63) redirect_path: str = "/signin-oidc" role_claim: str = Field(default="groups", min_length=1, max_length=80) client_secret_ref: str roles: OidcRoleMappingV1 @field_validator("application_slug") @classmethod def safe_application_slug(cls, value: str) -> str: if not _APP_ID.fullmatch(value): raise ValueError("application_slug must be a bounded lower-case identifier") return value @field_validator("client_secret_ref") @classmethod def secret_reference_only(cls, value: str) -> str: if not _SECRET_REFERENCE.fullmatch(value): raise ValueError("client_secret_ref must reference a stored AppOps secret, never a value") return value @field_validator("redirect_path") @classmethod def safe_redirect_path(cls, value: str) -> str: if not value.startswith("/") or "//" in value or any(character in value for character in "?#\\\r\n"): raise ValueError("redirect_path must be a relative HTTPS callback path") return value class DeploymentPlanV1(_StrictModel): """A validated, immutable deployment intent for one managed application.""" api_version: Literal["itworx.deployment/v1"] = "itworx.deployment/v1" application_id: str repository: str revision: str release_version: str image_repository: str image_tag: str topology: DeploymentTopologyV1 proxy: ProxyHostV1 | None = None oidc: OidcProvisioningV1 | None = None health_path: str = "/healthz" health_timeout_seconds: int = Field(default=180, ge=15, le=900) broker_secret_ref: str rollback_enabled: bool = True @field_validator("application_id") @classmethod def safe_app_id(cls, value: str) -> str: if not _APP_ID.fullmatch(value): raise ValueError("application_id must be a bounded lower-case identifier") return value @field_validator("repository") @classmethod def safe_repository(cls, value: str) -> str: normalized = value.lower() if not _REPOSITORY.fullmatch(normalized): raise ValueError("repository must be a canonical Jens repository") return normalized @field_validator("revision") @classmethod def immutable_revision(cls, value: str) -> str: normalized = value.lower() if not _REVISION.fullmatch(normalized): raise ValueError("revision must be a full lower-case 40-character Git SHA") return normalized @field_validator("release_version") @classmethod def semantic_version(cls, value: str) -> str: if not _VERSION.fullmatch(value): raise ValueError("release_version must be a semantic version") return value.removeprefix("v") @field_validator("image_repository") @classmethod def safe_image_repository(cls, value: str) -> str: normalized = value.lower() if not re.fullmatch(r"[a-z0-9][a-z0-9._/-]{1,180}", normalized): raise ValueError("image_repository is not a safe image identifier") return normalized @field_validator("image_tag") @classmethod def immutable_image_tag(cls, value: str) -> str: if value.lower() == "latest" or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", value): raise ValueError("image_tag must be an explicit non-latest tag") return value @field_validator("broker_secret_ref") @classmethod def broker_secret_reference_only(cls, value: str) -> str: if not _SECRET_REFERENCE.fullmatch(value): raise ValueError("broker_secret_ref must reference a stored AppOps secret, never a value") return value @field_validator("health_path") @classmethod def safe_health_path(cls, value: str) -> str: if not value.startswith("/") or "//" in value or any(character in value for character in "?#\\\r\n"): raise ValueError("health_path must be a relative path") return value @model_validator(mode="after") def validate_cross_field_contract(self) -> DeploymentPlanV1: if self.application_id not in self.topology.containers: raise ValueError("application_id must be one of the managed container names") if self.topology.compose_project != self.application_id: raise ValueError("compose_project must equal application_id in DeploymentPlanV1") if self.image_tag not in {self.release_version, self.revision[:12]}: raise ValueError("image_tag must be the release version or immutable revision prefix") if self.oidc and not self.proxy: raise ValueError("OIDC provisioning requires a managed HTTPS proxy host") return self @property def appdata_path(self) -> str: return f"{TRUSTED_APPCONFIG_ROOT}/{self.application_id}" @property def public_url(self) -> str | None: return f"https://{self.proxy.hostname}" if self.proxy else None def validate_compose_runtime_contract(plan: DeploymentPlanV1, raw_yaml: str) -> dict[str, Any]: """Validate a repository compose artifact against the narrow V1 policy. The result is suitable for evidence/audit storage and deliberately contains no Compose environment values. It rejects mounts, privileged flags and runtime capabilities that would turn a product deployment into a host control interface. """ try: document = yaml.safe_load(raw_yaml) except yaml.YAMLError as exc: raise ValueError("deployment compose YAML could not be parsed") from exc if not isinstance(document, dict) or set(document) - {"services", "networks", "name"}: raise ValueError("deployment compose only permits name, services and networks") services = document.get("services") if not isinstance(services, dict) or not services: raise ValueError("deployment compose must define services") expected = set(plan.topology.containers) observed = set(services) if observed != expected: raise ValueError("deployment compose services must exactly match the typed plan containers") for service_name, service in services.items(): if not isinstance(service, dict): raise TypeError("deployment service must be an object") forbidden = { "privileged", "devices", "pid", "ipc", "cap_add", "dockerfile", } present = forbidden.intersection(service) if present: raise ValueError(f"deployment service {service_name} uses forbidden runtime fields") build = service.get("build") if plan.topology.build is None: if build: raise ValueError("deployment service build is not declared by the typed plan") else: if isinstance(build, str): observed_build = {"context": build, "dockerfile": "Dockerfile"} elif isinstance(build, dict): observed_build = { "context": str(build.get("context") or "."), "dockerfile": str(build.get("dockerfile") or "Dockerfile"), } if set(build) - {"context", "dockerfile"}: raise ValueError("deployment service build contains unsupported fields") else: raise ValueError("deployment service build must be declared by the typed plan") expected_build = plan.topology.build.model_dump() if observed_build != expected_build: raise ValueError("deployment service build does not match the typed plan") if service.get("network_mode") in {"host", "service:host"}: raise ValueError("host networking is forbidden") if service.get("ports"): raise ValueError("published host ports are forbidden for managed proxy deployments") security_options = service.get("security_opt") or [] if set(security_options) - {"no-new-privileges:true"}: raise ValueError("deployment service uses unsupported security options") if service.get("read_only") is not True: raise ValueError("deployment services must use a read-only root filesystem") if service.get("cap_drop") != ["ALL"]: raise ValueError("deployment services must drop every Linux capability") if service.get("restart") != "unless-stopped": raise ValueError("deployment services must use restart: unless-stopped") image = str(service.get("image") or "") if image != f"{plan.image_repository}:{plan.image_tag}": raise ValueError("deployment service image must equal the immutable plan image") owned_bind_mounts = 0 for mount in service.get("volumes", []) or []: if isinstance(mount, dict) and mount.get("type") not in {None, "bind"}: raise ValueError("deployment services permit only application-owned bind mounts") source = mount.get("source") if isinstance(mount, dict) else str(mount).split(":", 1)[0] source_text = str(source or "") normalized_source = posixpath.normpath(source_text) if "docker.sock" in source_text: raise ValueError("docker socket and host-control mounts are forbidden") if ( not source_text.startswith("/") or normalized_source != source_text or source_text in {"/", "/proc", "/sys"} ): raise ValueError("only normalized application-owned bind mounts are permitted") if source_text != plan.appdata_path and not source_text.startswith(plan.appdata_path + "/"): raise ValueError("bind mounts must stay below the plan's owned appdata path") owned_bind_mounts += 1 if not owned_bind_mounts: raise ValueError("deployment services require an application-owned persistent bind mount") networks = service.get("networks") if networks != [plan.topology.proxy_network] and networks != {plan.topology.proxy_network: None}: raise ValueError("deployment services must use only the typed proxy network") networks = document.get("networks") if networks != {plan.topology.proxy_network: {"external": True}}: raise ValueError("deployment compose must reference exactly one existing proxy network") return { "services": sorted(observed), "proxyNetwork": plan.topology.proxy_network, "appdataPath": plan.appdata_path, "image": f"{plan.image_repository}:{plan.image_tag}", }