Implement supported ingress and TLS profiles
Dependency Audit / dependency-audit (push) Successful in 1m38s
Deployment Installer / deployment-installer (push) Successful in 5s
Security Audit / security-audit (push) Successful in 10m2s

This commit is contained in:
2026-08-03 01:15:06 +02:00
parent b40f1428fd
commit 2c515f73c2
12 changed files with 1174 additions and 37 deletions
+127 -4
View File
@@ -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(