From 3359b7913b73aed783ddc39f7fcfced15ec98ca9 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sat, 5 Sep 2026 09:35:59 +0200 Subject: [PATCH] Introduce CI/CD --- .gitea/workflows/deploy.yml | 66 +++++++++++++++++++++++++++++ .gitignore | 6 +++ README.md | 25 ++++++++++- argocd-values.yaml | 43 +++++++++++++++++++ ci-deployer.yaml | 82 +++++++++++++++++++++++++++++++++++++ create-ci-kubeconfig.sh | 26 ++++++++++++ install.sh | 59 ++++++++++++++++++++++++++ render.sh | 13 ++++++ test.sh | 15 +++++++ 9 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 argocd-values.yaml create mode 100644 ci-deployer.yaml create mode 100755 create-ci-kubeconfig.sh create mode 100755 install.sh create mode 100755 render.sh create mode 100755 test.sh diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..f0eb8cd --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,66 @@ +name: Validate and deploy Argo CD +on: + push: + pull_request: +env: + ARGOCD_CHART_VERSION: 10.7.2 + ARGOCD_CHART_SHA256: 26111ae91779b28f18ef5c367f70530e3ecbec3effad45a7db59979344956dab + HELM_VERSION: v4.2.2 + HELM_SHA256: 9adafecab4d406853bba163a70e9f104f47dbbf65ce24b7653bae7e36150bcb6 + KUBECTL_VERSION: v1.36.3 + KUBERNETES_API: https://host.containers.internal:6443 + KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 + NAMESPACE: argocd +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Helm + run: | + curl -fsSL -o "${RUNNER_TEMP}/helm.tgz" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + printf '%s %s\n' "${HELM_SHA256}" "${RUNNER_TEMP}/helm.tgz" | sha256sum -c + tar -xzf "${RUNNER_TEMP}/helm.tgz" -C "${RUNNER_TEMP}" --strip-components=1 linux-amd64/helm + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + - run: bash test.sh + deploy: + if: gitea.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install clients + run: | + curl -fsSL -o "${RUNNER_TEMP}/helm.tgz" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + printf '%s %s\n' "${HELM_SHA256}" "${RUNNER_TEMP}/helm.tgz" | sha256sum -c + tar -xzf "${RUNNER_TEMP}/helm.tgz" -C "${RUNNER_TEMP}" --strip-components=1 linux-amd64/helm + 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}/helm" "${RUNNER_TEMP}/kubectl" + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + - name: Configure cluster + 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" + 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" + ./render.sh "${RUNNER_TEMP}/argocd.yaml" + kubectl apply --server-side --force-conflicts --dry-run=server --validate=false -f "${RUNNER_TEMP}/argocd.yaml" + kubectl apply --server-side --force-conflicts --validate=false -f "${RUNNER_TEMP}/argocd.yaml" + for resource in deployment/argocd-applicationset-controller deployment/argocd-notifications-controller deployment/argocd-repo-server deployment/argocd-server deployment/argocd-dex-server deployment/argocd-redis statefulset/argocd-application-controller; do + for attempt in {1..120}; do + IFS='|' read -r generation observed desired updated ready available <<< "$(kubectl -n "${NAMESPACE}" get "${resource}" -o jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.readyReplicas}|{.status.availableReplicas}')" + echo "${resource} ${attempt}/120: ${updated}/${desired} updated, ${ready}/${desired} ready" + [[ "${observed}" == "${generation}" && "${updated}" == "${desired}" && "${ready}" == "${desired}" && "${available}" == "${desired}" ]] && break + [[ "${attempt}" == 120 ]] && { echo "${resource} rollout timed out" >&2; exit 1; } + sleep 5 + done + done + curl -fsS --max-time 15 https://cd.brunner.ninja/api/version >/dev/null diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..018d04c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.kubeconfig +kubeconfig +kubeconfig.* +.env +*-secret.yaml +.DS_Store diff --git a/README.md b/README.md index 9065fac..636426e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,25 @@ -# argocd-deployment +# Argo CD on Kubernetes +Argo CD is pinned to chart `10.7.2` (Argo CD `v3.5.2`) and deployed in +`argocd`. CRDs, notifications, Dex, and the chart-supported Redis backend stay +enabled. Login is delegated directly to the Authentik OIDC provider at +`/application/o/argocd/`; members of `authentik Admins` receive Argo CD admin +access. The committed values deliberately render no Secrets: existing +`argocd-secret`, `argocd-notifications-secret`, `argocd-redis`, and +`argocd-oidc` stay inside Kubernetes and continue to be consumed by the +enabled components. + +Run `./install.sh` for administrator bootstrap or `./test.sh` for local chart +rendering and schema validation. Gitea Actions validates every change and +updates only exact existing Argo CD objects and its three exact CRDs on `main`; +it cannot read Secrets, create objects, or delete objects. Because it manages +privileged Argo CD workloads and their exact RBAC objects, protect and review +`main`. + +Create the CI identity with `./create-ci-kubeconfig.sh` (or set +`KUBECTL_SSH_HOST=arschrock`), then store only its final line as the repository +Actions secret `KUBE_CONFIG_BASE64`. + +The Authentik repository's administrator install provisions and synchronizes +the OIDC client secret into `authentik/authentik-runtime` and +`argocd/argocd-oidc`. CI can reference that Secret but cannot read or alter it. diff --git a/argocd-values.yaml b/argocd-values.yaml new file mode 100644 index 0000000..d62af22 --- /dev/null +++ b/argocd-values.yaml @@ -0,0 +1,43 @@ +global: + domain: cd.brunner.ninja + +crds: + install: true + +configs: + cm: + oidc.config: | + name: Authentik + issuer: https://auth.brunner.ninja/application/o/argocd/ + clientID: argocd + clientSecret: $argocd-oidc:client-secret + requestedScopes: ["openid", "profile", "email", "groups"] + requestedIDTokenClaims: + groups: + essential: true + rbac: + policy.csv: | + g, authentik Admins, role:admin + policy.default: '' + scopes: '[groups]' + secret: + # Preserve the existing argocd-secret; no secret values belong in Git. + createSecret: false + params: + server.insecure: true + +notifications: + secret: + # Preserve the existing argocd-notifications-secret. + create: false + +redisSecretInit: + # Preserve the existing argocd-redis Secret without giving CI Secret access. + enabled: false + +server: + ingress: + enabled: true +# ingressClassName: traefik + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure diff --git a/ci-deployer.yaml b/ci-deployer.yaml new file mode 100644 index 0000000..d382930 --- /dev/null +++ b/ci-deployer.yaml @@ -0,0 +1,82 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: argocd-deployer + namespace: argocd +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: argocd-deployer +rules: + - apiGroups: [apiextensions.k8s.io] + resources: [customresourcedefinitions] + resourceNames: [applications.argoproj.io, applicationsets.argoproj.io, appprojects.argoproj.io] + verbs: [get, patch, update] + - apiGroups: [networking.k8s.io] + resources: [networkpolicies] + resourceNames: [argocd-application-controller, argocd-notifications-controller, argocd-repo-server, argocd-server, argocd-dex-server, argocd-redis] + verbs: [get, patch, update] + - apiGroups: [""] + resources: [serviceaccounts] + resourceNames: [argocd-application-controller, argocd-applicationset-controller, argocd-notifications-controller, argocd-repo-server, argocd-server, argocd-dex-server] + verbs: [get, patch, update] + - apiGroups: [""] + resources: [configmaps] + resourceNames: [argocd-cm, argocd-cmd-params-cm, argocd-gpg-keys-cm, argocd-notifications-cm, argocd-rbac-cm, argocd-ssh-known-hosts-cm, argocd-tls-certs-cm, argocd-redis-health-configmap] + verbs: [get, patch, update] + - apiGroups: [""] + resources: [services] + resourceNames: [argocd-applicationset-controller, argocd-repo-server, argocd-server, argocd-dex-server, argocd-redis] + verbs: [get, patch, update] + - apiGroups: [apps] + resources: [deployments] + resourceNames: [argocd-applicationset-controller, argocd-notifications-controller, argocd-repo-server, argocd-server, argocd-dex-server, argocd-redis] + verbs: [get, patch, update] + - apiGroups: [apps] + resources: [statefulsets] + resourceNames: [argocd-application-controller] + verbs: [get, patch, update] + - apiGroups: [networking.k8s.io] + resources: [ingresses] + resourceNames: [argocd-server] + verbs: [get, patch, update] + - apiGroups: [rbac.authorization.k8s.io] + resources: [clusterroles] + resourceNames: [argocd-application-controller, argocd-notifications-controller, argocd-server] + verbs: [get, patch, update, escalate, bind] + - apiGroups: [rbac.authorization.k8s.io] + resources: [clusterrolebindings] + resourceNames: [argocd-application-controller, argocd-notifications-controller, argocd-server] + verbs: [get, patch, update] + - apiGroups: [rbac.authorization.k8s.io] + resources: [roles] + resourceNames: [argocd-application-controller, argocd-applicationset-controller, argocd-notifications-controller, argocd-repo-server, argocd-server, argocd-dex-server] + verbs: [get, patch, update, escalate, bind] + - apiGroups: [rbac.authorization.k8s.io] + resources: [rolebindings] + resourceNames: [argocd-application-controller, argocd-applicationset-controller, argocd-notifications-controller, argocd-repo-server, argocd-server, argocd-dex-server] + verbs: [get, patch, update] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: argocd-deployer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: argocd-deployer +subjects: + - kind: ServiceAccount + name: argocd-deployer + namespace: argocd +--- +apiVersion: v1 +kind: Secret +metadata: + name: argocd-deployer-token + namespace: argocd + annotations: + kubernetes.io/service-account.name: argocd-deployer +type: kubernetes.io/service-account-token diff --git a/create-ci-kubeconfig.sh b/create-ci-kubeconfig.sh new file mode 100755 index 0000000..0bc8cb7 --- /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=${NAMESPACE:-argocd} +service_account=argocd-deployer +secret=argocd-deployer-token +ssh_host=${KUBECTL_SSH_HOST:-} +cluster_kubectl() { if [[ -n "${ssh_host}" ]]; then ssh "${ssh_host}" kubectl "$@"; 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 + token_data=$(cluster_kubectl -n "${namespace}" get secret "${secret}" -o jsonpath='{.data.token}' 2>/dev/null || true) + [[ -n "${token_data}" ]] && break + [[ "${attempt}" == 30 ]] && { echo "Timed out waiting for token" >&2; exit 1; } + sleep 1 +done +workdir=$(mktemp -d); trap 'rm -rf "${workdir}"' 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 > "${workdir}/ca.crt" +token=$(printf '%s' "${token_data}" | base64 -d) +export KUBECONFIG="${workdir}/config" +kubectl config set-cluster cluster --server="${server}" --certificate-authority="${workdir}/ca.crt" --embed-certs=true >/dev/null +kubectl config set-credentials "${service_account}" --token="${token}" >/dev/null +kubectl config set-context argocd --cluster=cluster --user="${service_account}" --namespace="${namespace}" >/dev/null +kubectl config use-context argocd >/dev/null +echo "Store the following line as KUBE_CONFIG_BASE64; do not commit it." >&2 +base64 -w0 "${KUBECONFIG}"; printf '\n' diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..67ac5fd --- /dev/null +++ b/install.sh @@ -0,0 +1,59 @@ +#!/bin/sh + +set -eu + +chart_version=10.7.2 +chart_sha256=26111ae91779b28f18ef5c367f70530e3ecbec3effad45a7db59979344956dab +project_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +for command in helm kubectl openssl base64 curl sha256sum; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command not found: $command" >&2 + exit 1 + fi +done + +kubectl create namespace argocd --dry-run=client --output=yaml | kubectl apply --filename=- + +if ! kubectl --namespace argocd get secret argocd-secret >/dev/null 2>&1; then + kubectl --namespace argocd create secret generic argocd-secret +fi +if [ -z "$(kubectl --namespace argocd get secret argocd-secret --output='jsonpath={.data.server\.secretkey}')" ]; then + secret_key=$(openssl rand -hex 32) + secret_key_base64=$(printf '%s' "$secret_key" | base64 | tr -d '\n') + secret_patch=$(mktemp) + chmod 600 "$secret_patch" + printf '{"data":{"server.secretkey":"%s"}}\n' "$secret_key_base64" > "$secret_patch" + kubectl --namespace argocd patch secret argocd-secret \ + --type=merge --patch-file="$secret_patch" >/dev/null + rm -f "$secret_patch" + unset secret_key secret_key_base64 +fi +kubectl --namespace argocd label secret argocd-secret \ + app.kubernetes.io/name=argocd-secret \ + app.kubernetes.io/part-of=argocd --overwrite >/dev/null +if ! kubectl --namespace argocd get secret argocd-notifications-secret >/dev/null 2>&1; then + kubectl --namespace argocd create secret generic argocd-notifications-secret +fi +if ! kubectl --namespace argocd get secret argocd-redis >/dev/null 2>&1; then + redis_password=$(openssl rand -hex 32) + kubectl --namespace argocd create secret generic argocd-redis \ + --from-literal="auth=${redis_password}" +fi +if ! kubectl --namespace argocd get secret argocd-oidc >/dev/null 2>&1; then + echo "Missing argocd/argocd-oidc; run ../authentik/install.sh first." >&2 + exit 1 +fi + +chart_archive=$(mktemp) +trap 'rm -f "$chart_archive"' EXIT HUP INT TERM +curl --fail --silent --show-error --location --output "$chart_archive" \ + "https://github.com/argoproj/argo-helm/releases/download/argo-cd-${chart_version}/argo-cd-${chart_version}.tgz" +printf '%s %s\n' "$chart_sha256" "$chart_archive" | sha256sum --check >&2 + +helm upgrade --install argocd "$chart_archive" \ + --namespace argocd \ + --version "$chart_version" \ + --values "$project_dir/argocd-values.yaml" \ + --wait \ + --timeout 10m diff --git a/render.sh b/render.sh new file mode 100755 index 0000000..9654e63 --- /dev/null +++ b/render.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +version=${ARGOCD_CHART_VERSION:-10.7.2} +digest=${ARGOCD_CHART_SHA256:-26111ae91779b28f18ef5c367f70530e3ecbec3effad45a7db59979344956dab} +output=${1:?Usage: render.sh OUTPUT_FILE} +chart=$(mktemp) +trap 'rm -f "${chart}"' EXIT +curl --fail --silent --show-error --location --output "${chart}" \ + "https://github.com/argoproj/argo-helm/releases/download/argo-cd-${version}/argo-cd-${version}.tgz" +printf '%s %s\n' "${digest}" "${chart}" | sha256sum --check >&2 +helm template argocd "${chart}" --namespace argocd \ + --values "${project_dir}/argocd-values.yaml" > "${output}" diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..8c5c75f --- /dev/null +++ b/test.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +workdir=$(mktemp -d); trap 'rm -rf "${workdir}"' EXIT +sh -n "${project_dir}/install.sh" +bash -n "${project_dir}/render.sh" "${project_dir}/create-ci-kubeconfig.sh" "${project_dir}/test.sh" +"${project_dir}/render.sh" "${workdir}/argocd.yaml" +if grep -Eq '^kind: Secret$' "${workdir}/argocd.yaml"; then + echo "Rendered Argo CD state must not contain Secrets" >&2; exit 1 +fi +image=ghcr.io/yannh/kubeconform:v0.7.0@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c +validate() { if command -v kubeconform >/dev/null; then kubeconform -strict -exit-on-error -summary "$@"; else local file=${!#}; docker run --rm -i "${image}" -strict -exit-on-error -summary "${@:1:$#-1}" < "$file"; fi; } +validate -ignore-missing-schemas "${workdir}/argocd.yaml" +validate "${project_dir}/ci-deployer.yaml" +echo "Argo CD chart and CI resources are valid."