Implement supported ingress and TLS profiles
This commit is contained in:
@@ -14,6 +14,7 @@ from urllib.parse import urlsplit
|
||||
SCHEMA_VERSION = 1
|
||||
DEFAULT_GARAGE_IMAGE = "dxflrs/garage:v2.3.0"
|
||||
DEFAULT_LOAD_BALANCER_IMAGE = "haproxy:3.2.21-alpine"
|
||||
DEFAULT_INGRESS_IMAGE = "caddy:2.10.2-alpine"
|
||||
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}$")
|
||||
@@ -113,6 +114,16 @@ class ReplicaConfig:
|
||||
worker: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IngressConfig:
|
||||
mode: str
|
||||
image: str
|
||||
trusted_proxy_cidrs: tuple[str, ...]
|
||||
http_port: int
|
||||
https_port: int
|
||||
acme_email: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InstallationSpec:
|
||||
schema_version: int
|
||||
@@ -124,11 +135,13 @@ class InstallationSpec:
|
||||
release: ReleaseConfig
|
||||
components: ComponentConfig
|
||||
replicas: ReplicaConfig
|
||||
ingress: IngressConfig
|
||||
enabled_modules: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["enabled_modules"] = list(self.enabled_modules)
|
||||
value["ingress"]["trusted_proxy_cidrs"] = list(self.ingress.trusted_proxy_cidrs)
|
||||
return value
|
||||
|
||||
|
||||
@@ -145,6 +158,12 @@ def default_spec(
|
||||
storage_mode: str = "local",
|
||||
garage_image: str = DEFAULT_GARAGE_IMAGE,
|
||||
load_balancer_image: str = DEFAULT_LOAD_BALANCER_IMAGE,
|
||||
ingress_mode: str | None = None,
|
||||
ingress_image: str = DEFAULT_INGRESS_IMAGE,
|
||||
trusted_proxy_cidrs: tuple[str, ...] = (),
|
||||
ingress_http_port: int = 80,
|
||||
ingress_https_port: int = 443,
|
||||
acme_email: str = "",
|
||||
api_replicas: int = 1,
|
||||
web_replicas: int = 1,
|
||||
worker_replicas: int | None = None,
|
||||
@@ -218,6 +237,15 @@ def default_spec(
|
||||
"web": web_replicas,
|
||||
"worker": effective_worker_replicas,
|
||||
},
|
||||
"ingress": {
|
||||
"mode": ingress_mode
|
||||
or ("unconfigured" if profile == "self-hosted" else "local"),
|
||||
"image": ingress_image if ingress_mode == "managed" else "",
|
||||
"trusted_proxy_cidrs": list(trusted_proxy_cidrs),
|
||||
"http_port": ingress_http_port,
|
||||
"https_port": ingress_https_port,
|
||||
"acme_email": acme_email,
|
||||
},
|
||||
"enabled_modules": list(modules),
|
||||
}
|
||||
return parse_spec(raw)
|
||||
@@ -247,6 +275,7 @@ def parse_spec(raw: object) -> InstallationSpec:
|
||||
"release",
|
||||
"components",
|
||||
"replicas",
|
||||
"ingress",
|
||||
"enabled_modules",
|
||||
},
|
||||
"installation",
|
||||
@@ -281,6 +310,17 @@ def parse_spec(raw: object) -> InstallationSpec:
|
||||
release = _release(root.get("release"))
|
||||
components = _components(root.get("components"), profile=profile)
|
||||
replicas = _replicas(root.get("replicas"), components=components)
|
||||
ingress = _ingress(root.get("ingress"), profile=profile)
|
||||
if ingress.mode == "managed":
|
||||
try:
|
||||
ipaddress.ip_address(public_parts.hostname or "")
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise SpecError("managed ingress requires a DNS hostname in public_url")
|
||||
public_port = public_parts.port or 443
|
||||
if public_port != ingress.https_port:
|
||||
raise SpecError("managed ingress HTTPS port must match the public_url port")
|
||||
|
||||
enabled_raw = root.get("enabled_modules")
|
||||
if not isinstance(enabled_raw, list):
|
||||
@@ -307,6 +347,7 @@ def parse_spec(raw: object) -> InstallationSpec:
|
||||
release=release,
|
||||
components=components,
|
||||
replicas=replicas,
|
||||
ingress=ingress,
|
||||
enabled_modules=tuple(enabled_modules),
|
||||
)
|
||||
|
||||
@@ -344,18 +385,14 @@ def _release(raw: object) -> ReleaseConfig:
|
||||
raise SpecError(
|
||||
"release.manifest_sha256 must be a lowercase SHA-256 hex digest"
|
||||
)
|
||||
manifest_keyring_sha256 = _optional_string(
|
||||
value, "manifest_keyring_sha256"
|
||||
).lower()
|
||||
manifest_keyring_sha256 = _optional_string(value, "manifest_keyring_sha256").lower()
|
||||
if manifest_keyring_sha256 and not SHA256_PATTERN.fullmatch(
|
||||
manifest_keyring_sha256
|
||||
):
|
||||
raise SpecError(
|
||||
"release.manifest_keyring_sha256 must be a lowercase SHA-256 hex digest"
|
||||
)
|
||||
manifest_signature_key_id = _optional_string(
|
||||
value, "manifest_signature_key_id"
|
||||
)
|
||||
manifest_signature_key_id = _optional_string(value, "manifest_signature_key_id")
|
||||
if manifest_signature_key_id and not re.fullmatch(
|
||||
r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", manifest_signature_key_id
|
||||
):
|
||||
@@ -483,6 +520,84 @@ def _replicas(raw: object, *, components: ComponentConfig) -> ReplicaConfig:
|
||||
return replicas
|
||||
|
||||
|
||||
def _ingress(raw: object, *, profile: str) -> IngressConfig:
|
||||
if raw is None:
|
||||
return IngressConfig(
|
||||
mode="unconfigured" if profile == "self-hosted" else "local",
|
||||
image="",
|
||||
trusted_proxy_cidrs=(),
|
||||
http_port=80,
|
||||
https_port=443,
|
||||
acme_email="",
|
||||
)
|
||||
value = _mapping(raw, "ingress")
|
||||
_only_keys(
|
||||
value,
|
||||
{
|
||||
"mode",
|
||||
"image",
|
||||
"trusted_proxy_cidrs",
|
||||
"http_port",
|
||||
"https_port",
|
||||
"acme_email",
|
||||
},
|
||||
"ingress",
|
||||
)
|
||||
mode = _choice(
|
||||
value,
|
||||
"mode",
|
||||
{"local", "existing-proxy", "managed", "unconfigured"},
|
||||
)
|
||||
image = _optional_string(value, "image")
|
||||
raw_cidrs = value.get("trusted_proxy_cidrs", [])
|
||||
if not isinstance(raw_cidrs, list) or len(raw_cidrs) > 16:
|
||||
raise SpecError(
|
||||
"ingress.trusted_proxy_cidrs must be an array of at most 16 networks"
|
||||
)
|
||||
cidrs: list[str] = []
|
||||
for item in raw_cidrs:
|
||||
if not isinstance(item, str):
|
||||
raise SpecError("ingress.trusted_proxy_cidrs must contain strings")
|
||||
cidrs.append(_trusted_proxy_network(item))
|
||||
if len(set(cidrs)) != len(cidrs):
|
||||
raise SpecError("ingress.trusted_proxy_cidrs contains duplicates")
|
||||
http_port = _port(_integer(value, "http_port"), "ingress.http_port")
|
||||
https_port = _port(_integer(value, "https_port"), "ingress.https_port")
|
||||
if http_port == https_port:
|
||||
raise SpecError("ingress HTTP and HTTPS ports must differ")
|
||||
acme_email = _optional_string(value, "acme_email")
|
||||
if acme_email and (
|
||||
len(acme_email) > 254 or re.fullmatch(r"[^@\s]+@[^@\s]+", acme_email) is None
|
||||
):
|
||||
raise SpecError("ingress.acme_email must be a valid email address")
|
||||
if profile == "self-hosted" and mode == "local":
|
||||
raise SpecError(
|
||||
"self-hosted installations require existing-proxy or managed ingress"
|
||||
)
|
||||
if profile == "evaluation" and mode == "unconfigured":
|
||||
raise SpecError("evaluation installations cannot use unconfigured ingress")
|
||||
if mode == "managed":
|
||||
image = _image(image or DEFAULT_INGRESS_IMAGE, "ingress.image")
|
||||
if not acme_email:
|
||||
raise SpecError("managed ingress requires ingress.acme_email")
|
||||
elif image:
|
||||
raise SpecError("ingress.image is only valid for managed ingress")
|
||||
if mode == "existing-proxy" and not cidrs:
|
||||
raise SpecError("existing-proxy ingress requires a trusted proxy CIDR")
|
||||
if mode != "existing-proxy" and cidrs:
|
||||
raise SpecError("trusted proxy CIDRs are only valid for existing-proxy ingress")
|
||||
if mode != "managed" and acme_email:
|
||||
raise SpecError("ingress.acme_email is only valid for managed ingress")
|
||||
return IngressConfig(
|
||||
mode=mode,
|
||||
image=image,
|
||||
trusted_proxy_cidrs=tuple(cidrs),
|
||||
http_port=http_port,
|
||||
https_port=https_port,
|
||||
acme_email=acme_email,
|
||||
)
|
||||
|
||||
|
||||
def _service(
|
||||
raw: object,
|
||||
label: str,
|
||||
@@ -614,6 +729,32 @@ def _network(value: str, label: str) -> str:
|
||||
return str(network)
|
||||
|
||||
|
||||
def _trusted_proxy_network(value: str) -> str:
|
||||
try:
|
||||
network = ipaddress.ip_network(value.strip(), strict=True)
|
||||
except ValueError as exc:
|
||||
raise SpecError(
|
||||
"ingress.trusted_proxy_cidrs must contain canonical IP networks"
|
||||
) from exc
|
||||
if network.is_unspecified or network.is_multicast:
|
||||
raise SpecError("trusted proxy CIDR cannot be unspecified or multicast")
|
||||
if network.version == 4:
|
||||
if network.is_global and network.prefixlen != 32:
|
||||
raise SpecError("a public trusted proxy must be an exact IPv4 address")
|
||||
if not network.is_global and network.prefixlen < 24:
|
||||
raise SpecError(
|
||||
"a private trusted IPv4 proxy network must be /24 or narrower"
|
||||
)
|
||||
else:
|
||||
if network.is_global and network.prefixlen != 128:
|
||||
raise SpecError("a public trusted proxy must be an exact IPv6 address")
|
||||
if not network.is_global and network.prefixlen < 64:
|
||||
raise SpecError(
|
||||
"a private trusted IPv6 proxy network must be /64 or narrower"
|
||||
)
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user