Implement supported ingress and TLS profiles
This commit is contained in:
@@ -22,6 +22,8 @@ ENV_FILENAME = "secrets.env"
|
||||
COMPOSE_FILENAME = "compose.json"
|
||||
GARAGE_CONFIG_FILENAME = "garage.toml"
|
||||
LOAD_BALANCER_CONFIG_FILENAME = "load-balancer.cfg"
|
||||
CADDY_CONFIG_FILENAME = "Caddyfile"
|
||||
EXISTING_PROXY_FILENAME = "existing-proxy.json"
|
||||
PLAN_FILENAME = "plan.json"
|
||||
RECEIPT_FILENAME = "receipt.json"
|
||||
MANIFEST_FILENAME = "distribution-manifest.json"
|
||||
@@ -87,6 +89,8 @@ class BundlePaths:
|
||||
compose: Path
|
||||
garage_config: Path
|
||||
load_balancer_config: Path
|
||||
caddy_config: Path
|
||||
existing_proxy: Path
|
||||
plan: Path
|
||||
receipt: Path
|
||||
manifest: Path
|
||||
@@ -106,6 +110,8 @@ def bundle_paths(root: Path) -> BundlePaths:
|
||||
compose=resolved / COMPOSE_FILENAME,
|
||||
garage_config=resolved / GARAGE_CONFIG_FILENAME,
|
||||
load_balancer_config=resolved / LOAD_BALANCER_CONFIG_FILENAME,
|
||||
caddy_config=resolved / CADDY_CONFIG_FILENAME,
|
||||
existing_proxy=resolved / EXISTING_PROXY_FILENAME,
|
||||
plan=resolved / PLAN_FILENAME,
|
||||
receipt=resolved / RECEIPT_FILENAME,
|
||||
manifest=resolved / MANIFEST_FILENAME,
|
||||
@@ -455,6 +461,10 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
||||
"restart": "unless-stopped",
|
||||
"volumes": data_mounts,
|
||||
"networks": ["internal"],
|
||||
"read_only": True,
|
||||
"tmpfs": ["/tmp:rw,noexec,nosuid,size=64m"],
|
||||
"security_opt": ["no-new-privileges:true"],
|
||||
"cap_drop": ["ALL"],
|
||||
}
|
||||
if dependency_conditions:
|
||||
common_runtime["depends_on"] = dependency_conditions
|
||||
@@ -470,6 +480,10 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
||||
"restart": "no",
|
||||
"volumes": data_mounts,
|
||||
"networks": ["internal"],
|
||||
"read_only": True,
|
||||
"tmpfs": ["/tmp:rw,noexec,nosuid,size=64m"],
|
||||
"security_opt": ["no-new-privileges:true"],
|
||||
"cap_drop": ["ALL"],
|
||||
**({"depends_on": dependency_conditions} if dependency_conditions else {}),
|
||||
}
|
||||
services["api"] = {
|
||||
@@ -515,9 +529,13 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
||||
"restart": "unless-stopped",
|
||||
"scale": spec.replicas.web,
|
||||
"environment": {"GOVOPLAN_API_UPSTREAM": "http://load-balancer:8000"},
|
||||
"read_only": True,
|
||||
"tmpfs": ["/tmp:rw,noexec,nosuid,size=64m"],
|
||||
"security_opt": ["no-new-privileges:true"],
|
||||
"cap_drop": ["ALL"],
|
||||
"networks": ["internal"],
|
||||
}
|
||||
services["load-balancer"] = {
|
||||
load_balancer: dict[str, object] = {
|
||||
"image": spec.components.load_balancer.image,
|
||||
"restart": "unless-stopped",
|
||||
"healthcheck": {
|
||||
@@ -537,7 +555,6 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
||||
render_load_balancer_config(spec).encode("utf-8")
|
||||
).hexdigest()
|
||||
},
|
||||
"ports": [_published_port(spec.listen.address, spec.listen.port, 8080)],
|
||||
"read_only": True,
|
||||
"security_opt": ["no-new-privileges:true"],
|
||||
"volumes": [
|
||||
@@ -545,6 +562,53 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
||||
],
|
||||
"networks": ["internal"],
|
||||
}
|
||||
if spec.ingress.mode != "managed":
|
||||
load_balancer["ports"] = [
|
||||
_published_port(spec.listen.address, spec.listen.port, 8080)
|
||||
]
|
||||
services["load-balancer"] = load_balancer
|
||||
if spec.ingress.mode == "managed":
|
||||
services["ingress"] = {
|
||||
"image": spec.ingress.image,
|
||||
"restart": "unless-stopped",
|
||||
"command": [
|
||||
"caddy",
|
||||
"run",
|
||||
"--config",
|
||||
"/etc/caddy/Caddyfile",
|
||||
"--adapter",
|
||||
"caddyfile",
|
||||
],
|
||||
"healthcheck": {
|
||||
"test": [
|
||||
"CMD",
|
||||
"caddy",
|
||||
"validate",
|
||||
"--config",
|
||||
"/etc/caddy/Caddyfile",
|
||||
"--adapter",
|
||||
"caddyfile",
|
||||
],
|
||||
"interval": "30s",
|
||||
"timeout": "5s",
|
||||
"retries": 3,
|
||||
},
|
||||
"ports": [
|
||||
_published_port("0.0.0.0", spec.ingress.http_port, 8080),
|
||||
_published_port("0.0.0.0", spec.ingress.https_port, 8443),
|
||||
],
|
||||
"read_only": True,
|
||||
"tmpfs": ["/tmp:rw,noexec,nosuid,size=64m"],
|
||||
"security_opt": ["no-new-privileges:true"],
|
||||
"cap_drop": ["ALL"],
|
||||
"volumes": [
|
||||
f"./{CADDY_CONFIG_FILENAME}:/etc/caddy/Caddyfile:ro",
|
||||
"caddy-data:/data",
|
||||
"caddy-config:/config",
|
||||
],
|
||||
"networks": ["internal"],
|
||||
"depends_on": {"load-balancer": {"condition": "service_healthy"}},
|
||||
}
|
||||
if spec.components.redis.mode != "disabled":
|
||||
services["worker"] = {
|
||||
**common_runtime,
|
||||
@@ -606,6 +670,9 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]:
|
||||
if spec.components.storage.mode == "garage":
|
||||
volumes["garage-meta"] = {}
|
||||
volumes["garage-data"] = {}
|
||||
if spec.ingress.mode == "managed":
|
||||
volumes["caddy-data"] = {}
|
||||
volumes["caddy-config"] = {}
|
||||
|
||||
return {
|
||||
"name": spec.installation_id,
|
||||
@@ -642,6 +709,31 @@ api_bind_addr = "[::]:3903"
|
||||
|
||||
def render_load_balancer_config(spec: InstallationSpec) -> str:
|
||||
health_host = urlsplit(spec.public_url).hostname or "localhost"
|
||||
trusted_proxy_cidrs = (
|
||||
(spec.network_subnet,)
|
||||
if spec.ingress.mode == "managed"
|
||||
else spec.ingress.trusted_proxy_cidrs
|
||||
if spec.ingress.mode == "existing-proxy"
|
||||
else ()
|
||||
)
|
||||
trusted_acl = (
|
||||
" acl trusted_forward_proxy src " + " ".join(trusted_proxy_cidrs) + "\n"
|
||||
if trusted_proxy_cidrs
|
||||
else ""
|
||||
)
|
||||
forwarded_rules = (
|
||||
" http-request set-var(txn.forwarded_proto) req.hdr(X-Forwarded-Proto) if trusted_forward_proxy\n"
|
||||
" http-request del-header X-Forwarded-Proto\n"
|
||||
" http-request set-header X-Forwarded-Proto https if trusted_forward_proxy { var(txn.forwarded_proto) -m str https }\n"
|
||||
" http-request set-header X-Forwarded-Proto http unless { var(txn.forwarded_proto) -m str https }\n"
|
||||
" http-request del-header X-Forwarded-For unless trusted_forward_proxy\n"
|
||||
if trusted_proxy_cidrs
|
||||
else (
|
||||
" http-request del-header X-Forwarded-Proto\n"
|
||||
" http-request set-header X-Forwarded-Proto http\n"
|
||||
" http-request del-header X-Forwarded-For\n"
|
||||
)
|
||||
)
|
||||
return f"""global
|
||||
log stdout format raw local0
|
||||
maxconn 4096
|
||||
@@ -669,6 +761,9 @@ resolvers docker
|
||||
|
||||
frontend public_web
|
||||
bind :8080
|
||||
{trusted_acl}{forwarded_rules} option forwardfor
|
||||
http-request del-header X-Forwarded-Host
|
||||
http-request set-header X-Forwarded-Host %[req.hdr(host)]
|
||||
default_backend web_replicas
|
||||
|
||||
backend web_replicas
|
||||
@@ -690,6 +785,52 @@ backend api_replicas
|
||||
"""
|
||||
|
||||
|
||||
def render_caddy_config(spec: InstallationSpec) -> str:
|
||||
if spec.ingress.mode != "managed":
|
||||
return "# Managed ingress is not selected.\n"
|
||||
hostname = urlsplit(spec.public_url).hostname or ""
|
||||
return f"""{{
|
||||
admin off
|
||||
email {spec.ingress.acme_email}
|
||||
http_port 8080
|
||||
https_port 8443
|
||||
}}
|
||||
|
||||
{hostname} {{
|
||||
encode zstd gzip
|
||||
header {{
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
}}
|
||||
reverse_proxy load-balancer:8080 {{
|
||||
header_up X-Forwarded-Proto https
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def render_existing_proxy_contract(spec: InstallationSpec) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"mode": spec.ingress.mode,
|
||||
"public_url": spec.public_url,
|
||||
"upstream": (
|
||||
f"http://{spec.listen.address}:{spec.listen.port}"
|
||||
if spec.ingress.mode == "existing-proxy"
|
||||
else None
|
||||
),
|
||||
"trusted_proxy_cidrs": list(spec.ingress.trusted_proxy_cidrs),
|
||||
"required_headers": {
|
||||
"Host": urlsplit(spec.public_url).hostname or "",
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-For": "client, proxy chain",
|
||||
},
|
||||
"health_paths": {
|
||||
"load_balancer": "/health",
|
||||
"api_readiness": "/health/ready",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def service_names(spec: InstallationSpec) -> tuple[str, ...]:
|
||||
return tuple(render_compose(spec)["services"].keys())
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ from .bundle import (
|
||||
initial_secrets,
|
||||
read_env,
|
||||
reconcile_runtime_environment,
|
||||
render_caddy_config,
|
||||
render_compose,
|
||||
render_existing_proxy_contract,
|
||||
render_garage_config,
|
||||
render_load_balancer_config,
|
||||
service_names,
|
||||
@@ -54,7 +56,9 @@ from .distribution import (
|
||||
from .model import (
|
||||
ComponentConfig,
|
||||
DEFAULT_GARAGE_IMAGE,
|
||||
DEFAULT_INGRESS_IMAGE,
|
||||
DEFAULT_LOAD_BALANCER_IMAGE,
|
||||
IngressConfig,
|
||||
InstallationSpec,
|
||||
ListenConfig,
|
||||
ReplicaConfig,
|
||||
@@ -320,6 +324,26 @@ def _configuration_arguments(
|
||||
default=default(DEFAULT_LOAD_BALANCER_IMAGE),
|
||||
help="HAProxy image used by the managed local load balancer.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ingress",
|
||||
choices=("local", "existing-proxy", "managed", "unconfigured"),
|
||||
default=default(None),
|
||||
help="Public route boundary; self-hosted requires existing-proxy or managed.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ingress-image",
|
||||
default=default(DEFAULT_INGRESS_IMAGE),
|
||||
help="Caddy image used by managed ingress.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trusted-proxy-cidr",
|
||||
action="append",
|
||||
default=None,
|
||||
help="Exact source CIDR trusted to supply forwarded headers; repeatable.",
|
||||
)
|
||||
parser.add_argument("--acme-email", default=default(""))
|
||||
parser.add_argument("--ingress-http-port", type=int, default=default(80))
|
||||
parser.add_argument("--ingress-https-port", type=int, default=default(443))
|
||||
parser.add_argument(
|
||||
"--api-replicas",
|
||||
type=int,
|
||||
@@ -409,6 +433,12 @@ def _init(args: argparse.Namespace) -> int:
|
||||
storage_mode=args.storage,
|
||||
garage_image=args.garage_image,
|
||||
load_balancer_image=args.load_balancer_image,
|
||||
ingress_mode=args.ingress,
|
||||
ingress_image=args.ingress_image,
|
||||
trusted_proxy_cidrs=tuple(args.trusted_proxy_cidr or ()),
|
||||
ingress_http_port=args.ingress_http_port,
|
||||
ingress_https_port=args.ingress_https_port,
|
||||
acme_email=args.acme_email,
|
||||
api_replicas=args.api_replicas,
|
||||
web_replicas=args.web_replicas,
|
||||
worker_replicas=args.worker_replicas,
|
||||
@@ -530,6 +560,7 @@ def _apply(args: argparse.Namespace) -> int:
|
||||
"components.mail.image.",
|
||||
"components.storage.image.",
|
||||
"components.load_balancer.image.",
|
||||
"ingress.image.",
|
||||
"release.manifest",
|
||||
"modules.image_composition",
|
||||
)
|
||||
@@ -600,7 +631,14 @@ def _apply(args: argparse.Namespace) -> int:
|
||||
_run([*compose, "stop", "web"], cwd=paths.root)
|
||||
runtime_services = [
|
||||
name
|
||||
for name in ("api", "web", "load-balancer", "worker", "scheduler")
|
||||
for name in (
|
||||
"api",
|
||||
"web",
|
||||
"load-balancer",
|
||||
"worker",
|
||||
"scheduler",
|
||||
"ingress",
|
||||
)
|
||||
if name in service_names(spec)
|
||||
]
|
||||
_run(
|
||||
@@ -794,7 +832,22 @@ def _verify_release(args: argparse.Namespace) -> int:
|
||||
image=str(dependency_images["load_balancer"]),
|
||||
),
|
||||
)
|
||||
adopted = parse_spec(replace(spec, release=release, components=components).to_dict())
|
||||
ingress = replace(
|
||||
spec.ingress,
|
||||
image=(
|
||||
str(dependency_images["managed_ingress"])
|
||||
if spec.ingress.mode == "managed"
|
||||
else spec.ingress.image
|
||||
),
|
||||
)
|
||||
adopted = parse_spec(
|
||||
replace(
|
||||
spec,
|
||||
release=release,
|
||||
components=components,
|
||||
ingress=ingress,
|
||||
).to_dict()
|
||||
)
|
||||
ensure_private_directory(paths.root)
|
||||
atomic_write(paths.manifest, encoded_manifest, mode=0o644)
|
||||
atomic_write(
|
||||
@@ -862,11 +915,12 @@ def _selected_dependency_images(
|
||||
names.append("test_mail")
|
||||
if spec.components.storage.mode == "garage":
|
||||
names.append("garage")
|
||||
if spec.ingress.mode == "managed":
|
||||
names.append("managed_ingress")
|
||||
missing = [name for name in names if not isinstance(available.get(name), str)]
|
||||
if missing:
|
||||
raise DistributionError(
|
||||
"distribution is missing selected dependency images: "
|
||||
+ ", ".join(missing)
|
||||
"distribution is missing selected dependency images: " + ", ".join(missing)
|
||||
)
|
||||
return {name: str(available[name]) for name in names}
|
||||
|
||||
@@ -997,6 +1051,11 @@ def _deployment_receipt(
|
||||
"address": spec.listen.address,
|
||||
"port": spec.listen.port,
|
||||
},
|
||||
"ingress": {
|
||||
"mode": spec.ingress.mode,
|
||||
"http_port": spec.ingress.http_port,
|
||||
"https_port": spec.ingress.https_port,
|
||||
},
|
||||
"management": {
|
||||
"mode": "govoplan-deploy",
|
||||
"agent": "cli",
|
||||
@@ -1080,6 +1139,41 @@ def _updated_spec(
|
||||
)
|
||||
),
|
||||
)
|
||||
ingress_mode = args.ingress or current.ingress.mode
|
||||
ingress = IngressConfig(
|
||||
mode=ingress_mode,
|
||||
image=(args.ingress_image or current.ingress.image or DEFAULT_INGRESS_IMAGE)
|
||||
if ingress_mode == "managed"
|
||||
else "",
|
||||
trusted_proxy_cidrs=tuple(
|
||||
(
|
||||
args.trusted_proxy_cidr
|
||||
if args.trusted_proxy_cidr is not None
|
||||
else current.ingress.trusted_proxy_cidrs
|
||||
)
|
||||
if ingress_mode == "existing-proxy"
|
||||
else ()
|
||||
),
|
||||
http_port=(
|
||||
args.ingress_http_port
|
||||
if args.ingress_http_port is not None
|
||||
else current.ingress.http_port
|
||||
),
|
||||
https_port=(
|
||||
args.ingress_https_port
|
||||
if args.ingress_https_port is not None
|
||||
else current.ingress.https_port
|
||||
),
|
||||
acme_email=(
|
||||
(
|
||||
args.acme_email
|
||||
if args.acme_email is not None
|
||||
else current.ingress.acme_email
|
||||
)
|
||||
if ingress_mode == "managed"
|
||||
else ""
|
||||
),
|
||||
)
|
||||
value = replace(
|
||||
current,
|
||||
installation_id=args.installation_id or current.installation_id,
|
||||
@@ -1092,6 +1186,7 @@ def _updated_spec(
|
||||
release=release,
|
||||
components=components,
|
||||
replicas=replicas,
|
||||
ingress=ingress,
|
||||
enabled_modules=modules,
|
||||
)
|
||||
return parse_spec(value.to_dict())
|
||||
@@ -1137,6 +1232,16 @@ def _write_bundle(
|
||||
render_load_balancer_config(spec).encode("utf-8"),
|
||||
mode=0o644,
|
||||
)
|
||||
atomic_write(
|
||||
paths.caddy_config,
|
||||
render_caddy_config(spec).encode("utf-8"),
|
||||
mode=0o644,
|
||||
)
|
||||
atomic_write(
|
||||
paths.existing_proxy,
|
||||
canonical_json(render_existing_proxy_contract(spec)),
|
||||
mode=0o644,
|
||||
)
|
||||
atomic_write(
|
||||
paths.garage_config,
|
||||
render_garage_config().encode("utf-8"),
|
||||
@@ -1169,6 +1274,24 @@ def _prompt_configuration(args: argparse.Namespace) -> None:
|
||||
if args.profile == "self-hosted" and args.public_url.startswith("http://"):
|
||||
args.public_url = "https://govoplan.example.org"
|
||||
args.public_url = _prompt("Public URL", args.public_url)
|
||||
if args.profile == "self-hosted":
|
||||
args.ingress = _prompt_choice(
|
||||
"Public ingress",
|
||||
args.ingress or "existing-proxy",
|
||||
("existing-proxy", "managed"),
|
||||
)
|
||||
if args.ingress == "existing-proxy":
|
||||
current = (args.trusted_proxy_cidr or ["127.0.0.1/32"])[0]
|
||||
args.trusted_proxy_cidr = [
|
||||
_prompt("Trusted reverse-proxy source CIDR", current)
|
||||
]
|
||||
else:
|
||||
args.acme_email = args.acme_email or _prompt(
|
||||
"ACME account email",
|
||||
"admin@example.org",
|
||||
)
|
||||
else:
|
||||
args.ingress = args.ingress or "local"
|
||||
args.postgres = _prompt_choice("PostgreSQL", args.postgres, ("managed", "external"))
|
||||
if args.postgres == "external" and not args.database_url:
|
||||
args.database_url = getpass.getpass(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -9,17 +9,24 @@ from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Callable, Mapping, Sequence
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from .bundle import (
|
||||
BundlePaths,
|
||||
canonical_json,
|
||||
digest_json,
|
||||
environment_fingerprint,
|
||||
read_env,
|
||||
render_caddy_config,
|
||||
render_compose,
|
||||
render_existing_proxy_contract,
|
||||
service_names,
|
||||
)
|
||||
from .distribution import (
|
||||
@@ -178,6 +185,7 @@ def build_plan(
|
||||
|
||||
def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ...]:
|
||||
checks: list[Check] = []
|
||||
checks.extend(_ingress_configuration_checks(spec, paths))
|
||||
images = {
|
||||
"release.api_image": spec.release.api_image,
|
||||
"release.web_image": spec.release.web_image,
|
||||
@@ -191,6 +199,8 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
|
||||
if spec.components.storage.mode == "garage":
|
||||
images["components.storage.image"] = spec.components.storage.image
|
||||
images["components.load_balancer.image"] = spec.components.load_balancer.image
|
||||
if spec.ingress.mode == "managed":
|
||||
images["ingress.image"] = spec.ingress.image
|
||||
|
||||
for label, image in images.items():
|
||||
if image_is_unpublished(image):
|
||||
@@ -340,6 +350,88 @@ def static_checks(spec: InstallationSpec, paths: BundlePaths) -> tuple[Check, ..
|
||||
return tuple(checks)
|
||||
|
||||
|
||||
def _ingress_configuration_checks(
|
||||
spec: InstallationSpec,
|
||||
paths: BundlePaths,
|
||||
) -> tuple[Check, ...]:
|
||||
if spec.ingress.mode == "unconfigured":
|
||||
return (
|
||||
Check(
|
||||
"ingress.configuration",
|
||||
"error" if spec.profile == "self-hosted" else "warning",
|
||||
"No supported public ingress boundary is configured.",
|
||||
"Select managed ingress or an existing reverse proxy before apply.",
|
||||
),
|
||||
)
|
||||
checks = [
|
||||
Check(
|
||||
"ingress.configuration",
|
||||
"ok",
|
||||
f"Ingress mode {spec.ingress.mode!r} has a bounded configuration.",
|
||||
)
|
||||
]
|
||||
if spec.ingress.mode == "managed":
|
||||
checks.append(
|
||||
_artifact_check(
|
||||
"ingress.managed_config",
|
||||
paths.caddy_config,
|
||||
render_caddy_config(spec).encode("utf-8"),
|
||||
"Managed ingress configuration",
|
||||
)
|
||||
)
|
||||
ingress = render_compose(spec)["services"].get("ingress", {})
|
||||
volumes = ingress.get("volumes", []) if isinstance(ingress, dict) else []
|
||||
persistent = "caddy-data:/data" in volumes and "caddy-config:/config" in volumes
|
||||
checks.append(
|
||||
Check(
|
||||
"ingress.certificate_state",
|
||||
"ok" if persistent else "error",
|
||||
(
|
||||
"Managed certificate and renewal state uses persistent private volumes."
|
||||
if persistent
|
||||
else "Managed certificate state is not persistent."
|
||||
),
|
||||
"Restore the caddy-data and caddy-config volume bindings."
|
||||
if not persistent
|
||||
else "",
|
||||
)
|
||||
)
|
||||
elif spec.ingress.mode == "existing-proxy":
|
||||
checks.append(
|
||||
_artifact_check(
|
||||
"ingress.existing_proxy_contract",
|
||||
paths.existing_proxy,
|
||||
canonical_json(render_existing_proxy_contract(spec)),
|
||||
"Existing reverse-proxy contract",
|
||||
)
|
||||
)
|
||||
return tuple(checks)
|
||||
|
||||
|
||||
def _artifact_check(
|
||||
check_id: str,
|
||||
path: Path,
|
||||
expected: bytes,
|
||||
label: str,
|
||||
) -> Check:
|
||||
try:
|
||||
actual = path.read_bytes()
|
||||
except OSError as exc:
|
||||
return Check(
|
||||
check_id,
|
||||
"error",
|
||||
f"{label} is unavailable: {exc}",
|
||||
"Re-render the installation bundle.",
|
||||
)
|
||||
matches = actual == expected
|
||||
return Check(
|
||||
check_id,
|
||||
"ok" if matches else "error",
|
||||
f"{label} {'matches' if matches else 'does not match'} the installation specification.",
|
||||
"Re-render the installation bundle before apply." if not matches else "",
|
||||
)
|
||||
|
||||
|
||||
def _distribution_checks(
|
||||
spec: InstallationSpec,
|
||||
paths: BundlePaths,
|
||||
@@ -379,7 +471,9 @@ def _distribution_checks(
|
||||
maximum_bytes=MAX_MANIFEST_BYTES,
|
||||
)
|
||||
if manifest_digest != spec.release.manifest_sha256:
|
||||
raise DistributionError("stored manifest digest does not match installation")
|
||||
raise DistributionError(
|
||||
"stored manifest digest does not match installation"
|
||||
)
|
||||
keyring_digest = file_sha256(
|
||||
paths.keyring,
|
||||
maximum_bytes=MAX_KEYRING_BYTES,
|
||||
@@ -400,7 +494,9 @@ def _distribution_checks(
|
||||
expected_channel=spec.release.channel,
|
||||
)
|
||||
if key_id != spec.release.manifest_signature_key_id:
|
||||
raise DistributionError("verified signature key does not match installation")
|
||||
raise DistributionError(
|
||||
"verified signature key does not match installation"
|
||||
)
|
||||
verify_manifest_binding(
|
||||
manifest,
|
||||
channel=spec.release.channel,
|
||||
@@ -461,6 +557,8 @@ def _selected_dependency_images(spec: InstallationSpec) -> dict[str, str]:
|
||||
values["test_mail"] = spec.components.mail.image
|
||||
if spec.components.storage.mode == "garage":
|
||||
values["garage"] = spec.components.storage.image
|
||||
if spec.ingress.mode == "managed":
|
||||
values["managed_ingress"] = spec.ingress.image
|
||||
return values
|
||||
|
||||
|
||||
@@ -471,6 +569,7 @@ def host_checks(
|
||||
command_runner: CommandRunner | None = None,
|
||||
) -> tuple[Check, ...]:
|
||||
checks: list[Check] = []
|
||||
runner = command_runner or _run_command
|
||||
machine = platform.machine().lower()
|
||||
supported = machine in {"x86_64", "amd64", "aarch64", "arm64"}
|
||||
checks.append(
|
||||
@@ -543,7 +642,6 @@ def host_checks(
|
||||
)
|
||||
)
|
||||
else:
|
||||
runner = command_runner or _run_command
|
||||
result = runner((docker, "compose", "version", "--short"), paths.root)
|
||||
checks.append(
|
||||
Check(
|
||||
@@ -608,30 +706,245 @@ def host_checks(
|
||||
)
|
||||
|
||||
receipt = _read_receipt(paths.receipt)
|
||||
checks.extend(
|
||||
_ingress_host_checks(
|
||||
spec,
|
||||
paths,
|
||||
receipt=receipt,
|
||||
docker=docker,
|
||||
command_runner=runner,
|
||||
)
|
||||
)
|
||||
return tuple(checks)
|
||||
|
||||
|
||||
def _ingress_host_checks(
|
||||
spec: InstallationSpec,
|
||||
paths: BundlePaths,
|
||||
*,
|
||||
receipt: Mapping[str, object],
|
||||
docker: str | None,
|
||||
command_runner: CommandRunner,
|
||||
) -> tuple[Check, ...]:
|
||||
checks: list[Check] = []
|
||||
applied = bool(receipt)
|
||||
if spec.ingress.mode in {"managed", "existing-proxy"}:
|
||||
public = urlsplit(spec.public_url)
|
||||
host = public.hostname or ""
|
||||
port = public.port or 443
|
||||
checks.append(_dns_resolution_check(host, port))
|
||||
if spec.ingress.mode == "managed" and not applied:
|
||||
checks.append(
|
||||
Check(
|
||||
"ingress.tls",
|
||||
"warning",
|
||||
"TLS issuance will be verified after managed ingress starts.",
|
||||
"Ensure public DNS resolves to this host and ports 80/443 are reachable.",
|
||||
)
|
||||
)
|
||||
checks.append(
|
||||
Check(
|
||||
"ingress.public_route",
|
||||
"warning",
|
||||
"Public-route health will be verified after managed ingress starts.",
|
||||
"Permit inbound HTTP and HTTPS through the host firewall and upstream NAT.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks.append(_tls_validity_check(host, port))
|
||||
checks.append(
|
||||
_public_route_check(
|
||||
spec.public_url,
|
||||
require_ready=applied,
|
||||
)
|
||||
)
|
||||
|
||||
previous_ingress = receipt.get("ingress")
|
||||
desired_ingress = {
|
||||
"mode": spec.ingress.mode,
|
||||
"http_port": spec.ingress.http_port,
|
||||
"https_port": spec.ingress.https_port,
|
||||
}
|
||||
previous_listen = receipt.get("listen")
|
||||
desired_listen = {
|
||||
"address": spec.listen.address,
|
||||
"port": spec.listen.port,
|
||||
}
|
||||
if not receipt or previous_listen != desired_listen:
|
||||
available = _port_available(spec.listen.address, spec.listen.port)
|
||||
if spec.ingress.mode == "managed":
|
||||
if not applied or previous_ingress != desired_ingress:
|
||||
for label, port in (
|
||||
("http", spec.ingress.http_port),
|
||||
("https", spec.ingress.https_port),
|
||||
):
|
||||
checks.append(
|
||||
_available_port_check(
|
||||
f"host.ingress_{label}_port",
|
||||
"0.0.0.0",
|
||||
port,
|
||||
)
|
||||
)
|
||||
elif not applied or previous_listen != desired_listen:
|
||||
checks.append(
|
||||
Check(
|
||||
_available_port_check(
|
||||
"host.listen_port",
|
||||
"ok" if available else "error",
|
||||
(
|
||||
f"Listen endpoint {spec.listen.address}:{spec.listen.port} is available."
|
||||
if available
|
||||
else f"Listen endpoint {spec.listen.address}:{spec.listen.port} is already in use."
|
||||
),
|
||||
"Choose another listen port or stop the conflicting service."
|
||||
if not available
|
||||
else "",
|
||||
spec.listen.address,
|
||||
spec.listen.port,
|
||||
)
|
||||
)
|
||||
|
||||
if applied:
|
||||
checks.append(
|
||||
_local_upstream_check(
|
||||
spec,
|
||||
paths,
|
||||
docker=docker,
|
||||
command_runner=command_runner,
|
||||
)
|
||||
)
|
||||
return tuple(checks)
|
||||
|
||||
|
||||
def _available_port_check(check_id: str, address: str, port: int) -> Check:
|
||||
available = _port_available(address, port)
|
||||
return Check(
|
||||
check_id,
|
||||
"ok" if available else "error",
|
||||
(
|
||||
f"Listen endpoint {address}:{port} is available."
|
||||
if available
|
||||
else f"Listen endpoint {address}:{port} is already in use."
|
||||
),
|
||||
"Choose another port or stop the conflicting service." if not available else "",
|
||||
)
|
||||
|
||||
|
||||
def _dns_resolution_check(host: str, port: int) -> Check:
|
||||
try:
|
||||
results = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
addresses = sorted({str(item[4][0]) for item in results})
|
||||
except OSError as exc:
|
||||
return Check(
|
||||
"ingress.dns",
|
||||
"error",
|
||||
f"Public hostname {host!r} does not resolve: {exc}",
|
||||
"Publish public A/AAAA records before apply.",
|
||||
)
|
||||
return Check(
|
||||
"ingress.dns",
|
||||
"ok",
|
||||
f"Public hostname {host!r} resolves to {', '.join(addresses[:8])}.",
|
||||
)
|
||||
|
||||
|
||||
def _tls_validity_check(host: str, port: int) -> Check:
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
with socket.create_connection((host, port), timeout=3.0) as connection:
|
||||
with context.wrap_socket(connection, server_hostname=host) as secured:
|
||||
certificate = secured.getpeercert()
|
||||
expires = str(certificate.get("notAfter") or "")
|
||||
remaining_seconds = ssl.cert_time_to_seconds(expires) - time.time()
|
||||
except (OSError, ValueError, ssl.SSLError) as exc:
|
||||
return Check(
|
||||
"ingress.tls",
|
||||
"error",
|
||||
f"Public TLS validation failed for {host}:{port}: {exc}",
|
||||
"Correct certificate issuance, trust chain, hostname, and public routing.",
|
||||
)
|
||||
remaining_days = int(remaining_seconds // 86400)
|
||||
level = "ok" if remaining_days >= 21 else "warning"
|
||||
return Check(
|
||||
"ingress.tls",
|
||||
level,
|
||||
f"Public TLS certificate is valid for approximately {remaining_days} more day(s).",
|
||||
"Verify automated certificate renewal immediately."
|
||||
if level == "warning"
|
||||
else "",
|
||||
)
|
||||
|
||||
|
||||
def _public_route_check(public_url: str, *, require_ready: bool) -> Check:
|
||||
target = public_url.rstrip("/") + "/health/ready"
|
||||
status: int | None = None
|
||||
try:
|
||||
request = Request(target, headers={"User-Agent": "govoplan-deploy/doctor"})
|
||||
with urlopen(request, timeout=4.0) as response:
|
||||
status = response.status
|
||||
except HTTPError as exc:
|
||||
status = exc.code
|
||||
except (OSError, URLError, ValueError) as exc:
|
||||
return Check(
|
||||
"ingress.public_route",
|
||||
"error",
|
||||
f"Public route is unreachable: {exc}",
|
||||
"Check external DNS, firewall/NAT, reverse-proxy routing, and TLS.",
|
||||
)
|
||||
acceptable = status == 200 if require_ready else status not in {400, 404, 421}
|
||||
return Check(
|
||||
"ingress.public_route",
|
||||
"ok" if acceptable else "error",
|
||||
f"Public readiness route returned HTTP {status}.",
|
||||
(
|
||||
"Route the configured hostname and /health/ready path to the generated upstream."
|
||||
if not acceptable
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _local_upstream_check(
|
||||
spec: InstallationSpec,
|
||||
paths: BundlePaths,
|
||||
*,
|
||||
docker: str | None,
|
||||
command_runner: CommandRunner,
|
||||
) -> Check:
|
||||
if docker is None:
|
||||
return Check(
|
||||
"ingress.local_upstream",
|
||||
"error",
|
||||
"Local upstream health cannot be checked without Docker.",
|
||||
"Restore Docker access and rerun doctor.",
|
||||
)
|
||||
argv = (
|
||||
docker,
|
||||
"compose",
|
||||
"--env-file",
|
||||
str(paths.env),
|
||||
"--project-name",
|
||||
spec.installation_id,
|
||||
"--file",
|
||||
str(paths.compose),
|
||||
"exec",
|
||||
"--no-TTY",
|
||||
"load-balancer",
|
||||
"wget",
|
||||
"-qO-",
|
||||
"http://127.0.0.1:8080/health",
|
||||
)
|
||||
try:
|
||||
result = command_runner(argv, paths.root)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return Check(
|
||||
"ingress.local_upstream",
|
||||
"error",
|
||||
f"Local upstream health probe failed: {exc}",
|
||||
"Inspect the load-balancer and WebUI service health.",
|
||||
)
|
||||
return Check(
|
||||
"ingress.local_upstream",
|
||||
"ok" if result.returncode == 0 else "error",
|
||||
(
|
||||
"The generated local upstream is healthy."
|
||||
if result.returncode == 0
|
||||
else "The generated local upstream health probe failed."
|
||||
),
|
||||
"Inspect load-balancer and WebUI health before exposing the route."
|
||||
if result.returncode != 0
|
||||
else "",
|
||||
)
|
||||
|
||||
|
||||
def _read_receipt(path: Path) -> Mapping[str, object]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
|
||||
@@ -25,6 +25,10 @@ _BUNDLE_FILES = (
|
||||
"compose.json",
|
||||
"garage.toml",
|
||||
"load-balancer.cfg",
|
||||
"Caddyfile",
|
||||
"existing-proxy.json",
|
||||
"distribution-manifest.json",
|
||||
"distribution-keyring.json",
|
||||
"receipt.json",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user