From fbacd9845b46fcbf4d06fe2c9ffa98479b1e8f3f Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Mon, 19 Oct 2026 08:43:29 +0200 Subject: [PATCH] Add Argo CD Helm chart release --- .gitea/workflows/deploy.yml | 103 ----------------- .gitea/workflows/publish.yml | 97 ++++++++++++++++ Chart.yaml | 3 +- README.md | 217 ++++------------------------------- install.sh | 30 ++--- templates/deployment.yaml | 3 +- test.sh | 6 +- values-brunner-ninja.yaml | 30 ----- values.schema.json | 4 +- values.yaml | 1 - 10 files changed, 132 insertions(+), 362 deletions(-) delete mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitea/workflows/publish.yml delete mode 100644 values-brunner-ninja.yaml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml deleted file mode 100644 index 9b9eafd..0000000 --- a/.gitea/workflows/deploy.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: Validate and deploy error pages - -on: - push: - pull_request: - -env: - HELM_VERSION: v4.2.2 - KUBECTL_VERSION: v1.36.2 - KUBERNETES_API: https://host.containers.internal:6443 - KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 - NAMESPACE: default - RELEASE_NAME: traefik-error-pages - -jobs: - validate: - name: Validate manifests - runs-on: ubuntu-latest - steps: - - name: Check out source - uses: actions/checkout@v4 - - - name: Install Helm - run: | - curl --fail --silent --show-error --location \ - --output "${RUNNER_TEMP}/helm.tar.gz" \ - "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" - tar --extract --gzip --file "${RUNNER_TEMP}/helm.tar.gz" \ - --directory "${RUNNER_TEMP}" linux-amd64/helm - mv "${RUNNER_TEMP}/linux-amd64/helm" "${RUNNER_TEMP}/helm" - chmod 0700 "${RUNNER_TEMP}/helm" - - - name: Lint and render - run: | - export PATH="${RUNNER_TEMP}:${PATH}" - ./test.sh - - deploy: - name: Deploy to Kubernetes - if: gitea.ref == 'refs/heads/main' - needs: validate - runs-on: ubuntu-latest - steps: - - name: Check out source - uses: actions/checkout@v4 - - - name: Install Helm and kubectl - run: | - curl --fail --silent --show-error --location \ - --output "${RUNNER_TEMP}/helm.tar.gz" \ - "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" - tar --extract --gzip --file "${RUNNER_TEMP}/helm.tar.gz" \ - --directory "${RUNNER_TEMP}" linux-amd64/helm - mv "${RUNNER_TEMP}/linux-amd64/helm" "${RUNNER_TEMP}/helm" - chmod 0700 "${RUNNER_TEMP}/helm" - - curl --fail --silent --show-error --location \ - --output "${RUNNER_TEMP}/kubectl" \ - "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" - 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 --decode > "${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" - "${RUNNER_TEMP}/helm" template "${RELEASE_NAME}" . \ - --namespace "${NAMESPACE}" \ - --values values-brunner-ninja.yaml > "${RUNNER_TEMP}/rendered.yaml" - "${RUNNER_TEMP}/kubectl" apply \ - --validate=false \ - --filename "${RUNNER_TEMP}/rendered.yaml" - - for attempt in {1..24}; do - IFS='|' read -r generation observed desired updated available unavailable <<< "$( - "${RUNNER_TEMP}/kubectl" --namespace "${NAMESPACE}" \ - get deployment/traefik-error-pages \ - --output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.availableReplicas}|{.status.unavailableReplicas}' - )" - - echo "Rollout ${attempt}/24: generation ${observed}/${generation}, replicas ${updated}/${desired} updated, ${available}/${desired} available" - if [[ "${observed}" == "${generation}" \ - && "${updated}" == "${desired}" \ - && "${available}" == "${desired}" \ - && ( -z "${unavailable}" || "${unavailable}" == "0" ) ]]; then - echo "Error-page deployment successfully rolled out" - exit 0 - fi - sleep 5 - done - - echo "Error-page rollout did not complete within two minutes" >&2 - exit 1 diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..da5c395 --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,97 @@ +name: Validate, publish chart and promote Traefik error pages + +on: + push: + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Helm + run: | + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/helm.tar.gz" \ + https://get.helm.sh/helm-v4.2.2-linux-amd64.tar.gz + tar -xzf "${RUNNER_TEMP}/helm.tar.gz" -C "${RUNNER_TEMP}" linux-amd64/helm + mv "${RUNNER_TEMP}/linux-amd64/helm" "${RUNNER_TEMP}/helm" + chmod 0700 "${RUNNER_TEMP}/helm" + echo "${RUNNER_TEMP}" >> "$GITHUB_PATH" + - run: ./test.sh + + publish: + if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Helm + run: | + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/helm.tar.gz" \ + https://get.helm.sh/helm-v4.2.2-linux-amd64.tar.gz + tar -xzf "${RUNNER_TEMP}/helm.tar.gz" -C "${RUNNER_TEMP}" linux-amd64/helm + mv "${RUNNER_TEMP}/linux-amd64/helm" "${RUNNER_TEMP}/helm" + chmod 0700 "${RUNNER_TEMP}/helm" + echo "${RUNNER_TEMP}" >> "$GITHUB_PATH" + - id: chart + name: Derive chart version + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + set -euo pipefail + git fetch --tags origin main + test "$(git rev-parse origin/main)" = "$COMMIT_SHA" + description=$(git describe --tags --long --match 'v[0-9]*' --dirty) + case "$description" in *-dirty) echo 'Refusing dirty chart tree' >&2; exit 1 ;; esac + version=${description#v} + case "$version" in *-0-g*) version=${version%%-0-g*} ;; esac + echo "version=${version}" >> "$GITHUB_OUTPUT" + - name: Package and publish + env: + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + CHART_VERSION: ${{ steps.chart.outputs.version }} + run: | + set -euo pipefail + test -n "$PACKAGE_USERNAME" && test -n "$PACKAGE_TOKEN" + helm lint . + helm template traefik-error-pages . --namespace default >/dev/null + helm package . --version "$CHART_VERSION" --destination "$RUNNER_TEMP" + curl --fail --silent --show-error \ + --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \ + --request POST --upload-file "${RUNNER_TEMP}/traefik-error-pages-${CHART_VERSION}.tgz" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + - name: Promote GitOps chart version + env: + GITOPS_TOKEN: ${{ secrets.GITOPS_TOKEN }} + CHART_VERSION: ${{ steps.chart.outputs.version }} + COMMIT_SHA: ${{ gitea.sha }} + run: | + set -euo pipefail + test -n "$GITOPS_TOKEN" + test "$(git rev-parse origin/main)" = "$COMMIT_SHA" + auth=$(printf 'feedc0de:%s' "$GITOPS_TOKEN" | base64 | tr -d '\n') + git -c "http.extraheader=AUTHORIZATION: basic ${auth}" clone --depth=1 \ + https://code.brunner.ninja/feedc0de/homelab-gitops.git "$RUNNER_TEMP/gitops" + unset auth + cd "$RUNNER_TEMP/gitops" + gitops_auth=$(printf 'feedc0de:%s' "$GITOPS_TOKEN" | base64 | tr -d '\n') + unset GITOPS_TOKEN + git config user.name feedc0de + git config user.email feedc0de@users.noreply.code.brunner.ninja + sed -i -E "0,/targetRevision: [^ ]+/s//targetRevision: ${CHART_VERSION}/" bootstrap/traefik-error-pages.yaml + if git diff --quiet; then exit 0; fi + git add bootstrap/traefik-error-pages.yaml + git commit -m "Deploy Traefik error pages chart ${CHART_VERSION}" + for attempt in 1 2 3; do + if git -c "http.extraheader=AUTHORIZATION: basic ${gitops_auth}" push origin HEAD:main; then exit 0; fi + git -c "http.extraheader=AUTHORIZATION: basic ${gitops_auth}" fetch origin main + git rebase origin/main + done + exit 1 diff --git a/Chart.yaml b/Chart.yaml index 99b9138..a2c39c1 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -3,5 +3,4 @@ name: traefik-error-pages description: Cloudflare-style upstream error pages for Traefik on Kubernetes type: application version: 0.1.0 -appVersion: "1.0.0" - +appVersion: "1.30.6-alpine" diff --git a/README.md b/README.md index 3e520f6..c8b3418 100644 --- a/README.md +++ b/README.md @@ -1,205 +1,30 @@ # Traefik error pages -A small, portable Kubernetes deployment that serves a Cloudflare-style -diagnostic page for reverse-proxy failures while preserving the original HTTP -status code. +Maintained Helm chart serving a Cloudflare-style diagnostic page while +preserving upstream HTTP status codes. The chart owns its ConfigMap, Nginx +Deployment and Service, Traefik Errors middleware, and optional ForwardAuth +chain. -Traefik's native Errors middleware matches status codes only; it cannot tell a -Traefik transport failure from an application's intentional JSON or HTML 5xx. -The brunner.ninja Traefik release therefore uses the local -`upstreamErrorPages` filter in `../traefik/upstream-error-pages-plugin.yaml`. -It replaces only Traefik's exact plain-text upstream failure responses and -passes application responses through unchanged. +The brunner.ninja branding and Authentik integration are cluster-specific and +live in the separate +[homelab-gitops application](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/traefik-error-pages). +No credentials, PVCs, certificates, or public Ingress are required by this +chart. The global Traefik error-page plugin is configured separately by the +Traefik installation. -The page reports the browser, reverse proxy, optional ForwardAuth provider, and -upstream states. Routes without ForwardAuth omit the authentication stage -entirely. Every generated response includes an `X-Error-ID` header and the same -identifier in the HTML footer. - -## Repository contents - -- `values.yaml`: portable defaults with authentication integration disabled. -- `values-brunner-ninja.yaml`: the live brunner.ninja branding and Authentik - integration. -- `templates/`: ConfigMap, nginx Deployment, Service, Traefik Errors middleware, - and optional ForwardAuth chain. -- `install.sh`: validates, renders, applies, and waits for a healthy rollout. -- `test.sh`: renders both the generic and brunner.ninja configurations locally. -- `ci-deployer.yaml`: restricted Gitea Actions ServiceAccount and token request. -- `create-ci-kubeconfig.sh`: bootstraps that identity and prints the one-line CI - kubeconfig secret. -- `.gitea/workflows/deploy.yml`: validates every push and deploys `main`. -- `examples/`: global and per-Ingress Traefik attachment examples. - -No credential value or generated kubeconfig belongs in this repository. - -## Prerequisites - -- Kubernetes with a working Traefik installation. -- Traefik's `middlewares.traefik.io` CRD. -- Helm 3 or 4 for local rendering. -- kubectl access with permission to create the initial namespaced resources. - -The nginx image uses a readable version tag. No registry credentials, persistent -volumes, cert-manager resources, or public Ingress are required. - -## Install on brunner.ninja - -Run the first installation with an administrator kubeconfig: +## Install elsewhere ```sh -./install.sh +helm repo add brunner.ninja https://brunner.ninja/charts +helm repo update brunner.ninja +helm upgrade --install traefik-error-pages brunner.ninja/traefik-error-pages \ + --namespace default --wait --timeout 5m ``` -The script uses namespace `default` and `values-brunner-ninja.yaml` by default. -It performs a server-side dry run before changing anything, then waits for the -new pod to become Ready. +Main-branch CI validates the chart, publishes a Git-describe-derived chart +version to the public Helm repository, and promotes that exact version in +homelab-gitops. It has no Kubernetes credential. The workflow uses +`PACKAGE_USERNAME`, `PACKAGE_TOKEN`, and `GITOPS_TOKEN` repository secrets. -To use another namespace or values file: - -```sh -NAMESPACE=traefik VALUES_FILE=values.yaml ./install.sh -``` - -The target namespace must already exist. If `fullnameOverride` is changed, -also pass the resulting Deployment name through `DEPLOYMENT_NAME`. - -## Connect it to Traefik - -Installing the chart creates the error backend and a native Errors middleware -but does not edit an independently managed Traefik release. - -For brunner.ninja, install the upstream-only local plugin with the Traefik -release and attach that filter globally: - -```yaml -ports: - websecure: - http: - middlewares: - - default-upstream-error-pages@kubernetescrd -``` - -See `../traefik/traefik-values.yaml` for the plugin mount and static plugin -registration. Restart or upgrade Traefik after changing its static entrypoint -configuration. - -On clusters where replacing every application 5xx is acceptable, the native -middleware can still be attached to selected routes using the annotation shown -in `examples/ingress.yaml`, or added to an IngressRoute: - -```yaml -middlewares: - - name: upstream-errors - namespace: default -``` - -The native middleware preserves the original status, asks this service for -`/{status}.html`, replaces the body, and returns the original 5xx code. Do not -attach it globally when routes serve APIs: it will replace their JSON 5xx -bodies too. - -## Optional ForwardAuth diagnostics - -Generic installations leave `auth.enabled: false`. Direct upstream failures -then render three stages and contain no authentication-provider wording. - -The brunner.ninja values enable a generated chain named `authentik`: - -1. `authentik-error-context` marks the request and clears spoofed completion - headers. -2. `authentik-forward` calls Authentik. -3. `authentik-error-checked` marks a successful access check. - -Existing routes continue to reference: - -```text -default-authentik@kubernetescrd -``` - -If Authentik fails, the page shows `brunner.ninja Auth` as failed and the -upstream as not accessed. If Authentik succeeds but the application fails, Auth -is shown as working. Customize these resources through the `auth` and -`branding` sections of the values file. - -## Validate and update manually - -Run local rendering tests before committing: - -```sh -./test.sh -``` - -Update `values-brunner-ninja.yaml` or a template, then deploy with the same -idempotent command used for installation: - -```sh -./install.sh -``` - -The Deployment contains a hash of the rendered ConfigMap. HTML or nginx changes -therefore create a rolling update automatically. With one replica, -`maxUnavailable: 0` keeps the old healthy pod until the replacement passes its -readiness probe. A broken nginx configuration should fail the rollout without -removing the last working pod. - -Rollback by reverting the Git commit and running `./install.sh` again. - -## Automatic deployment with Gitea Actions - -The workflow validates all pushes and pull requests. A successful push to -`main` renders `values-brunner-ninja.yaml`, applies the fixed set of resources, -and polls the Deployment for up to two minutes. - -CI deliberately does not use `helm upgrade`. It cannot read or write Helm -release Secrets. Its Role can only patch the already-created ConfigMap, -Deployment, Service, and five named Traefik Middleware objects. It cannot create -or delete workloads, read Secrets, or modify Traefik itself. - -### One-time CI setup - -1. Install the application manually with `./install.sh`. -2. With your administrator kubeconfig active, generate the restricted CI - kubeconfig: - - ```sh - ./create-ci-kubeconfig.sh - ``` - - The script applies `ci-deployer.yaml`, waits for Kubernetes to populate the - service-account token, and prints one base64 line. - -3. Create the Gitea Actions repository secret `KUBE_CONFIG_BASE64` and paste - that line into it. Never add the output to a file in Git. -4. Confirm `KUBERNETES_API` and `KUBERNETES_TLS_SERVER_NAME` in - `.gitea/workflows/deploy.yml` match the networking seen by the runner. -5. Protect the `main` branch. Merging to it changes the globally used error - backend. - -The committed `ci-deployer.yaml` is safe: it contains only RBAC declarations -and an empty service-account-token Secret request. Kubernetes populates the -actual credential only inside the cluster. - -To revoke the CI credential: - -```sh -kubectl --namespace default delete secret traefik-error-pages-deployer-token -``` - -Remove or replace `KUBE_CONFIG_BASE64` in Gitea immediately afterward. Running -`./create-ci-kubeconfig.sh` again recreates a token and prints a replacement -kubeconfig. - -## Making another cluster-specific configuration - -Copy the generic values and edit only the override: - -```sh -cp values.yaml values-my-cluster.yaml -./test.sh values-my-cluster.yaml -``` - -In practice, keep `values.yaml` as the reusable base and create a small override -containing only branding and optional ForwardAuth settings, like -`values-brunner-ninja.yaml`. Do not put API tokens, passwords, TLS keys, or -kubeconfigs into a values file. +Run `./test.sh` for local lint/render tests. The image version is the chart's +`appVersion`; the chart's own Git tag versions the Helm package. diff --git a/install.sh b/install.sh index 014d25f..62a678b 100755 --- a/install.sh +++ b/install.sh @@ -5,27 +5,17 @@ set -euo pipefail project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) namespace=${NAMESPACE:-default} release_name=${RELEASE_NAME:-traefik-error-pages} -values_file=${VALUES_FILE:-values-brunner-ninja.yaml} -deployment_name=${DEPLOYMENT_NAME:-traefik-error-pages} - -case "${values_file}" in - /*) ;; - *) values_file="${project_dir}/${values_file}" ;; -esac - -rendered=$(mktemp) -trap 'rm -f "${rendered}"' EXIT kubectl get crd middlewares.traefik.io >/dev/null -helm lint "${project_dir}" --values "${values_file}" -helm template "${release_name}" "${project_dir}" \ - --namespace "${namespace}" \ - --values "${values_file}" > "${rendered}" - -kubectl apply --dry-run=server --filename "${rendered}" -kubectl apply --filename "${rendered}" -kubectl --namespace "${namespace}" rollout status \ - "deployment/${deployment_name}" --timeout=120s +helm repo add brunner.ninja https://brunner.ninja/charts --force-update +helm repo update brunner.ninja +if [[ -n "${VALUES_FILE:-}" ]]; then + helm upgrade --install "${release_name}" brunner.ninja/traefik-error-pages \ + --namespace "${namespace}" --create-namespace \ + --values "${VALUES_FILE}" --wait --timeout 5m +else + helm upgrade --install "${release_name}" brunner.ninja/traefik-error-pages \ + --namespace "${namespace}" --create-namespace --wait --timeout 5m +fi printf 'Installed %s in namespace %s\n' "${release_name}" "${namespace}" - diff --git a/templates/deployment.yaml b/templates/deployment.yaml index 7149048..26de825 100644 --- a/templates/deployment.yaml +++ b/templates/deployment.yaml @@ -29,7 +29,7 @@ spec: type: RuntimeDefault containers: - name: nginx - image: {{ printf "%s:%s" .Values.image.repository .Values.image.tag | quote }} + image: {{ printf "%s:%s" .Values.image.repository .Chart.AppVersion | quote }} imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - name: http @@ -78,4 +78,3 @@ spec: path: error-page.html - name: tmp emptyDir: {} - diff --git a/test.sh b/test.sh index 2638486..18d6076 100755 --- a/test.sh +++ b/test.sh @@ -9,7 +9,7 @@ trap 'rm -rf "${workdir}"' EXIT if (( $# > 0 )); then values_files=("$@") else - values_files=(values.yaml values-brunner-ninja.yaml) + values_files=(values.yaml) fi for values_file in "${values_files[@]}"; do @@ -29,10 +29,6 @@ for values_file in "${values_files[@]}"; do fi done -if [[ -f "${workdir}/values-brunner-ninja.yaml" ]]; then - grep --quiet 'brunner\.ninja Reverse Proxy' "${workdir}/values-brunner-ninja.yaml" - grep --quiet '^ name: authentik$' "${workdir}/values-brunner-ninja.yaml" -fi if [[ -f "${workdir}/values.yaml" ]]; then if grep --quiet '^ name: error-auth$' "${workdir}/values.yaml"; then echo "Generic values unexpectedly enabled the authentication middleware" >&2 diff --git a/values-brunner-ninja.yaml b/values-brunner-ninja.yaml deleted file mode 100644 index b3b32f9..0000000 --- a/values-brunner-ninja.yaml +++ /dev/null @@ -1,30 +0,0 @@ -branding: - reverseProxyName: brunner.ninja Reverse Proxy - authenticationName: brunner.ninja Auth - clusterReachedText: The request reached the basement cluster - -auth: - enabled: true - marker: authentik - contextMiddlewareName: authentik-error-context - forwardMiddlewareName: authentik-forward - checkedMiddlewareName: authentik-error-checked - # Preserve the public middleware name used by existing routes: - # default-authentik@kubernetescrd - chainMiddlewareName: authentik - forwardAddress: http://authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik - trustForwardHeader: true - responseHeaders: - - X-authentik-username - - X-authentik-groups - - X-authentik-entitlements - - X-authentik-email - - X-authentik-name - - X-authentik-uid - - X-authentik-jwt - - X-authentik-meta-jwks - - X-authentik-meta-outpost - - X-authentik-meta-provider - - X-authentik-meta-app - - X-authentik-meta-version - diff --git a/values.schema.json b/values.schema.json index 74ef37a..73482e2 100644 --- a/values.schema.json +++ b/values.schema.json @@ -7,10 +7,9 @@ "replicaCount": {"type": "integer", "minimum": 1}, "image": { "type": "object", - "required": ["repository", "tag", "pullPolicy"], + "required": ["repository", "pullPolicy"], "properties": { "repository": {"type": "string", "minLength": 1}, - "tag": {"type": "string", "minLength": 1}, "pullPolicy": {"enum": ["Always", "IfNotPresent", "Never"]} } }, @@ -56,4 +55,3 @@ } } } - diff --git a/values.yaml b/values.yaml index f13d1dc..bcd881c 100644 --- a/values.yaml +++ b/values.yaml @@ -6,7 +6,6 @@ replicaCount: 1 image: repository: nginxinc/nginx-unprivileged - tag: 1.29.4-alpine pullPolicy: IfNotPresent branding: