Introduce CI/CD
Validate and deploy Argo CD / validate (push) Successful in 16s
Validate and deploy Argo CD / deploy (push) Successful in 29s

This commit is contained in:
2026-09-05 09:35:59 +02:00
parent 43c4354cd1
commit 3359b7913b
9 changed files with 334 additions and 1 deletions
+66
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
*.kubeconfig
kubeconfig
kubeconfig.*
.env
*-secret.yaml
.DS_Store
+24 -1
View File
@@ -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.
+43
View File
@@ -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
+82
View File
@@ -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
+26
View File
@@ -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'
Executable
+59
View File
@@ -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
Executable
+13
View File
@@ -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}"
Executable
+15
View File
@@ -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."