Implement supported ingress and TLS profiles
This commit is contained in:
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user