From ccb1f91ed4b71126152e2a880052284520244d8a Mon Sep 17 00:00:00 2001 From: Rob Kooper Date: Tue, 21 Nov 2023 23:33:00 -0600 Subject: [PATCH] version 1.1.0 --- .dockerignore | 2 + .gitignore | 1 + CHANGELOG.md | 9 ++++ Dockerfile | 3 +- main.py | 100 +++++++++++++++++++++++++++------------ traefik-certmanager.yaml | 7 ++- 6 files changed, 89 insertions(+), 33 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f1b636b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.git +venv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ceb386 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +venv diff --git a/CHANGELOG.md b/CHANGELOG.md index f989c51..0525330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## 1.1.0 - 2023-11-21 + +### Changed +- now monitors `traefik.io` as well as `traefik.containo.us` + +### Added +- catch signals to exit quicker +- will add secretName to ingressRoute if missing (PATCH_SECRETNAME=true) + ## 1.0.0 - 2022-10-09 This is the initial release. This will work with Traefik IngressRoutes and create Certificates from them. diff --git a/Dockerfile b/Dockerfile index 1a32609..cb3575e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,8 @@ FROM python:alpine ENV PYTHONUNBUFFERED=1 \ ISSUER_NAME=letsencrypt \ ISSUER_KIND=ClusterIssuer \ - CERT_CLEANUP=false + CERT_CLEANUP=false \ + PATCH_SECRETNAME=true RUN pip install kubernetes COPY main.py / diff --git a/main.py b/main.py index eb7ef78..7e6dc3d 100644 --- a/main.py +++ b/main.py @@ -1,22 +1,16 @@ -# helm install \ -# cert-manager jetstack/cert-manager \ -# --namespace cert-manager \ -# --create-namespace \ -# --version v1.9.1 \ -# --set installCRDs=true +import json +import logging +import os +import re +import signal +import sys +import threading from unicodedata import name from kubernetes import client, config, watch from kubernetes.client.rest import ApiException -import json -import re -import os -TRAEFIK_GROUP = "traefik.containo.us" -TRAEFIK_VERSION = "v1alpha1" -TRAEFIK_PLURAL = "ingressroutes" - CERT_GROUP = "cert-manager.io" CERT_VERSION = "v1" CERT_KIND = "Certificate" @@ -24,6 +18,8 @@ CERT_PLURAL = "certificates" CERT_ISSUER_NAME = os.getenv("ISSUER_NAME", "letsencrypt") CERT_ISSUER_KIND = os.getenv("ISSUER_KIND", "ClusterIssuer") CERT_CLEANUP = os.getenv("CERT_CLEANUP", "false").lower() in ("yes", "true", "t", "1") +PATCH_SECRETNAME = os.getenv("PATCH_SECRETNAME", "false").lower() in ("yes", "true", "t", "1") + def safe_get(obj, keys, default=None): """ @@ -43,17 +39,17 @@ def create_certificate(crds, namespace, secretname, routes): """ try: secret = crds.get_namespaced_custom_object(CERT_GROUP, CERT_VERSION, namespace, CERT_PLURAL, secretname) - print(f"{secretname} : certificate already exists.") + logging.info(f"{secretname} : certificate request already exists.") return except ApiException as e: pass for route in routes: if route.get("kind") == "Rule" and "Host" in route.get("match"): - hostmatch = re.findall("Host\(([^\)]*)\)", route["match"]) - hosts = re.findall('`([^`]*?)`', ",".join(hostmatch)) - - print(f"{secretname} : requesting a new certificate for {', '.join(hosts)}") + hostmatch = re.findall(r"Host\(([^\)]*)\)", route["match"]) + hosts = re.findall(r'`([^`]*?)`', ",".join(hostmatch)) + + logging.info(f"{secretname} : requesting a new certificate for {', '.join(hosts)}") body = { "apiVersion": f"{CERT_GROUP}/{CERT_VERSION}", "kind": CERT_KIND, @@ -72,7 +68,7 @@ def create_certificate(crds, namespace, secretname, routes): try: crds.create_namespaced_custom_object(CERT_GROUP, CERT_VERSION, namespace, CERT_PLURAL, body) except ApiException as e: - print("Exception when calling CustomObjectsApi->create_namespaced_custom_object: %s\n" % e) + logging.exception("Exception when calling CustomObjectsApi->create_namespaced_custom_object:", e) def delete_certificate(crds, namespace, secretname): @@ -80,14 +76,14 @@ def delete_certificate(crds, namespace, secretname): Delete a certificate request for certmanager based on the IngressRoute. """ if CERT_CLEANUP: - print(f"{secretname} : removing certificate") + logging.info(f"{secretname} : removing certificate") try: crds.delete_namespaced_custom_object(CERT_GROUP, CERT_VERSION, namespace, CERT_PLURAL, secretname) except ApiException as e: - print("Exception when calling CustomObjectsApi->delete_namespaced_custom_object: %s\n" % e) + logging.exception("Exception when calling CustomObjectsApi->delete_namespaced_custom_object:", e) -def main(): +def watch_crd(group, version, plural): """ Watch Traefik IngressRoute CRD and create/delete certificates based on them """ @@ -96,9 +92,11 @@ def main(): crds = client.CustomObjectsApi() resource_version = "" + logging.info(f"Watching {group}/{version}/{plural}") + while True: stream = watch.Watch().stream(crds.list_cluster_custom_object, - TRAEFIK_GROUP, TRAEFIK_VERSION, TRAEFIK_PLURAL, + group=group, version=version, plural=plural, resource_version=resource_version) for event in stream: t = event["type"] @@ -109,21 +107,61 @@ def main(): # get information about IngressRoute namespace = safe_get(obj, "metadata.namespace") + name = safe_get(obj, "metadata.name") secretname = safe_get(obj, "spec.tls.secretName") routes = safe_get(obj, 'spec.routes') - # create a Certificate if needed - if secretname: - if t == 'ADDED': + # create or delete certificate based on event type + if t == 'ADDED': + # if no secretName is set, add one to the IngressRoute + if not secretname and PATCH_SECRETNAME: + logging.info(f"{namespace}/{name} : no secretName found in IngressRoute, patch to add one") + patch = { "spec": { "tls": { "secretName": name }}} + crds.patch_namespaced_custom_object(group, version, namespace, plural, name, patch) + secretname = name + if secretname: create_certificate(crds, namespace, secretname, routes) - - elif t == 'DELETED': + else: + logging.info(f"{namespace}/{name} : no secretName found in IngressRoute, skipping adding") + elif t == 'DELETED': + if secretname: delete_certificate(crds, namespace, secretname) + else: + logging.info(f"{namespace}/{name} : no secretName found in IngressRoute, skipping delete") + elif t == 'MODIFIED': + if secretname: + create_certificate(crds, namespace, secretname, routes) + else: + logging.info(f"{namespace}/{name} : no secretName found in IngressRoute, skipping modify") + else: + logging.info(f"{namespace}/{name} : unknown event type: {t}") + logging.debug(json.dumps(obj, indent=2)) - else: - print(t) - print(json.dumps(obj, indent=2)) + +def exit_gracefully(signum, frame): + logging.info(f"Shutting down gracefully on {signum}") + sys.exit(0) + + +def main(): + signal.signal(signal.SIGINT, exit_gracefully) + signal.signal(signal.SIGTERM, exit_gracefully) + + # deprecated traefik CRD + th1 = threading.Thread(target=watch_crd, args=("traefik.containo.us", "v1alpha1", "ingressroutes"), daemon=True) + th1.start() + + # new traefik CRD + th2 = threading.Thread(target=watch_crd, args=("traefik.io", "v1alpha1", "ingressroutes"), daemon=True) + th2.start() + + # wait for threads to finish + while th1.is_alive() and th2.is_alive(): + th1.join(0.1) + th2.join(0.1) + logging.info(f"One of the threads exited {th1.is_alive()}, {th2.is_alive()}") if __name__ == '__main__': + logging.basicConfig(level=logging.INFO) main() diff --git a/traefik-certmanager.yaml b/traefik-certmanager.yaml index b7b6ed4..b6b6dce 100644 --- a/traefik-certmanager.yaml +++ b/traefik-certmanager.yaml @@ -11,7 +11,10 @@ metadata: rules: - apiGroups: ["traefik.containo.us"] resources: ["ingressroutes"] - verbs: ["watch"] + verbs: ["watch", "patch"] +- apiGroups: ["traefik.io"] + resources: ["ingressroutes"] + verbs: ["watch", "patch"] - apiGroups: ["cert-manager.io"] resources: ["certificates"] verbs: ["get", "create", "delete"] @@ -56,3 +59,5 @@ spec: value: ClusterIssuer - name: CERT_CLEANUP value: "false" + - name: PATCH_SECRETNAME + value: "true"