From 523b5779be48cac2eb0ea48daaa2b89cd6df3ee9 Mon Sep 17 00:00:00 2001 From: Thomas E Lackey Date: Thu, 7 Mar 2024 17:38:36 +0000 Subject: [PATCH] Auto-detect which certificate to use (including wildcards). (#779) Rather than always requesting a certificate, attempt to re-use an existing certificate if it already exists in the k8s cluster. This includes matching to a wildcard certificate. Reviewed-on: https://git.vdb.to/cerc-io/stack-orchestrator/pulls/779 Co-authored-by: Thomas E Lackey Co-committed-by: Thomas E Lackey --- stack_orchestrator/deploy/k8s/cluster_info.py | 18 ++++--- stack_orchestrator/deploy/k8s/deploy_k8s.py | 48 +++++++++++++++++-- stack_orchestrator/deploy/spec.py | 46 +++++++++--------- 3 files changed, 78 insertions(+), 34 deletions(-) diff --git a/stack_orchestrator/deploy/k8s/cluster_info.py b/stack_orchestrator/deploy/k8s/cluster_info.py index 55393bbf..dbf7c907 100644 --- a/stack_orchestrator/deploy/k8s/cluster_info.py +++ b/stack_orchestrator/deploy/k8s/cluster_info.py @@ -101,7 +101,7 @@ class ClusterInfo: ) return service - def get_ingress(self, use_tls=False): + def get_ingress(self, use_tls=False, certificate=None, cluster_issuer="letsencrypt-prod"): # No ingress for a deployment that has no http-proxy defined, for now http_proxy_info_list = self.spec.get_http_proxy() ingress = None @@ -114,8 +114,8 @@ class ClusterInfo: host_name = http_proxy_info["host-name"] rules = [] tls = [client.V1IngressTLS( - hosts=[host_name], - secret_name=f"{self.app_name}-tls" + hosts=certificate["spec"]["dnsNames"] if certificate else [host_name], + secret_name=certificate["spec"]["secretName"] if certificate else f"{self.app_name}-tls" )] if use_tls else None paths = [] for route in http_proxy_info["routes"]: @@ -147,13 +147,17 @@ class ClusterInfo: tls=tls, rules=rules ) + + ingress_annotations = { + "kubernetes.io/ingress.class": "nginx", + } + if not certificate: + ingress_annotations["cert-manager.io/cluster-issuer"] = cluster_issuer + ingress = client.V1Ingress( metadata=client.V1ObjectMeta( name=f"{self.app_name}-ingress", - annotations={ - "kubernetes.io/ingress.class": "nginx", - "cert-manager.io/cluster-issuer": "letsencrypt-prod" - } + annotations=ingress_annotations ), spec=spec ) diff --git a/stack_orchestrator/deploy/k8s/deploy_k8s.py b/stack_orchestrator/deploy/k8s/deploy_k8s.py index a3855fee..5781cd26 100644 --- a/stack_orchestrator/deploy/k8s/deploy_k8s.py +++ b/stack_orchestrator/deploy/k8s/deploy_k8s.py @@ -169,6 +169,39 @@ class K8sDeployer(Deployer): print("Service created:") print(f"{service_resp}") + def _find_certificate_for_host_name(self, host_name): + all_certificates = self.custom_obj_api.list_namespaced_custom_object( + group="cert-manager.io", + version="v1", + namespace=self.k8s_namespace, + plural="certificates" + ) + + host_parts = host_name.split(".", 1) + host_as_wild = None + if len(host_parts) == 2: + host_as_wild = f"*.{host_parts[1]}" + + now = datetime.utcnow().replace(tzinfo=timezone.utc) + fmt = "%Y-%m-%dT%H:%M:%S%z" + + # Walk over all the configured certificates. + for cert in all_certificates["items"]: + dns = cert["spec"]["dnsNames"] + # Check for an exact hostname match or a wildcard match. + if host_name in dns or host_as_wild in dns: + status = cert.get("status", {}) + # Check the certificate date. + if "notAfter" in status and "notBefore" in status: + before = datetime.strptime(status["notBefore"], fmt) + after = datetime.strptime(status["notAfter"], fmt) + if before < now < after: + # Check the status is Ready + for condition in status.get("conditions", []): + if "True" == condition.get("status") and "Ready" == condition.get("type"): + return cert + return None + def up(self, detach, services): if not opts.o.dry_run: if self.is_kind(): @@ -189,8 +222,15 @@ class K8sDeployer(Deployer): self._create_volume_data() self._create_deployment() + http_proxy_info = self.cluster_info.spec.get_http_proxy() # Note: at present we don't support tls for kind (and enabling tls causes errors) - ingress: client.V1Ingress = self.cluster_info.get_ingress(use_tls=not self.is_kind()) + use_tls = http_proxy_info and not self.is_kind() + certificate = self._find_certificate_for_host_name(http_proxy_info[0]["host-name"]) if use_tls else None + if opts.o.debug: + if certificate: + print(f"Using existing certificate: {certificate}") + + ingress: client.V1Ingress = self.cluster_info.get_ingress(use_tls=use_tls, certificate=certificate) if ingress: if opts.o.debug: print(f"Sending this ingress: {ingress}") @@ -350,9 +390,11 @@ class K8sDeployer(Deployer): name=ingress.spec.tls[0].secret_name ) - hostname = ingress.spec.tls[0].hosts[0] + hostname = ingress.spec.rules[0].host ip = ingress.status.load_balancer.ingress[0].ip - tls = "notBefore: %s, notAfter: %s" % (cert["status"]["notBefore"], cert["status"]["notAfter"]) + tls = "notBefore: %s; notAfter: %s; names: %s" % ( + cert["status"]["notBefore"], cert["status"]["notAfter"], ingress.spec.tls[0].hosts + ) except: # noqa: E722 pass diff --git a/stack_orchestrator/deploy/spec.py b/stack_orchestrator/deploy/spec.py index ab452fe3..cbec8ae5 100644 --- a/stack_orchestrator/deploy/spec.py +++ b/stack_orchestrator/deploy/spec.py @@ -27,7 +27,9 @@ class ResourceLimits: memory: int = None storage: int = None - def __init__(self, obj={}): + def __init__(self, obj=None): + if obj is None: + obj = {} if "cpus" in obj: self.cpus = float(obj["cpus"]) if "memory" in obj: @@ -50,7 +52,9 @@ class Resources: limits: ResourceLimits = None reservations: ResourceLimits = None - def __init__(self, obj={}): + def __init__(self, obj=None): + if obj is None: + obj = {} if "reservations" in obj: self.reservations = ResourceLimits(obj["reservations"]) if "limits" in obj: @@ -72,7 +76,9 @@ class Spec: obj: typing.Any file_path: Path - def __init__(self, file_path: Path = None, obj={}) -> None: + def __init__(self, file_path: Path = None, obj=None) -> None: + if obj is None: + obj = {} self.file_path = file_path self.obj = obj @@ -91,49 +97,41 @@ class Spec: self.file_path = file_path def get_image_registry(self): - return (self.obj[constants.image_registry_key] - if self.obj and constants.image_registry_key in self.obj - else None) + return self.obj.get(constants.image_registry_key) def get_volumes(self): - return (self.obj["volumes"] - if self.obj and "volumes" in self.obj - else {}) + return self.obj.get(constants.volumes_key, {}) def get_configmaps(self): - return (self.obj["configmaps"] - if self.obj and "configmaps" in self.obj - else {}) + return self.obj.get(constants.configmaps_key, {}) def get_container_resources(self): - return Resources(self.obj.get("resources", {}).get("containers", {})) + return Resources(self.obj.get(constants.resources_key, {}).get("containers", {})) def get_volume_resources(self): - return Resources(self.obj.get("resources", {}).get("volumes", {})) + return Resources(self.obj.get(constants.resources_key, {}).get(constants.volumes_key, {})) def get_http_proxy(self): - return (self.obj[constants.network_key][constants.http_proxy_key] - if self.obj and constants.network_key in self.obj - and constants.http_proxy_key in self.obj[constants.network_key] - else None) + return self.obj.get(constants.network_key, {}).get(constants.http_proxy_key, []) def get_annotations(self): - return self.obj.get("annotations", {}) + return self.obj.get(constants.annotations_key, {}) def get_labels(self): - return self.obj.get("labels", {}) + return self.obj.get(constants.labels_key, {}) def get_privileged(self): - return "true" == str(self.obj.get("security", {}).get("privileged", "false")).lower() + return "true" == str(self.obj.get(constants.security_key, {}).get("privileged", "false")).lower() def get_capabilities(self): - return self.obj.get("security", {}).get("capabilities", []) + return self.obj.get(constants.security_key, {}).get("capabilities", []) def get_deployment_type(self): - return self.obj[constants.deploy_to_key] + return self.obj.get(constants.deploy_to_key) def is_kubernetes_deployment(self): - return self.get_deployment_type() in [constants.k8s_kind_deploy_type, constants.k8s_deploy_type] + return self.get_deployment_type() in [constants.k8s_kind_deploy_type, + constants.k8s_deploy_type] def is_kind_deployment(self): return self.get_deployment_type() in [constants.k8s_kind_deploy_type]