diff --git a/stack_orchestrator/deploy/k8s/cluster_info.py b/stack_orchestrator/deploy/k8s/cluster_info.py index 55393bbf..9e0fbffa 100644 --- a/stack_orchestrator/deploy/k8s/cluster_info.py +++ b/stack_orchestrator/deploy/k8s/cluster_info.py @@ -110,13 +110,32 @@ class ClusterInfo: http_proxy_info = http_proxy_info_list[0] if opts.o.debug: print(f"http-proxy: {http_proxy_info}") - # TODO: good enough parsing for webapp deployment for now + host_name = http_proxy_info["host-name"] + + tls = None + tls_issuer = None + + if use_tls: + tls_info = http_proxy_info.get("tls", {}) + tls_hosts = tls_info.get("hosts", [host_name]) + tls_issuer = tls_info.get("issuer", "letsencrypt-prod") + tls_secret_name = f"{self.app_name}-tls" + if "secret" in tls_info: + # If an existing secret is specified, unset the issuer so that we don't try to re-request it. + tls_secret_name = tls_info["secret"] + tls_issuer = None + + if opts.o.debug: + print(f"TLS hosts/secret: {tls_hosts}/{tls_secret_name}") + + tls = [client.V1IngressTLS( + hosts=tls_hosts, + secret_name=tls_secret_name + )] + + # TODO: good enough parsing for webapp deployment for now rules = [] - tls = [client.V1IngressTLS( - hosts=[host_name], - secret_name=f"{self.app_name}-tls" - )] if use_tls else None paths = [] for route in http_proxy_info["routes"]: path = route["path"] @@ -147,13 +166,15 @@ class ClusterInfo: tls=tls, rules=rules ) + annotations = { + "kubernetes.io/ingress.class": "nginx", + } + if tls_issuer: + annotations["cert-manager.io/cluster-issuer"] = tls_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=annotations ), spec=spec ) diff --git a/stack_orchestrator/deploy/k8s/deploy_k8s.py b/stack_orchestrator/deploy/k8s/deploy_k8s.py index a3855fee..dfc4a3bb 100644 --- a/stack_orchestrator/deploy/k8s/deploy_k8s.py +++ b/stack_orchestrator/deploy/k8s/deploy_k8s.py @@ -350,9 +350,10 @@ 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"], ", ".join(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] diff --git a/stack_orchestrator/deploy/webapp/deploy_webapp.py b/stack_orchestrator/deploy/webapp/deploy_webapp.py index 4c91dec3..375814f7 100644 --- a/stack_orchestrator/deploy/webapp/deploy_webapp.py +++ b/stack_orchestrator/deploy/webapp/deploy_webapp.py @@ -15,6 +15,7 @@ import click import os +import sys from pathlib import Path from urllib.parse import urlparse from tempfile import NamedTemporaryFile @@ -23,6 +24,7 @@ from stack_orchestrator.util import error_exit, global_options2 from stack_orchestrator.deploy.deployment_create import init_operation, create_operation from stack_orchestrator.deploy.deploy import create_deploy_context from stack_orchestrator.deploy.deploy_types import DeployCommandContext +from stack_orchestrator.deploy.webapp.util import TlsDetails def _fixup_container_tag(deployment_dir: str, image: str): @@ -36,16 +38,16 @@ def _fixup_container_tag(deployment_dir: str, image: str): wfile.write(contents) -def _fixup_url_spec(spec_file_name: str, url: str): +def _fixup_url_spec(spec_file_name: str, url: str, tls_details: TlsDetails = TlsDetails()): # url is like: https://example.com/path parsed_url = urlparse(url) - http_proxy_spec = f''' - http-proxy: + http_proxy_spec = f''' http-proxy: - host-name: {parsed_url.hostname} routes: - path: '{parsed_url.path if parsed_url.path else "/"}' proxy-to: webapp:80 - ''' +{tls_details.to_yaml(indent=6)} +''' spec_file_path = Path(spec_file_name) with open(spec_file_path) as rfile: contents = rfile.read() @@ -54,7 +56,8 @@ def _fixup_url_spec(spec_file_name: str, url: str): wfile.write(contents) -def create_deployment(ctx, deployment_dir, image, url, kube_config, image_registry, env_file): +def create_deployment(ctx, deployment_dir, image, url, kube_config, image_registry, env_file, + tls_details: TlsDetails = None): # Do the equivalent of: # 1. laconic-so --stack webapp-template deploy --deploy-to k8s init --output webapp-spec.yml # --config (eqivalent of the contents of my-config.env) @@ -86,7 +89,7 @@ def create_deployment(ctx, deployment_dir, image, url, kube_config, image_regist None ) # Add the TLS and DNS spec - _fixup_url_spec(spec_file_name, url) + _fixup_url_spec(spec_file_name, url, tls_details) create_operation( deploy_command_context, spec_file_name, @@ -116,8 +119,16 @@ def command(ctx): @click.option("--image", help="image to deploy", required=True) @click.option("--url", help="url to serve", required=True) @click.option("--env-file", help="environment file for webapp") +@click.option("--tls-host", help="Override TLS hostname (eg, '*.mydomain.com')") +@click.option("--tls-secret", help="Override TLS secret name") +@click.option("--tls-issuer", help="TLS issuer to use (default: letsencrypt-prod)") @click.pass_context -def create(ctx, deployment_dir, image, url, kube_config, image_registry, env_file): +def create(ctx, deployment_dir, image, url, kube_config, image_registry, env_file, tls_host, tls_secret, tls_issuer): '''create a deployment for the specified webapp container''' - return create_deployment(ctx, deployment_dir, image, url, kube_config, image_registry, env_file) + if (tls_secret and not tls_host) or (tls_host and not tls_secret): + print("Cannot specify --tls-host without --tls-secret", file=sys.stderr) + sys.exit(2) + + return create_deployment(ctx, deployment_dir, image, url, kube_config, image_registry, env_file, + TlsDetails(tls_host, tls_secret, tls_issuer)) diff --git a/stack_orchestrator/deploy/webapp/deploy_webapp_from_registry.py b/stack_orchestrator/deploy/webapp/deploy_webapp_from_registry.py index 2cc704ff..df0fb5f9 100644 --- a/stack_orchestrator/deploy/webapp/deploy_webapp_from_registry.py +++ b/stack_orchestrator/deploy/webapp/deploy_webapp_from_registry.py @@ -26,7 +26,7 @@ import click from stack_orchestrator.deploy.images import remote_image_exists, add_tags_to_image from stack_orchestrator.deploy.webapp import deploy_webapp -from stack_orchestrator.deploy.webapp.util import (LaconicRegistryClient, TimedLogger, +from stack_orchestrator.deploy.webapp.util import (LaconicRegistryClient, TimedLogger, TlsDetails, build_container_image, push_container_image, file_hash, deploy_to_k8s, publish_deployment, hostname_for_deployment_request, generate_hostname_for_app, @@ -44,7 +44,8 @@ def process_app_deployment_request( kube_config, image_registry, force_rebuild, - logger + tls_details, + logger, ): logger.log("BEGIN - process_app_deployment_request") @@ -106,7 +107,7 @@ def process_app_deployment_request( (app_deployment_crn, deployment_dir)) print("deploy_webapp", deployment_dir) deploy_webapp.create_deployment(ctx, deployment_dir, deployment_container_tag, - f"https://{fqdn}", kube_config, image_registry, env_filename) + f"https://{fqdn}", kube_config, image_registry, env_filename, tls_details) elif env_filename: shutil.copyfile(env_filename, deployment_config_file) @@ -198,11 +199,14 @@ def dump_known_requests(filename, requests, status="SEEN"): @click.option("--exclude-tags", help="Exclude requests with matching tags (comma-separated).", default="") @click.option("--force-rebuild", help="Rebuild even if the image already exists.", is_flag=True) @click.option("--log-dir", help="Output build/deployment logs to directory.", default=None) +@click.option("--tls-host", help="Override TLS hostname (eg, '*.mydomain.com')") +@click.option("--tls-secret", help="Override TLS secret name") +@click.option("--tls-issuer", help="TLS issuer to use (default: letsencrypt-prod)") @click.pass_context def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_dir, # noqa: C901 request_id, discover, state_file, only_update_state, dns_suffix, record_namespace_dns, record_namespace_deployments, dry_run, - include_tags, exclude_tags, force_rebuild, log_dir): + include_tags, exclude_tags, force_rebuild, log_dir, tls_host, tls_secret, tls_issuer): if request_id and discover: print("Cannot specify both --request-id and --discover", file=sys.stderr) sys.exit(2) @@ -220,6 +224,10 @@ def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_ print("--dns-suffix, --record-namespace-dns, and --record-namespace-deployments are all required", file=sys.stderr) sys.exit(2) + if (tls_secret and not tls_host) or (tls_host and not tls_secret): + print("Cannot specify --tls-host without --tls-secret", file=sys.stderr) + sys.exit(2) + # Split CSV and clean up values. include_tags = [tag.strip() for tag in include_tags.split(",") if tag] exclude_tags = [tag.strip() for tag in exclude_tags.split(",") if tag] @@ -305,6 +313,7 @@ def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_ print("Found %d unsatisfied request(s) to process." % len(requests_to_execute)) if not dry_run: + tls_details = TlsDetails(tls_host, tls_secret, tls_issuer) for r in requests_to_execute: dump_known_requests(state_file, [r], "DEPLOYING") status = "ERROR" @@ -334,6 +343,7 @@ def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_ kube_config, image_registry, force_rebuild, + tls_details, logger ) status = "DEPLOYED" diff --git a/stack_orchestrator/deploy/webapp/util.py b/stack_orchestrator/deploy/webapp/util.py index 5c484ed1..3440832e 100644 --- a/stack_orchestrator/deploy/webapp/util.py +++ b/stack_orchestrator/deploy/webapp/util.py @@ -26,6 +26,40 @@ import uuid import yaml +class TlsDetails: + def __init__(self, host_or_hosts=None, secret_name: str = None, issuer_name: str = None): + if host_or_hosts: + if isinstance(host_or_hosts, list): + self.hosts = host_or_hosts + else: + self.hosts = [host_or_hosts] + else: + self.hosts = None + self.secret_name = secret_name + self.issuer_name = issuer_name + + def to_yaml(self, indent=6): + if not self.hosts and not self.secret_name and not self.issuer_name: + return "" + + ret = " " * indent + "tls:\n" + indent += 2 + + if self.issuer_name: + ret += " " * indent + "issuer: '%s'\n" % self.issuer_name + + if self.secret_name: + ret += " " * indent + "secret: '%s'\n" % self.secret_name + + if self.hosts: + ret += " " * indent + "hosts:" + indent += 2 + for h in self.hosts: + ret += "\n" + " " * indent + "- '%s'" % h + + return ret + + class AttrDict(dict): def __init__(self, *args, **kwargs): super(AttrDict, self).__init__(*args, **kwargs)