diff --git a/docs/gateway-api.md b/docs/gateway-api.md index 73c57e5..440549b 100644 --- a/docs/gateway-api.md +++ b/docs/gateway-api.md @@ -31,8 +31,9 @@ stack-added listeners). For a spec with an `http-proxy` section, `stack manage start` creates: -1. **An HTTPS listener on `stack-gateway`** for the deployment's `host-name`, named `-https`, - referencing a certificate secret named `-tls`. cert-manager notices the new listener on the +1. **An HTTPS listener on `stack-gateway`** for the deployment's `host-name`, named after the hostname + (`app.example.com` gives `stack-app-example-com-https`), referencing a certificate secret named the same way + (`stack-app-example-com-tls`). cert-manager notices the new listener on the annotated Gateway and obtains a Let's Encrypt certificate for the hostname over ACME HTTP-01 (solving the challenge with a temporary HTTPRoute on the Gateway's HTTP listener). If an existing HTTPS listener already covers the hostname — in particular a machine-provisioned wildcard listener (`*.example.com`, certificate @@ -47,6 +48,13 @@ For a spec with an `http-proxy` section, `stack manage start` creates: behind deliberately: redeploying the same hostname reuses the still-valid certificate instead of asking Let's Encrypt for a new one. +Naming those objects after the hostname rather than after the deployment is what makes that reuse work. A +deployment id changes whenever the stack is re-`init`ed, and a new secret name means cert-manager sees no +certificate to reuse and places a fresh ACME order; Let's Encrypt issues five certificates per hostname per 168 +hours, so the sixth redeploy in a week used to leave the site with no certificate at all until the window +rolled over (issue #283). Keyed by hostname, redeploying is free: the certificate is reissued only on genuine +expiry, and only one secret per hostname accumulates in the Gateway's namespace. + Because the certificate belongs to the Gateway's listener rather than to the workload, everything here is plain Kubernetes API objects — provisioning an HTTPS endpoint needs no access to the host beyond the Kubernetes API itself, and no DNS API access (only an A/AAAA record pointing the hostname at the machine, created by whatever diff --git a/src/stack/deploy/k8s/deploy_k8s.py b/src/stack/deploy/k8s/deploy_k8s.py index 0f75841..699f56d 100644 --- a/src/stack/deploy/k8s/deploy_k8s.py +++ b/src/stack/deploy/k8s/deploy_k8s.py @@ -473,7 +473,7 @@ def _create_gateway_resources(self, http_proxy_info_list): else: # cert-manager sees the new listener on the annotated Gateway # and obtains its certificate over HTTP-01. - gateway.add_https_listener(self.custom_obj_api, gw, self.k8s_namespace, host_name) + gateway.add_https_listener(self.custom_obj_api, gw, host_name) http_route = self.cluster_info.get_http_route(gateway.GATEWAY_NAME, gateway.GATEWAY_NAMESPACE) log_debug(f"Sending this HTTPRoute: {http_route}") @@ -612,11 +612,13 @@ def down(self, timeout, volumes, skip_cluster_management): # noqa: C901 if backup_settings().enabled and k8up.k8up_available(self.custom_obj_api): k8up.delete_backup_configuration(self.core_api, self.custom_obj_api, self.k8s_namespace) - if self.cluster_info.spec.get_http_proxy() and gateway.gateway_api_available(self.custom_obj_api): + http_proxy_info_list = self.cluster_info.spec.get_http_proxy() + if http_proxy_info_list and gateway.gateway_api_available(self.custom_obj_api): gateway.delete_http_route(self.custom_obj_api, self.k8s_namespace) # The certificate Secret survives so that a redeployment of the # same hostname reuses it rather than asking for a new one. - gateway.remove_https_listener(self.custom_obj_api, self.k8s_namespace) + host_name = http_proxy_info_list[0][constants.host_name_key] + gateway.remove_https_listener(self.custom_obj_api, host_name, self.k8s_namespace) else: ingress: client.V1Ingress = self.cluster_info.get_ingress(use_tls=not self.is_kind()) if ingress: diff --git a/src/stack/deploy/k8s/gateway.py b/src/stack/deploy/k8s/gateway.py index 6bb2668..e0ad1e2 100644 --- a/src/stack/deploy/k8s/gateway.py +++ b/src/stack/deploy/k8s/gateway.py @@ -35,6 +35,9 @@ no listener of their own -- only an HTTPRoute. """ +import hashlib +import re + from kubernetes import client from stack.log import log_debug @@ -56,6 +59,12 @@ HTTP_ROUTE_NAME = "http-route" +# Listeners and certificate Secrets are named after the hostname they serve, +# under a prefix that marks them as stack's among whatever else lives in the +# Gateway's namespace. +NAME_PREFIX = "stack-" +MAX_OBJECT_NAME_LENGTH = 253 + CLUSTER_ISSUER_ANNOTATION = "cert-manager.io/cluster-issuer" @@ -162,24 +171,48 @@ def https_listener_covering_host(gateway, host_name: str): return None -def listener_name_for_deployment(deployment_name: str) -> str: - return f"{deployment_name}-https" +def _name_for_host(host_name: str, suffix: str) -> str: + """A Kubernetes object name derived from a hostname. + + Listener and Secret names are keyed by hostname rather than by deployment + so that redeploying the same hostname lands on the same Secret, where + cert-manager finds the certificate it already issued. Keyed by deployment + instead, every redeploy asked Let's Encrypt for another certificate, and the + sixth in a week hit the rate limit and left the site with none (issue #283). + + Names are lowercased and reduced to alphanumerics and dashes: a hostname's + dots are legal in a name but a wildcard's asterisk is not, and the two would + otherwise be indistinguishable from each other after the asterisk was + dropped. A hostname at the length limit is truncated, with a digest of the + whole hostname keeping the result unique. + """ + sanitized = re.sub(r"[^a-z0-9]+", "-", host_name.lower()).strip("-") + stem = f"{NAME_PREFIX}{sanitized}" + budget = MAX_OBJECT_NAME_LENGTH - len(suffix) + if len(stem) > budget: + digest = hashlib.sha256(host_name.encode()).hexdigest()[:8] + stem = f"{stem[: budget - len(digest) - 1]}-{digest}" + return f"{stem}{suffix}" + +def listener_name_for_host(host_name: str) -> str: + return _name_for_host(host_name, "-https") -def secret_name_for_deployment(deployment_name: str) -> str: - return f"{deployment_name}-tls" +def secret_name_for_host(host_name: str) -> str: + return _name_for_host(host_name, "-tls") -def https_listener_for_deployment(deployment_name: str, host_name: str): + +def https_listener_for_host(host_name: str): return { - "name": listener_name_for_deployment(deployment_name), + "name": listener_name_for_host(host_name), "port": GATEWAY_HTTPS_PORT, "protocol": "HTTPS", "hostname": host_name, "allowedRoutes": {"namespaces": {"from": "All"}}, "tls": { "mode": "Terminate", - "certificateRefs": [{"name": secret_name_for_deployment(deployment_name)}], + "certificateRefs": [{"name": secret_name_for_host(host_name)}], }, } @@ -197,17 +230,23 @@ def _patch_listeners(custom_obj_api: client.CustomObjectsApi, listeners): ) -def add_https_listener(custom_obj_api: client.CustomObjectsApi, gateway, deployment_name: str, host_name: str): - """Add (or update in place) this deployment's HTTPS listener on the Gateway.""" - new_listener = https_listener_for_deployment(deployment_name, host_name) +def add_https_listener(custom_obj_api: client.CustomObjectsApi, gateway, host_name: str): + """Add (or update in place) the HTTPS listener for a hostname on the Gateway.""" + new_listener = https_listener_for_host(host_name) listeners = [listener for listener in gateway["spec"]["listeners"] if listener["name"] != new_listener["name"]] listeners.append(new_listener) log_debug(f"Adding Gateway listener: {new_listener}") _patch_listeners(custom_obj_api, listeners) -def remove_https_listener(custom_obj_api: client.CustomObjectsApi, deployment_name: str): - """Remove this deployment's HTTPS listener from the Gateway, if present. +def remove_https_listener(custom_obj_api: client.CustomObjectsApi, host_name: str, deployment_name: str = None): + """Remove a deployment's HTTPS listener from the Gateway, if present. + + Listeners are matched by name rather than by hostname, so that a listener + stack did not add -- a machine-provisioned one for the same hostname -- is + left alone. deployment_name, when given, also removes a listener named the + way stack named them before they were keyed by hostname, so that stopping a + deployment made by an older stack still cleans up after it. The certificate Secret is left behind deliberately: a redeployment of the same hostname re-adds the listener and cert-manager reuses the still-valid @@ -216,11 +255,13 @@ def remove_https_listener(custom_obj_api: client.CustomObjectsApi, deployment_na gateway = get_gateway(custom_obj_api) if not gateway: return - listener_name = listener_name_for_deployment(deployment_name) + names = {listener_name_for_host(host_name)} + if deployment_name: + names.add(f"{deployment_name}-https") listeners = gateway["spec"]["listeners"] - remaining = [listener for listener in listeners if listener["name"] != listener_name] + remaining = [listener for listener in listeners if listener["name"] not in names] if len(remaining) != len(listeners): - log_debug(f"Removing Gateway listener: {listener_name}") + log_debug(f"Removing Gateway listeners: {names}") _patch_listeners(custom_obj_api, remaining) diff --git a/tests/unit/test_k8s_gateway.py b/tests/unit/test_k8s_gateway.py index 88686d3..a44fc54 100644 --- a/tests/unit/test_k8s_gateway.py +++ b/tests/unit/test_k8s_gateway.py @@ -140,21 +140,41 @@ def test_http_route_multiple_routes_preserve_order(tmp_path): def test_https_listener_shape(tmp_path): - listener = gateway.https_listener_for_deployment("mydeployment", "app.example.com") + listener = gateway.https_listener_for_host("app.example.com") assert listener == { - "name": "mydeployment-https", + "name": "stack-app-example-com-https", "port": 8443, "protocol": "HTTPS", "hostname": "app.example.com", "allowedRoutes": {"namespaces": {"from": "All"}}, "tls": { "mode": "Terminate", - "certificateRefs": [{"name": "mydeployment-tls"}], + "certificateRefs": [{"name": "stack-app-example-com-tls"}], }, } +def test_listener_and_secret_are_keyed_by_hostname_not_deployment(): + # Issue #283: keyed by deployment, every redeploy of the same hostname + # ordered another Let's Encrypt certificate and the sixth in a week was + # refused. The same hostname must always name the same Secret, so that + # cert-manager finds the certificate it already issued. + assert gateway.secret_name_for_host("app.example.com") == "stack-app-example-com-tls" + assert gateway.listener_name_for_host("app.example.com") == "stack-app-example-com-https" + + +def test_names_are_legal_kubernetes_names(): + # Uppercase and the wildcard's asterisk are both illegal in an object name. + assert gateway.secret_name_for_host("*.Example.COM") == "stack-example-com-tls" + assert gateway.secret_name_for_host("app.example.com") != gateway.secret_name_for_host("*.example.com") + + long_host = ".".join(["a" * 60] * 4) + name = gateway.secret_name_for_host(long_host) + assert len(name) <= 253 + assert name != gateway.secret_name_for_host(long_host[:-1] + "b") + + def test_hostname_matches_exact_and_wildcard(): assert gateway.hostname_matches("app.example.com", "app.example.com") # A wildcard covers exactly one extra label, like a wildcard certificate. @@ -186,6 +206,6 @@ def test_listener_covering_host_finds_wildcard(): def test_listener_covering_host_finds_exact(): - exact = gateway.https_listener_for_deployment("mydeployment", "app.example.com") + exact = gateway.https_listener_for_host("app.example.com") gw = gateway_with_listeners([exact]) assert gateway.https_listener_covering_host(gw, "app.example.com") is exact