From 3d36d550c721fd3df763cbcf4d4ab9bf978d4493 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sat, 5 Sep 2026 15:42:12 +0200 Subject: [PATCH] Introduce CI/CD --- .gitea/workflows/deploy.yml | 53 ++++++++++++++++++++++ .gitignore | 4 ++ README.md | 12 ++++- certificates.yaml | 60 +++++++++++++++++++++++++ ci-deployer.yaml | 46 +++++++++++++++++++ cloudflare-api-credentials.yaml.example | 8 ++++ cloudflare.yaml | 17 +++++++ create-ci-kubeconfig.sh | 26 +++++++++++ http-issuer.yaml | 14 ++++++ install.sh | 45 +++++++++++++++++++ test.sh | 14 ++++++ values.yaml | 6 +++ 12 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 certificates.yaml create mode 100644 ci-deployer.yaml create mode 100644 cloudflare-api-credentials.yaml.example create mode 100644 cloudflare.yaml create mode 100755 create-ci-kubeconfig.sh create mode 100644 http-issuer.yaml create mode 100755 install.sh create mode 100755 test.sh create mode 100644 values.yaml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..a8aa289 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,53 @@ +name: Validate and deploy cert-manager configuration +on: [push, pull_request] +env: + KUBECTL_VERSION: v1.36.3 + KUBERNETES_API: https://host.containers.internal:6443 + KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: bash test.sh + deploy: + if: gitea.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install kubectl + run: | + curl -fsSL -o "${RUNNER_TEMP}/kubectl" "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" + curl -fsSL -o "${RUNNER_TEMP}/kubectl.sha256" "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" + printf '%s %s\n' "$(cat "${RUNNER_TEMP}/kubectl.sha256")" "${RUNNER_TEMP}/kubectl" | sha256sum -c + chmod 0700 "${RUNNER_TEMP}/kubectl" + - name: Configure Kubernetes access + env: + KUBE_CONFIG_BASE64: ${{ secrets.KUBE_CONFIG_BASE64 }} + run: | + test -n "${KUBE_CONFIG_BASE64}" + printf '%s' "${KUBE_CONFIG_BASE64}" | base64 -d > "${RUNNER_TEMP}/kubeconfig" + chmod 0600 "${RUNNER_TEMP}/kubeconfig" + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + "${RUNNER_TEMP}/kubectl" config set-cluster cluster --server="${KUBERNETES_API}" --tls-server-name="${KUBERNETES_TLS_SERVER_NAME}" + - name: Apply and verify + run: | + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + for file in cloudflare.yaml http-issuer.yaml certificates.yaml; do + "${RUNNER_TEMP}/kubectl" apply --server-side --force-conflicts --dry-run=server -f "$file" + done + for file in cloudflare.yaml http-issuer.yaml certificates.yaml; do + "${RUNNER_TEMP}/kubectl" apply --server-side --force-conflicts -f "$file" + done + for issuer in letsencrypt-dns letsencrypt-http; do + for attempt in {1..60}; do + ready=$("${RUNNER_TEMP}/kubectl" get clusterissuer "$issuer" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}') + [[ "$ready" == True ]] && break + [[ "$attempt" != 60 ]] || exit 1 + sleep 5 + done + done + for certificate in brunner-ninja watschn-studio brgkepler-com gschissenes-equipment; do + test "$("${RUNNER_TEMP}/kubectl" -n default get certificate "$certificate" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}')" = True + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17a99aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +cloudflare-api-credentials.yaml +*.kubeconfig +*secrets*.env +*.secret diff --git a/README.md b/README.md index d538919..d2c08fe 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,12 @@ -# cert-manager-deployment +# cert-manager on Kubernetes +The operator is pinned to chart/app `v1.21.1` and remains an administrator +upgrade because it owns CRDs, admission webhooks, and cluster-wide RBAC. Gitea +Actions validates and deploys only the two exact ClusterIssuers and four exact +Certificates; it cannot read Secrets, create/delete resources, or modify the +operator. + +`cloudflare-api-credentials.yaml` is intentionally ignored. Copy the `.example`, +insert the existing token, and run `./install.sh`; when the file is absent the +installer preserves the existing live Secret. Run `./create-ci-kubeconfig.sh` +once and save only its final line as repository secret `KUBE_CONFIG_BASE64`. diff --git a/certificates.yaml b/certificates.yaml new file mode 100644 index 0000000..0c6516c --- /dev/null +++ b/certificates.yaml @@ -0,0 +1,60 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: brunner-ninja + namespace: default +spec: + secretName: brunner-ninja-tls + issuerRef: + name: letsencrypt-dns + kind: ClusterIssuer + dnsNames: + - brunner.ninja + - "*.brunner.ninja" + privateKey: + rotationPolicy: Always +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: watschn-studio + namespace: default +spec: + secretName: watschn-studio-tls + issuerRef: + name: letsencrypt-dns + kind: ClusterIssuer + dnsNames: + - watschn.studio + privateKey: + rotationPolicy: Always +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: brgkepler-com + namespace: default +spec: + secretName: brgkepler-com-tls + issuerRef: + name: letsencrypt-http + kind: ClusterIssuer + dnsNames: + - brgkepler.com + privateKey: + rotationPolicy: Always +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: gschissenes-equipment + namespace: default +spec: + secretName: gschissenes-equipment-tls + issuerRef: + name: letsencrypt-http + kind: ClusterIssuer + dnsNames: + - gschissenes.equipment + privateKey: + rotationPolicy: Always diff --git a/ci-deployer.yaml b/ci-deployer.yaml new file mode 100644 index 0000000..47ad756 --- /dev/null +++ b/ci-deployer.yaml @@ -0,0 +1,46 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: cert-manager-config-deployer + namespace: cert-manager +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: cert-manager-config-deployer +rules: + - apiGroups: [cert-manager.io] + resources: [clusterissuers] + resourceNames: [letsencrypt-dns, letsencrypt-http] + verbs: [get, patch, update] + - apiGroups: [cert-manager.io] + resources: [certificates] + resourceNames: [brunner-ninja, watschn-studio, brgkepler-com, gschissenes-equipment] + verbs: [get, patch, update] + - apiGroups: [apps] + resources: [deployments] + resourceNames: [cert-manager, cert-manager-cainjector, cert-manager-webhook] + verbs: [get] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: cert-manager-config-deployer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cert-manager-config-deployer +subjects: + - kind: ServiceAccount + name: cert-manager-config-deployer + namespace: cert-manager +--- +apiVersion: v1 +kind: Secret +metadata: + name: cert-manager-config-deployer-token + namespace: cert-manager + annotations: + kubernetes.io/service-account.name: cert-manager-config-deployer +type: kubernetes.io/service-account-token diff --git a/cloudflare-api-credentials.yaml.example b/cloudflare-api-credentials.yaml.example new file mode 100644 index 0000000..78a8ac8 --- /dev/null +++ b/cloudflare-api-credentials.yaml.example @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: cloudflare-api-token-secret + namespace: cert-manager +type: Opaque +stringData: + api-token: REPLACE_WITH_CLOUDFLARE_API_TOKEN diff --git a/cloudflare.yaml b/cloudflare.yaml new file mode 100644 index 0000000..9360fd6 --- /dev/null +++ b/cloudflare.yaml @@ -0,0 +1,17 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-dns +spec: + acme: + email: daniel@brunner.ninja + server: https://acme-v02.api.letsencrypt.org/directory + privateKeySecretRef: + name: letsencrypt-dns-account-key + solvers: + - dns01: + cloudflare: + email: daniel@brunner.ninja + apiKeySecretRef: + name: cloudflare-api-credentials + key: apiKey diff --git a/create-ci-kubeconfig.sh b/create-ci-kubeconfig.sh new file mode 100755 index 0000000..583f456 --- /dev/null +++ b/create-ci-kubeconfig.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +namespace=cert-manager +service_account=cert-manager-config-deployer +secret=cert-manager-config-deployer-token +ssh_host=${KUBECTL_SSH_HOST:-} +ssh_config=${SSH_CONFIG:-} +cluster_kubectl() { if [[ -n "$ssh_host" ]]; then if [[ -n "$ssh_config" ]]; then ssh -F "$ssh_config" "$ssh_host" kubectl "$@"; else ssh "$ssh_host" kubectl "$@"; fi; else kubectl "$@"; fi; } +if [[ -n "$ssh_host" ]]; then cluster_kubectl apply -f - < "$project_dir/ci-deployer.yaml" >&2; else cluster_kubectl apply -f "$project_dir/ci-deployer.yaml" >&2; fi +for attempt in {1..30}; do + if cluster_kubectl -n "$namespace" get secret "$secret" -o jsonpath='{.data.token}' 2>/dev/null | grep -q .; then break; fi + [[ "$attempt" != 30 ]] || { echo 'Timed out waiting for service-account token' >&2; exit 1; } + sleep 1 +done +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT +server=$(cluster_kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}') +cluster_kubectl -n "$namespace" get secret "$secret" -o jsonpath='{.data.ca\.crt}' | base64 -d > "$work/ca.crt" +token=$(cluster_kubectl -n "$namespace" get secret "$secret" -o jsonpath='{.data.token}' | base64 -d) +export KUBECONFIG="$work/config" +kubectl config set-cluster cluster --server="$server" --certificate-authority="$work/ca.crt" --embed-certs=true >/dev/null +kubectl config set-credentials "$service_account" --token="$token" >/dev/null +kubectl config set-context cert-manager --cluster=cluster --user="$service_account" --namespace="$namespace" >/dev/null +kubectl config use-context cert-manager >/dev/null +echo 'Store this line as Gitea Actions secret KUBE_CONFIG_BASE64; do not commit it.' >&2 +base64 -w0 "$KUBECONFIG"; printf '\n' diff --git a/http-issuer.yaml b/http-issuer.yaml new file mode 100644 index 0000000..7704dfc --- /dev/null +++ b/http-issuer.yaml @@ -0,0 +1,14 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-http +spec: + acme: + email: daniel@brunner.ninja + server: https://acme-v02.api.letsencrypt.org/directory + privateKeySecretRef: + name: letsencrypt-http-account-key + solvers: + - http01: + ingress: + ingressClassName: traefik diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..5d67eef --- /dev/null +++ b/install.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +set -eu + +CERT_MANAGER_VERSION=v1.21.1 +CERT_MANAGER_CHART=oci://quay.io/jetstack/charts/cert-manager +PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +CREDENTIALS_FILE=${CLOUDFLARE_CREDENTIALS_FILE:-$PROJECT_DIR/cloudflare-api-credentials.yaml} + +kubectl create namespace cert-manager --dry-run=client -o yaml | kubectl apply -f - + +if [ -f "$CREDENTIALS_FILE" ]; then + if grep -q 'REPLACE_WITH_' "$CREDENTIALS_FILE"; then + echo "Refusing placeholder credentials in $CREDENTIALS_FILE" >&2 + exit 1 + fi + kubectl apply -f "$CREDENTIALS_FILE" +elif kubectl -n cert-manager get secret cloudflare-api-token-secret >/dev/null 2>&1; then + echo "Keeping existing cert-manager/cloudflare-api-token-secret Secret." +else + echo "Missing $CREDENTIALS_FILE and the live Cloudflare Secret." >&2 + exit 1 +fi + +helm upgrade --install cert-manager "$CERT_MANAGER_CHART" \ + --version "$CERT_MANAGER_VERSION" \ + --namespace cert-manager \ + --create-namespace \ + --take-ownership \ + --force-conflicts \ + --rollback-on-failure \ + --wait \ + --timeout 10m \ + -f "$PROJECT_DIR/values.yaml" + +kubectl apply -f "$PROJECT_DIR/cloudflare.yaml" +kubectl apply -f "$PROJECT_DIR/http-issuer.yaml" +kubectl wait --for=condition=Ready clusterissuer/letsencrypt-dns --timeout=120s +kubectl wait --for=condition=Ready clusterissuer/letsencrypt-http --timeout=120s + +kubectl apply -f "$PROJECT_DIR/certificates.yaml" +kubectl wait --for=condition=Ready certificate/brunner-ninja --namespace default --timeout=10m +kubectl wait --for=condition=Ready certificate/watschn-studio --namespace default --timeout=10m +kubectl wait --for=condition=Ready certificate/brgkepler-com --namespace default --timeout=10m +kubectl wait --for=condition=Ready certificate/gschissenes-equipment --namespace default --timeout=10m diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..647a909 --- /dev/null +++ b/test.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +sh -n "$dir/install.sh" +bash -n "$dir/create-ci-kubeconfig.sh" "$dir/test.sh" +grep -Fxq 'cloudflare-api-credentials.yaml' "$dir/.gitignore" +if grep -R -q 'cloudflare-api-credentials' "$dir/.gitea/workflows"; then + echo 'CI must never read or apply the ignored credential manifest.' >&2 + exit 1 +fi +image=ghcr.io/yannh/kubeconform:v0.7.0@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c +for file in cloudflare.yaml http-issuer.yaml certificates.yaml; do docker run --rm -i "$image" -strict -exit-on-error -ignore-missing-schemas -summary < "$dir/$file"; done +docker run --rm -i "$image" -strict -exit-on-error -summary < "$dir/ci-deployer.yaml" +echo 'cert-manager configuration is valid; no credential file is tracked by CI.' diff --git a/values.yaml b/values.yaml new file mode 100644 index 0000000..f5c537f --- /dev/null +++ b/values.yaml @@ -0,0 +1,6 @@ +crds: + enabled: true + +extraArgs: + - --dns01-recursive-nameservers-only + - --dns01-recursive-nameservers=1.1.1.1:53,8.8.8.8:53