Add Argo CD Helm chart release
Validate, publish chart and promote Traefik error pages / publish (push) Successful in 20s
Validate, publish chart and promote Traefik error pages / validate (push) Successful in 15s

This commit is contained in:
feedc0de committed 2026-10-19 08:43:29 +02:00
1 parent e17bcb25c6
commit fbacd9845b
10 files changed
+132 -362

No files matched your search

-103
View File
@@ -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
+97
View File
@@ -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
+1 -2
View File
@@ -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"
+21 -196
View File
@@ -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.
+10 -20
View File
@@ -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}"
+1 -2
View File
@@ -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: {}
+1 -5
View File
@@ -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
-30
View File
@@ -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
+1 -3
View File
@@ -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 @@
}
}
}
-1
View File
@@ -6,7 +6,6 @@ replicaCount: 1
image:
repository: nginxinc/nginx-unprivileged
tag: 1.29.4-alpine
pullPolicy: IfNotPresent
branding: