Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/gateway-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<deployment-id>-https`,
referencing a certificate secret named `<deployment-id>-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
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/stack/deploy/k8s/deploy_k8s.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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:
Expand Down
71 changes: 56 additions & 15 deletions src/stack/deploy/k8s/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"


Expand Down Expand Up @@ -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)}],
},
}

Expand All @@ -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
Expand All @@ -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)


Expand Down
28 changes: 24 additions & 4 deletions tests/unit/test_k8s_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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