WIP: Support wildcard certs. #776

Closed
telackey wants to merge 12 commits from telackey/wild into main
6 changed files with 122 additions and 47 deletions

View File

@ -110,13 +110,32 @@ class ClusterInfo:
http_proxy_info = http_proxy_info_list[0] http_proxy_info = http_proxy_info_list[0]
if opts.o.debug: if opts.o.debug:
print(f"http-proxy: {http_proxy_info}") print(f"http-proxy: {http_proxy_info}")
# TODO: good enough parsing for webapp deployment for now
host_name = http_proxy_info["host-name"] 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 = [] rules = []
tls = [client.V1IngressTLS(
hosts=[host_name],
secret_name=f"{self.app_name}-tls"
)] if use_tls else None
paths = [] paths = []
for route in http_proxy_info["routes"]: for route in http_proxy_info["routes"]:
path = route["path"] path = route["path"]
@ -147,13 +166,15 @@ class ClusterInfo:
tls=tls, tls=tls,
rules=rules rules=rules
) )
annotations = {
"kubernetes.io/ingress.class": "nginx",
}
if tls_issuer:
annotations["cert-manager.io/cluster-issuer"] = tls_issuer
ingress = client.V1Ingress( ingress = client.V1Ingress(
metadata=client.V1ObjectMeta( metadata=client.V1ObjectMeta(
name=f"{self.app_name}-ingress", name=f"{self.app_name}-ingress",
annotations={ annotations=annotations
"kubernetes.io/ingress.class": "nginx",
"cert-manager.io/cluster-issuer": "letsencrypt-prod"
}
), ),
spec=spec spec=spec
) )

View File

@ -350,9 +350,10 @@ class K8sDeployer(Deployer):
name=ingress.spec.tls[0].secret_name 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 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 except: # noqa: E722
pass pass

View File

@ -27,7 +27,9 @@ class ResourceLimits:
memory: int = None memory: int = None
storage: int = None storage: int = None
def __init__(self, obj={}): def __init__(self, obj=None):
if obj is None:
obj = {}
if "cpus" in obj: if "cpus" in obj:
self.cpus = float(obj["cpus"]) self.cpus = float(obj["cpus"])
if "memory" in obj: if "memory" in obj:
@ -50,7 +52,9 @@ class Resources:
limits: ResourceLimits = None limits: ResourceLimits = None
reservations: ResourceLimits = None reservations: ResourceLimits = None
def __init__(self, obj={}): def __init__(self, obj=None):
if obj is None:
obj = {}
if "reservations" in obj: if "reservations" in obj:
self.reservations = ResourceLimits(obj["reservations"]) self.reservations = ResourceLimits(obj["reservations"])
if "limits" in obj: if "limits" in obj:
@ -72,7 +76,9 @@ class Spec:
obj: typing.Any obj: typing.Any
file_path: Path 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.file_path = file_path
self.obj = obj self.obj = obj
@ -91,49 +97,41 @@ class Spec:
self.file_path = file_path self.file_path = file_path
def get_image_registry(self): def get_image_registry(self):
return (self.obj[constants.image_registry_key] return self.obj.get(constants.image_registry_key)
if self.obj and constants.image_registry_key in self.obj
else None)
def get_volumes(self): def get_volumes(self):
return (self.obj["volumes"] return self.obj.get(constants.volumes_key, {})
if self.obj and "volumes" in self.obj
else {})
def get_configmaps(self): def get_configmaps(self):
return (self.obj["configmaps"] return self.obj.get(constants.configmaps_key, {})
if self.obj and "configmaps" in self.obj
else {})
def get_container_resources(self): 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): 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): def get_http_proxy(self):
return (self.obj[constants.network_key][constants.http_proxy_key] return self.obj.get(constants.network_key, {}).get(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)
def get_annotations(self): def get_annotations(self):
return self.obj.get("annotations", {}) return self.obj.get(constants.annotations_key, {})
def get_labels(self): def get_labels(self):
return self.obj.get("labels", {}) return self.obj.get(constants.labels_key, {})
def get_privileged(self): 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): 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): 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): 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): def is_kind_deployment(self):
return self.get_deployment_type() in [constants.k8s_kind_deploy_type] return self.get_deployment_type() in [constants.k8s_kind_deploy_type]

View File

@ -15,6 +15,7 @@
import click import click
import os import os
import sys
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from tempfile import NamedTemporaryFile 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.deployment_create import init_operation, create_operation
from stack_orchestrator.deploy.deploy import create_deploy_context from stack_orchestrator.deploy.deploy import create_deploy_context
from stack_orchestrator.deploy.deploy_types import DeployCommandContext 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): 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) 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 # url is like: https://example.com/path
parsed_url = urlparse(url) parsed_url = urlparse(url)
http_proxy_spec = f''' http_proxy_spec = f''' http-proxy:
http-proxy:
- host-name: {parsed_url.hostname} - host-name: {parsed_url.hostname}
routes: routes:
- path: '{parsed_url.path if parsed_url.path else "/"}' - path: '{parsed_url.path if parsed_url.path else "/"}'
proxy-to: webapp:80 proxy-to: webapp:80
''' {tls_details.to_yaml(indent=6)}
'''
spec_file_path = Path(spec_file_name) spec_file_path = Path(spec_file_name)
with open(spec_file_path) as rfile: with open(spec_file_path) as rfile:
contents = rfile.read() contents = rfile.read()
@ -54,7 +56,8 @@ def _fixup_url_spec(spec_file_name: str, url: str):
wfile.write(contents) 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: # Do the equivalent of:
# 1. laconic-so --stack webapp-template deploy --deploy-to k8s init --output webapp-spec.yml # 1. laconic-so --stack webapp-template deploy --deploy-to k8s init --output webapp-spec.yml
# --config (eqivalent of the contents of my-config.env) # --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 None
) )
# Add the TLS and DNS spec # Add the TLS and DNS spec
_fixup_url_spec(spec_file_name, url) _fixup_url_spec(spec_file_name, url, tls_details)
create_operation( create_operation(
deploy_command_context, deploy_command_context,
spec_file_name, spec_file_name,
@ -116,8 +119,16 @@ def command(ctx):
@click.option("--image", help="image to deploy", required=True) @click.option("--image", help="image to deploy", required=True)
@click.option("--url", help="url to serve", required=True) @click.option("--url", help="url to serve", required=True)
@click.option("--env-file", help="environment file for webapp") @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 @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''' '''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))

View File

@ -26,7 +26,7 @@ import click
from stack_orchestrator.deploy.images import remote_image_exists, add_tags_to_image 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 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, build_container_image, push_container_image,
file_hash, deploy_to_k8s, publish_deployment, file_hash, deploy_to_k8s, publish_deployment,
hostname_for_deployment_request, generate_hostname_for_app, hostname_for_deployment_request, generate_hostname_for_app,
@ -44,7 +44,8 @@ def process_app_deployment_request(
kube_config, kube_config,
image_registry, image_registry,
force_rebuild, force_rebuild,
logger tls_details,
logger,
): ):
logger.log("BEGIN - process_app_deployment_request") logger.log("BEGIN - process_app_deployment_request")
@ -106,7 +107,7 @@ def process_app_deployment_request(
(app_deployment_crn, deployment_dir)) (app_deployment_crn, deployment_dir))
print("deploy_webapp", deployment_dir) print("deploy_webapp", deployment_dir)
deploy_webapp.create_deployment(ctx, deployment_dir, deployment_container_tag, 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: elif env_filename:
shutil.copyfile(env_filename, deployment_config_file) 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("--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("--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("--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 @click.pass_context
def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_dir, # noqa: C901 def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_dir, # noqa: C901
request_id, discover, state_file, only_update_state, request_id, discover, state_file, only_update_state,
dns_suffix, record_namespace_dns, record_namespace_deployments, dry_run, 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: if request_id and discover:
print("Cannot specify both --request-id and --discover", file=sys.stderr) print("Cannot specify both --request-id and --discover", file=sys.stderr)
sys.exit(2) 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) print("--dns-suffix, --record-namespace-dns, and --record-namespace-deployments are all required", file=sys.stderr)
sys.exit(2) 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. # Split CSV and clean up values.
include_tags = [tag.strip() for tag in include_tags.split(",") if tag] include_tags = [tag.strip() for tag in include_tags.split(",") if tag]
exclude_tags = [tag.strip() for tag in exclude_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)) print("Found %d unsatisfied request(s) to process." % len(requests_to_execute))
if not dry_run: if not dry_run:
tls_details = TlsDetails(tls_host, tls_secret, tls_issuer)
for r in requests_to_execute: for r in requests_to_execute:
dump_known_requests(state_file, [r], "DEPLOYING") dump_known_requests(state_file, [r], "DEPLOYING")
status = "ERROR" status = "ERROR"
@ -334,6 +343,7 @@ def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_
kube_config, kube_config,
image_registry, image_registry,
force_rebuild, force_rebuild,
tls_details,
logger logger
) )
status = "DEPLOYED" status = "DEPLOYED"

View File

@ -26,6 +26,40 @@ import uuid
import yaml 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): class AttrDict(dict):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(AttrDict, self).__init__(*args, **kwargs) super(AttrDict, self).__init__(*args, **kwargs)