From ff40a71145bb32c3f987cbec42a4ee25affd74bc Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 6 Oct 2026 15:58:44 +0200 Subject: [PATCH 1/7] fix(deploy): resolve Compose config and check namespaced pod Secrets --- .gitea/tests/deploy-validation.sh | 80 +++++++++++++++++++++++++++ .gitea/workflows/ci.yaml | 1 + .gitea/workflows/compose-lint.sh | 3 +- .gitea/workflows/deploy-lib.sh | 77 +++++++++++++------------- .gitea/workflows/secret-references.jq | 13 +++++ 5 files changed, 133 insertions(+), 41 deletions(-) create mode 100755 .gitea/tests/deploy-validation.sh create mode 100644 .gitea/workflows/secret-references.jq diff --git a/.gitea/tests/deploy-validation.sh b/.gitea/tests/deploy-validation.sh new file mode 100755 index 0000000..b03a429 --- /dev/null +++ b/.gitea/tests/deploy-validation.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Local regressions only: kubectl is mocked and Docker is used for config parsing. +set -euo pipefail +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +mkdir -p "$scratch/repo/app" "$scratch/repo/postgres" "$scratch/repo/netbird" "$scratch/repo/renovate" +git -C "$scratch/repo" init -q +for file in app/compose.yaml postgres/shared-compose.yaml netbird/client.compose.yaml renovate/renovate-compose.yaml; do + touch "$scratch/repo/$file" +done +git -C "$scratch/repo" add . +# shellcheck source=../workflows/compose-lint.sh +source "$repo/.gitea/workflows/compose-lint.sh" +actual="$(cd "$scratch/repo" && compose_files)" +expected=$'app/compose.yaml\nnetbird/client.compose.yaml\npostgres/shared-compose.yaml\nrenovate/renovate-compose.yaml' +[ "$actual" = "$expected" ] || { echo 'Compose discovery missed a file' >&2; exit 1; } + +cat >"$scratch/compose.yaml" <<'YAML' +services: + example: + image: busybox:1.37.0 + environment: + REQUIRED: ${HOMELAB_TEST_REQUIRED:?required for this regression} +YAML +unset HOMELAB_TEST_REQUIRED +if validate_compose_file "$scratch/compose.yaml" >"$scratch/config.log" 2>&1; then + echo 'Full Compose validation accepted a missing variable' >&2 + exit 1 +fi +rg -q 'required for this regression' "$scratch/config.log" +HOMELAB_TEST_REQUIRED=present validate_compose_file "$scratch/compose.yaml" + +cat >"$scratch/resources.json" <<'JSON' +{"kind":"List","items":[ + {"kind":"Deployment","metadata":{"namespace":"app"},"spec":{"template":{"spec":{ + "containers":[{"envFrom":[{"secretRef":{"name":"credentials"}},{"secretRef":{"name":"optional","optional":true}}],"env":[{"valueFrom":{"secretKeyRef":{"name":"credentials","key":"password"}}}]}], + "initContainers":[{"envFrom":[{"secretRef":{"name":"init"}}]}], + "imagePullSecrets":[{"name":"registry"}], + "volumes":[{"secret":{"secretName":"mounted"}},{"projected":{"sources":[{"secret":{"name":"projected"}},{"secret":{"name":"optional-projected","optional":true}}]}}] + }}}}, + {"kind":"CronJob","metadata":{},"spec":{"jobTemplate":{"spec":{"template":{"spec":{"containers":[{"envFrom":[{"secretRef":{"name":"cron"}}]}]}}}}}}, + {"kind":"IngressRoute","metadata":{"namespace":"app"},"spec":{"tls":{"secretName":"controller-issued-tls"}}} +]} +JSON +actual="$(jq -r -f "$repo/.gitea/workflows/secret-references.jq" "$scratch/resources.json" | sort)" +expected=$'app credentials\napp init\napp mounted\napp projected\napp registry\ndefault cron' +[ "$actual" = "$expected" ] || { echo "Unexpected Secret references: $actual" >&2; exit 1; } + +REPO="$repo" +# shellcheck source=../workflows/deploy-lib.sh +source "$repo/.gitea/workflows/deploy-lib.sh" +K8S_MANIFESTS=("$scratch/resources.json") +KUSTOMIZE_APPS=() +# No live cluster access. Reject credentials in app even if they exist elsewhere. +kubectl() { + case "$1" in + create) cat "$scratch/resources.json" ;; + get) + if [ "$3" = credentials ] && [ "$5" = app ]; then + return 1 + fi + return 0 + ;; + *) echo "Unexpected kubectl invocation: $*" >&2; return 1 ;; + esac +} +if check_referenced_secrets >"$scratch/secrets.log"; then + echo 'Namespace-scoped Secret check accepted a missing Secret' >&2 + exit 1 +fi +rg -q 'MISSING OR UNREADABLE: app/credentials' "$scratch/secrets.log" +# API/rendering errors must not produce an empty reference list and pass. +kubectl() { return 1; } +if check_referenced_secrets >"$scratch/secrets.log"; then + echo 'Secret check accepted a failed manifest render' >&2 + exit 1 +fi +printf '%s\n' 'Deploy validation regressions passed.' diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 2480c13..232534d 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -98,6 +98,7 @@ jobs: exit 0 fi shellcheck --external-sources --source-path=SCRIPTDIR --severity=style "${scripts[@]}" + bash .gitea/tests/deploy-validation.sh lint-prettier: runs-on: [self-hosted, linux, arch, homelab] diff --git a/.gitea/workflows/compose-lint.sh b/.gitea/workflows/compose-lint.sh index c27e29f..30e79bb 100644 --- a/.gitea/workflows/compose-lint.sh +++ b/.gitea/workflows/compose-lint.sh @@ -21,8 +21,7 @@ # All committed Compose files, including the ones deploy never starts. compose_files() { git ls-files \ - '*/compose.yaml' '*/compose.yml' 'compose.yaml' 'compose.yml' \ - '*/docker-compose.yaml' '*/docker-compose.yml' + '*compose.yaml' '*compose.yml' } # Prints the flags that turn `docker compose config` into the general check. diff --git a/.gitea/workflows/deploy-lib.sh b/.gitea/workflows/deploy-lib.sh index fa7452e..d96880e 100644 --- a/.gitea/workflows/deploy-lib.sh +++ b/.gitea/workflows/deploy-lib.sh @@ -686,27 +686,52 @@ stage_preflight() { git -C "$REPO" reset --hard "$target" } +# Required pod Secrets, scoped to the resource namespace. TLS route Secrets are +# created by cert-manager and are not prerequisites for applying a Certificate. +check_referenced_secrets() { + local m k objects refs extracted ns name + local missing=() + refs="" + for m in ${K8S_MANIFESTS[@]+"${K8S_MANIFESTS[@]}"}; do + objects="$(kubectl create --dry-run=client --validate=false -f "$m" -o json)" || return 1 + extracted="$(printf '%s' "$objects" | jq -r -f "$REPO/.gitea/workflows/secret-references.jq")" || return 1 + refs+="$extracted"$'\n' + done + for k in ${KUSTOMIZE_APPS[@]+"${KUSTOMIZE_APPS[@]}"}; do + objects="$(kubectl kustomize "$k" | kubectl create --dry-run=client --validate=false -f - -o json)" || return 1 + extracted="$(printf '%s' "$objects" | jq -r -f "$REPO/.gitea/workflows/secret-references.jq")" || return 1 + refs+="$extracted"$'\n' + done + while read -r ns name; do + [ -n "${name:-}" ] || continue + if kubectl get secret "$name" -n "$ns" -o name >/dev/null 2>&1; then + echo " ok: $ns/$name" + else + echo " MISSING OR UNREADABLE: $ns/$name" + missing+=("$ns/$name") + fi + done < <(printf '%s' "$refs" | sort -u) + if [ "${#missing[@]}" -gt 0 ]; then + echo "ERROR: required pod Secrets are missing or unreadable:" + printf ' - %s\n' "${missing[@]}" + echo "Create them in the listed namespaces from the service's secret example." + return 1 + fi +} + stage_validate() { cd "$REPO" select_manifests local m k cf - # Compose .env files and secret files are gitignored by design, so the - # workstation never has real values for the inactive stacks. This stage only - # runs the full check on active stacks; the general structure check for every - # committed Compose file (active or not) lives in the ci workflow, which has no - # .env at all. - # - # Active stacks are still validated with interpolation and env-file resolution - # off, so required-variable guards (:?) and missing local files do not fail the - # deploy. Normalization and consistency checks stay enabled. + # The deploy host has the local .env and secret files. Resolve them here so + # missing configuration fails before either apply job changes workloads. + # CI keeps the structure-only check for inactive stacks. # shellcheck source=compose-lint.sh source "$REPO/.gitea/workflows/compose-lint.sh" - local compose_validate_flags=() - mapfile -t compose_validate_flags < <(compose_safe_flags) log "Validate compose stacks" for cf in ${COMPOSE_STACKS[@]+"${COMPOSE_STACKS[@]}"}; do echo " config: $cf" - validate_compose_file "$cf" ${compose_validate_flags[@]+"${compose_validate_flags[@]}"} + validate_compose_file "$cf" done log "Validate k8s manifests (kubectl dry-run=client)" for m in ${K8S_MANIFESTS[@]+"${K8S_MANIFESTS[@]}"}; do @@ -724,33 +749,7 @@ stage_validate() { done log "Checking referenced Secrets exist" echo " (deploy never applies *secret*.yaml; create missing ones manually)" - local ref_secrets=() missing_secrets=() all_secrets s - if [ "${#K8S_MANIFESTS[@]}" -gt 0 ]; then - while IFS= read -r s; do - [ -n "$s" ] && ref_secrets+=("$s") - done < <( - { - grep -h -A1 -E 'secretRef:|secretKeyRef:' "${K8S_MANIFESTS[@]}" 2>/dev/null || true - grep -h -E 'secretName:' "${K8S_MANIFESTS[@]}" 2>/dev/null || true - } | grep -E 'name:' | sed -E 's/.*name:[[:space:]]*//' | tr -d '"'"'"' "'"'" | sed -E 's/[[:space:]]*#.*//' | awk 'NF' | sort -u || true - ) - fi - all_secrets="$(kubectl get secrets -A --no-headers -o custom-columns=:metadata.name 2>/dev/null || true)" - for s in ${ref_secrets[@]+"${ref_secrets[@]}"}; do - if printf '%s\n' "$all_secrets" | grep -qx "$s"; then - echo " ok: $s" - else - echo " MISSING: $s" - missing_secrets+=("$s") - fi - done - if [ "${#missing_secrets[@]}" -gt 0 ]; then - echo "ERROR: ${#missing_secrets[@]} referenced Secret(s) not found in the cluster:" - printf ' - %s\n' "${missing_secrets[@]}" - echo "Create them manually from the laptop, e.g.:" - echo " kubectl apply -f SERVICE/k8s/secrets.yaml # see SERVICE/k8s/secrets.yaml.example" - exit 1 - fi + check_referenced_secrets } stage_apply_k8s() { diff --git a/.gitea/workflows/secret-references.jq b/.gitea/workflows/secret-references.jq new file mode 100644 index 0000000..a205f61 --- /dev/null +++ b/.gitea/workflows/secret-references.jq @@ -0,0 +1,13 @@ +# kubectl emits a List for files containing multiple resources. +(if .kind == "List" then .items[] else . end) +| (.metadata.namespace // "default") as $ns +| [ + (.. | objects + | (.secretRef? // empty), (.secretKeyRef? // empty), (.secret? // empty) + | select(.optional != true) + | .name // .secretName // empty), + (.. | objects | .imagePullSecrets[]?.name) + ] +| unique[] +| select(. != null and . != "") +| "\($ns) \(.)" From 8d3185f8abceb86fed0ccfa262e5aa972c24fe33 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 6 Oct 2026 16:31:48 +0200 Subject: [PATCH 2/7] fix(ci): install jq for deploy validation regressions --- .gitea/tests/deploy-validation.sh | 4 ++-- .gitea/workflows/ci.yaml | 2 +- .gitea/workflows/install-ci-tools.sh | 10 ++++++++++ .gitea/workflows/tool-versions.env | 3 +++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.gitea/tests/deploy-validation.sh b/.gitea/tests/deploy-validation.sh index b03a429..94e6480 100755 --- a/.gitea/tests/deploy-validation.sh +++ b/.gitea/tests/deploy-validation.sh @@ -29,7 +29,7 @@ if validate_compose_file "$scratch/compose.yaml" >"$scratch/config.log" 2>&1; th echo 'Full Compose validation accepted a missing variable' >&2 exit 1 fi -rg -q 'required for this regression' "$scratch/config.log" +grep -q 'required for this regression' "$scratch/config.log" HOMELAB_TEST_REQUIRED=present validate_compose_file "$scratch/compose.yaml" cat >"$scratch/resources.json" <<'JSON' @@ -70,7 +70,7 @@ if check_referenced_secrets >"$scratch/secrets.log"; then echo 'Namespace-scoped Secret check accepted a missing Secret' >&2 exit 1 fi -rg -q 'MISSING OR UNREADABLE: app/credentials' "$scratch/secrets.log" +grep -q 'MISSING OR UNREADABLE: app/credentials' "$scratch/secrets.log" # API/rendering errors must not produce an empty reference list and pass. kubectl() { return 1; } if check_referenced_secrets >"$scratch/secrets.log"; then diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 232534d..b9f7ebc 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -88,7 +88,7 @@ jobs: shell: bash run: | set -euo pipefail - tools_dir="$(bash .gitea/workflows/install-ci-tools.sh shellcheck)" + tools_dir="$(bash .gitea/workflows/install-ci-tools.sh shellcheck jq)" export PATH="$tools_dir:$PATH" mapfile -t scripts < <( git ls-files '*.sh' ':(glob)**/*.bash' diff --git a/.gitea/workflows/install-ci-tools.sh b/.gitea/workflows/install-ci-tools.sh index 988fe64..2fcb3b3 100755 --- a/.gitea/workflows/install-ci-tools.sh +++ b/.gitea/workflows/install-ci-tools.sh @@ -120,6 +120,15 @@ install_shellcheck() { rm -rf "$tmp" } +install_jq() { + if at_version jq "${JQ_VERSION}"; then + return 0 + fi + fetch "https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-linux-${goarch}" \ + "$BIN_DIR/jq" + chmod 0755 "$BIN_DIR/jq" +} + install_uv() { if at_version uv "${UV_VERSION}"; then return 0 @@ -236,6 +245,7 @@ for tool in "${wanted[@]}"; do case "$tool" in kubeconform) install_kubeconform ;; shellcheck) install_shellcheck ;; + jq) install_jq ;; actionlint) install_actionlint ;; prettier) install_prettier ;; ruff) install_ruff ;; diff --git a/.gitea/workflows/tool-versions.env b/.gitea/workflows/tool-versions.env index d010495..cf8edb9 100644 --- a/.gitea/workflows/tool-versions.env +++ b/.gitea/workflows/tool-versions.env @@ -31,3 +31,6 @@ UV_VERSION="0.12.17" # so the tree that gets tested is the tree that gets built. Renovate keeps this # in step with the Dockerfile's node: tag via the "node runtime" group. NODE_VERSION="22.23.3" + +# Secret-reference regression tests parse rendered Kubernetes objects. +JQ_VERSION="1.8.1" From ff83daed1eb35366d7c6cc060c0fc31acb746437 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 6 Oct 2026 16:14:05 +0200 Subject: [PATCH 3/7] feat(reloader): enable deployment and reload runtime-config consumers --- adguardhome/k8s/adguard.yaml | 4 ++-- authentik/k8s/authentik.yaml | 4 ++++ cfddns/k8s/deployment.yaml | 2 ++ checkmk/k8s/checkmk.yaml | 2 ++ cloudflared/k8s/deployment.yaml | 2 ++ converters/k8s/convertx.yaml | 2 ++ edu_master/k8s/session-keeper.yaml | 2 ++ edu_master/k8s/webinar-checker.yaml | 2 ++ gitea/k8s/gitea.yaml | 2 ++ glance/k8s/glance.yaml | 2 ++ homarr/k8s/homarr.yaml | 2 ++ immich/k8s/immich.yaml | 2 ++ immich/k8s/machine-learning.yaml | 2 ++ immich/k8s/valkey.yaml | 2 ++ kener/k8s/kener.yaml | 2 ++ metube/k8s/metube.yaml | 2 ++ n8n/k8s/n8n.yaml | 2 ++ netbird/k8s/netbird.yaml | 4 ++++ netbox/k8s/netbox.yaml | 4 ++++ netbox/k8s/valkey.yaml | 2 ++ netronome/k8s/netronome.yaml | 2 ++ reloader/k8s/active | 0 reloader/k8s/reloader-values.yaml | 8 +++++++- searxng/k8s/searxng.yaml | 2 ++ termix/k8s/termix.yaml | 2 ++ vaultwarden/k8s/vaultwarden.yaml | 2 ++ vpn/xui/k8s/xui.yaml | 2 ++ 27 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 reloader/k8s/active diff --git a/adguardhome/k8s/adguard.yaml b/adguardhome/k8s/adguard.yaml index 290dfc4..40db3ea 100644 --- a/adguardhome/k8s/adguard.yaml +++ b/adguardhome/k8s/adguard.yaml @@ -51,6 +51,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: adguard-deployment namespace: adguard spec: @@ -64,8 +66,6 @@ spec: metadata: labels: app: adguard - annotations: - reloader.stakater.com/auto: "true" spec: containers: - name: adguard diff --git a/authentik/k8s/authentik.yaml b/authentik/k8s/authentik.yaml index 5cfcbdf..2afecb6 100644 --- a/authentik/k8s/authentik.yaml +++ b/authentik/k8s/authentik.yaml @@ -27,6 +27,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: authentik-server-deployment namespace: authentik spec: @@ -63,6 +65,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: authentik-worker-deployment namespace: authentik spec: diff --git a/cfddns/k8s/deployment.yaml b/cfddns/k8s/deployment.yaml index a5598ec..b10f2b0 100644 --- a/cfddns/k8s/deployment.yaml +++ b/cfddns/k8s/deployment.yaml @@ -1,6 +1,8 @@ apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: cfddns labels: app: cfddns diff --git a/checkmk/k8s/checkmk.yaml b/checkmk/k8s/checkmk.yaml index 3fb5fc2..ddb7095 100644 --- a/checkmk/k8s/checkmk.yaml +++ b/checkmk/k8s/checkmk.yaml @@ -17,6 +17,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: checkmk-deployment namespace: checkmk spec: diff --git a/cloudflared/k8s/deployment.yaml b/cloudflared/k8s/deployment.yaml index c164c46..7be3a6c 100644 --- a/cloudflared/k8s/deployment.yaml +++ b/cloudflared/k8s/deployment.yaml @@ -1,6 +1,8 @@ apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: cloudflared labels: app: cloudflared diff --git a/converters/k8s/convertx.yaml b/converters/k8s/convertx.yaml index bc81cc1..d5924b8 100644 --- a/converters/k8s/convertx.yaml +++ b/converters/k8s/convertx.yaml @@ -13,6 +13,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: convertx-deployment namespace: converters spec: diff --git a/edu_master/k8s/session-keeper.yaml b/edu_master/k8s/session-keeper.yaml index d9f5cda..f3c330d 100644 --- a/edu_master/k8s/session-keeper.yaml +++ b/edu_master/k8s/session-keeper.yaml @@ -1,6 +1,8 @@ apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: session-keeper namespace: edu-master labels: diff --git a/edu_master/k8s/webinar-checker.yaml b/edu_master/k8s/webinar-checker.yaml index a8f275e..3c53074 100644 --- a/edu_master/k8s/webinar-checker.yaml +++ b/edu_master/k8s/webinar-checker.yaml @@ -1,6 +1,8 @@ apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: webinar-checker namespace: edu-master labels: diff --git a/gitea/k8s/gitea.yaml b/gitea/k8s/gitea.yaml index 94b20a7..1b75c91 100644 --- a/gitea/k8s/gitea.yaml +++ b/gitea/k8s/gitea.yaml @@ -17,6 +17,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: gitea-deployment namespace: gitea spec: diff --git a/glance/k8s/glance.yaml b/glance/k8s/glance.yaml index b9b2707..14b3dac 100644 --- a/glance/k8s/glance.yaml +++ b/glance/k8s/glance.yaml @@ -13,6 +13,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: glance-deployment namespace: glance spec: diff --git a/homarr/k8s/homarr.yaml b/homarr/k8s/homarr.yaml index b09e754..e1812b3 100644 --- a/homarr/k8s/homarr.yaml +++ b/homarr/k8s/homarr.yaml @@ -13,6 +13,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: homarr-deployment namespace: homarr spec: diff --git a/immich/k8s/immich.yaml b/immich/k8s/immich.yaml index d22397d..448e5c1 100644 --- a/immich/k8s/immich.yaml +++ b/immich/k8s/immich.yaml @@ -14,6 +14,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: immich-deployment namespace: immich labels: diff --git a/immich/k8s/machine-learning.yaml b/immich/k8s/machine-learning.yaml index 3ff65a2..1fd7096 100644 --- a/immich/k8s/machine-learning.yaml +++ b/immich/k8s/machine-learning.yaml @@ -14,6 +14,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: immich-machine-learning-deployment namespace: immich labels: diff --git a/immich/k8s/valkey.yaml b/immich/k8s/valkey.yaml index 0a408eb..7e587d6 100644 --- a/immich/k8s/valkey.yaml +++ b/immich/k8s/valkey.yaml @@ -17,6 +17,8 @@ spec: apiVersion: apps/v1 kind: StatefulSet metadata: + annotations: + reloader.stakater.com/auto: "true" name: immich-valkey namespace: immich labels: diff --git a/kener/k8s/kener.yaml b/kener/k8s/kener.yaml index 61b04d0..db3962a 100644 --- a/kener/k8s/kener.yaml +++ b/kener/k8s/kener.yaml @@ -13,6 +13,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: kener-deployment namespace: kener spec: diff --git a/metube/k8s/metube.yaml b/metube/k8s/metube.yaml index 1f773e7..6caac66 100644 --- a/metube/k8s/metube.yaml +++ b/metube/k8s/metube.yaml @@ -13,6 +13,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: metube-deployment namespace: metube spec: diff --git a/n8n/k8s/n8n.yaml b/n8n/k8s/n8n.yaml index 585eb41..54804ed 100644 --- a/n8n/k8s/n8n.yaml +++ b/n8n/k8s/n8n.yaml @@ -13,6 +13,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: n8n-deployment namespace: n8n spec: diff --git a/netbird/k8s/netbird.yaml b/netbird/k8s/netbird.yaml index 550b498..bdd8f2f 100644 --- a/netbird/k8s/netbird.yaml +++ b/netbird/k8s/netbird.yaml @@ -32,6 +32,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: netbird-server-deployment namespace: netbird spec: @@ -126,6 +128,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: netbird-dashboard-deployment namespace: netbird spec: diff --git a/netbox/k8s/netbox.yaml b/netbox/k8s/netbox.yaml index e5a3f08..d5badc7 100644 --- a/netbox/k8s/netbox.yaml +++ b/netbox/k8s/netbox.yaml @@ -14,6 +14,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: netbox-deployment namespace: netbox labels: @@ -118,6 +120,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: netbox-worker-deployment namespace: netbox labels: diff --git a/netbox/k8s/valkey.yaml b/netbox/k8s/valkey.yaml index fc8cef9..503da8a 100644 --- a/netbox/k8s/valkey.yaml +++ b/netbox/k8s/valkey.yaml @@ -17,6 +17,8 @@ spec: apiVersion: apps/v1 kind: StatefulSet metadata: + annotations: + reloader.stakater.com/auto: "true" name: netbox-valkey namespace: netbox labels: diff --git a/netronome/k8s/netronome.yaml b/netronome/k8s/netronome.yaml index b5ba753..5fc558f 100644 --- a/netronome/k8s/netronome.yaml +++ b/netronome/k8s/netronome.yaml @@ -14,6 +14,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: netronome-deployment namespace: netronome labels: diff --git a/reloader/k8s/active b/reloader/k8s/active new file mode 100644 index 0000000..e69de29 diff --git a/reloader/k8s/reloader-values.yaml b/reloader/k8s/reloader-values.yaml index f26caea..ef8ba27 100644 --- a/reloader/k8s/reloader-values.yaml +++ b/reloader/k8s/reloader-values.yaml @@ -1,11 +1,17 @@ # Pinned chart: stakater/reloader 2.2.17 (app v1.4.22). # Deployed by the deploy workflow, namespace reloader. # Restarts pods when a ConfigMap or Secret they consume changes. Opt-in per workload -# via the reloader.stakater.com/auto: "true" pod annotation; watchGlobally because +# via the reloader.stakater.com/auto: "true" workload annotation; watchGlobally because # the workloads that need it are spread across a few dozen namespaces. reloader: watchGlobally: true + # Only opted-in workloads are restarted. Keep scheduled jobs on their schedule. + autoReloadAll: false + ignoreJobs: true + ignoreCronJobs: true + # Change pod-template annotations rather than injecting STAKATER_* env vars. + reloadStrategy: annotations deployment: replicas: 1 diff --git a/searxng/k8s/searxng.yaml b/searxng/k8s/searxng.yaml index cbea1e5..f84664d 100644 --- a/searxng/k8s/searxng.yaml +++ b/searxng/k8s/searxng.yaml @@ -13,6 +13,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: searxng-deployment namespace: searxng spec: diff --git a/termix/k8s/termix.yaml b/termix/k8s/termix.yaml index b8d9e18..f0d13a0 100644 --- a/termix/k8s/termix.yaml +++ b/termix/k8s/termix.yaml @@ -13,6 +13,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: termix-deployment namespace: termix spec: diff --git a/vaultwarden/k8s/vaultwarden.yaml b/vaultwarden/k8s/vaultwarden.yaml index f0968f3..b210a67 100644 --- a/vaultwarden/k8s/vaultwarden.yaml +++ b/vaultwarden/k8s/vaultwarden.yaml @@ -13,6 +13,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: vaultwarden-deployment namespace: vaultwarden spec: diff --git a/vpn/xui/k8s/xui.yaml b/vpn/xui/k8s/xui.yaml index 69f02de..103e0c2 100644 --- a/vpn/xui/k8s/xui.yaml +++ b/vpn/xui/k8s/xui.yaml @@ -20,6 +20,8 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: + annotations: + reloader.stakater.com/auto: "true" name: xui-deployment namespace: xui spec: From c098807aa4d7e1495327ef2fdb6f1e475b740a10 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 6 Oct 2026 16:03:53 +0200 Subject: [PATCH 4/7] fix(deploy): reject destructive per-file pruning before apply --- .gitea/workflows/deploy-lib.sh | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/deploy-lib.sh b/.gitea/workflows/deploy-lib.sh index d96880e..d3cb30a 100644 --- a/.gitea/workflows/deploy-lib.sh +++ b/.gitea/workflows/deploy-lib.sh @@ -31,6 +31,16 @@ warn() { echo "WARNING: $*" >&2 } +# Prune needs the complete desired set in one invocation. Per-file pruning +# treats resources from the other files as absent and can delete them. +check_prune_mode() { + if [ "$APPLY_PRUNE" = "true" ]; then + echo "ERROR: APPLY_PRUNE=true is unsupported by the per-file deploy loop." >&2 + echo "Disable it; remove obsolete resources explicitly after review." >&2 + return 1 + fi +} + collect_k8s() { git -C "$REPO" ls-files -- "$1" \ | grep -E '\.ya?ml$' \ @@ -720,6 +730,7 @@ check_referenced_secrets() { } stage_validate() { + check_prune_mode || return 1 cd "$REPO" select_manifests local m k cf @@ -753,18 +764,16 @@ stage_validate() { } stage_apply_k8s() { + check_prune_mode || return 1 cd "$REPO" select_manifests >/dev/null - local ns_files=() other_files=() m k prune_opts=() + local ns_files=() other_files=() m k for m in ${K8S_MANIFESTS[@]+"${K8S_MANIFESTS[@]}"}; do case "$m" in */namespace.y?ml) ns_files+=("$m") ;; *) other_files+=("$m") ;; esac done - if [ "$APPLY_PRUNE" = "true" ]; then - prune_opts=(--prune -l app.kubernetes.io/managed-by=homelab-deploy) - fi # Record what is about to change, and publish it for the verify job, before # the first apply. Both are fatal on failure: see snapshot_dir. @@ -789,7 +798,7 @@ stage_apply_k8s() { if [ "${#other_files[@]}" -gt 0 ]; then log "Applying resources (${#other_files[@]} files, our images pinned to digests)" for m in "${other_files[@]}"; do - if ! render_pinned <"$m" | kubectl apply "${prune_opts[@]}" -f -; then + if ! render_pinned <"$m" | kubectl apply -f -; then echo "ERROR: apply failed for ${m#"$REPO"/}" >&2 exit 1 fi From 67422663b7833dc7bb1c3b73573fe1444b3ccf72 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 6 Oct 2026 16:59:26 +0200 Subject: [PATCH 5/7] fix(ci): avoid duplicate branch and PR runs --- .gitea/workflows/ci.yaml | 2 +- .gitea/workflows/renovate-ci.yaml | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b9f7ebc..77587bf 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -3,7 +3,7 @@ name: ci on: push: branches: - - "**" + - main pull_request: workflow_dispatch: diff --git a/.gitea/workflows/renovate-ci.yaml b/.gitea/workflows/renovate-ci.yaml index 00dfee9..0886d50 100644 --- a/.gitea/workflows/renovate-ci.yaml +++ b/.gitea/workflows/renovate-ci.yaml @@ -2,9 +2,23 @@ name: renovate-ci on: pull_request: + paths: + - "renovate/**" + - ".gitea/workflows/renovate-ci.yaml" + - ".gitea/workflows/sync-renovate-configmap.sh" + - ".gitea/workflows/compose-lint.sh" + - ".gitea/workflows/install-ci-tools.sh" + - ".gitea/workflows/tool-versions.env" push: branches: - main + paths: + - "renovate/**" + - ".gitea/workflows/renovate-ci.yaml" + - ".gitea/workflows/sync-renovate-configmap.sh" + - ".gitea/workflows/compose-lint.sh" + - ".gitea/workflows/install-ci-tools.sh" + - ".gitea/workflows/tool-versions.env" workflow_dispatch: permissions: From b357ef95d8f9cc925b47d25caa81fde413417b29 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 6 Oct 2026 17:00:05 +0200 Subject: [PATCH 6/7] fix(deploy): only create automatic runs for main CI --- .gitea/workflows/deploy.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 159ba25..40a158b 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -5,6 +5,7 @@ on: # workflow_dispatch so a red lint/validate run can never reach the cluster. workflow_run: workflows: [ci] + branches: [main] types: [completed] workflow_dispatch: From 8729cb50620f487d9e3b2c82fa73fde5a4b38036 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Tue, 6 Oct 2026 15:58:44 +0200 Subject: [PATCH 7/7] fix(netbird): restore Compose setup and server entrypoint --- .gitea/workflows/ci.yaml | 1 + netbird/entrypoint.sh | 109 ++++++++++++++++++++++++++++++++++ netbird/setup.sh | 38 ++++++++++++ tests/test_netbird_runtime.py | 87 +++++++++++++++++++++++++++ 4 files changed, 235 insertions(+) create mode 100755 netbird/entrypoint.sh create mode 100755 netbird/setup.sh create mode 100644 tests/test_netbird_runtime.py diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 77587bf..5552327 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -142,6 +142,7 @@ jobs: export PATH="$tools_dir:$PATH" ruff check . ruff format --check . + python3 -m unittest discover -s tests -v lint-yaml: runs-on: [self-hosted, linux, arch, homelab] diff --git a/netbird/entrypoint.sh b/netbird/entrypoint.sh new file mode 100755 index 0000000..fe5420b --- /dev/null +++ b/netbird/entrypoint.sh @@ -0,0 +1,109 @@ +#!/bin/sh +set -eu + +umask 077 + +TEMPLATE_PATH=/opt/netbird/config.template.yaml +RENDERED_PATH=/run/netbird/config.yaml +RELAY_SECRET_PATH=/run/secrets/relay_auth_secret +ENCRYPTION_KEY_PATH=/run/secrets/datastore_encryption_key + +is_valid_proxy_subnet() { + candidate="$1" + case "$candidate" in + 0.0.0.0/0) + return 1 + ;; + */*) + address="${candidate%%/*}" + prefix="${candidate#*/}" + ;; + *) + return 1 + ;; + esac + + case "$prefix" in + 0|[1-9]|[1-2][0-9]|3[0-2]) ;; + *) + return 1 + ;; + esac + + old_ifs="$IFS" + IFS=. + # shellcheck disable=SC2086 + set -- $address + IFS="$old_ifs" + [ "$#" -eq 4 ] || return 1 + + for octet do + case "$octet" in + 0|[1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]) ;; + *) + return 1 + ;; + esac + done +} + +read_secret() { + secret_path="$1" + + if [ ! -r "$secret_path" ]; then + echo "Required secret is not readable: $secret_path" >&2 + exit 1 + fi + + secret_value="$(cat "$secret_path")" + if [ -z "$secret_value" ]; then + echo "Required secret is empty: $secret_path" >&2 + exit 1 + fi + + printf '%s' "$secret_value" +} + +if [ -z "${NETBIRD_DOMAIN:-}" ]; then + echo "NETBIRD_DOMAIN must be set" >&2 + exit 1 +fi + +case "$NETBIRD_DOMAIN" in + *[!A-Za-z0-9.-]*) + echo "NETBIRD_DOMAIN contains unsupported characters" >&2 + exit 1 + ;; +esac + +if [ -z "${NETBIRD_PROXY_SUBNET:-}" ] || [ "$NETBIRD_PROXY_SUBNET" = "auto" ]; then + echo "NETBIRD_PROXY_SUBNET must be an explicit IPv4 CIDR; run netbird/setup.sh first" >&2 + exit 1 +fi +if ! is_valid_proxy_subnet "$NETBIRD_PROXY_SUBNET"; then + echo "NETBIRD_PROXY_SUBNET must be a non-default IPv4 CIDR, for example 172.20.0.0/16" >&2 + exit 1 +fi + +if [ "$#" -ne 2 ] || [ "$1" != "--config" ] || [ "$2" != "$RENDERED_PATH" ]; then + echo "Expected: --config $RENDERED_PATH" >&2 + exit 1 +fi + +relay_secret="$(read_secret "$RELAY_SECRET_PATH")" +encryption_key="$(read_secret "$ENCRYPTION_KEY_PATH")" + +mkdir -p "$(dirname "$RENDERED_PATH")" +sed \ + -e "s|__NETBIRD_DOMAIN__|${NETBIRD_DOMAIN}|g" \ + -e "s|__NETBIRD_AUTH_SECRET__|${relay_secret}|g" \ + -e "s|__NETBIRD_ENCRYPTION_KEY__|${encryption_key}|g" \ + -e "s|__NETBIRD_PROXY_SUBNET__|${NETBIRD_PROXY_SUBNET}|g" \ + "$TEMPLATE_PATH" >"$RENDERED_PATH" + +if grep -q '__NETBIRD_' "$RENDERED_PATH"; then + echo "Rendered NetBird configuration still contains unresolved placeholders" >&2 + exit 1 +fi + +exec /go/bin/netbird-server "$@" diff --git a/netbird/setup.sh b/netbird/setup.sh new file mode 100755 index 0000000..3b4829b --- /dev/null +++ b/netbird/setup.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Prepare local Compose configuration without replacing existing credentials. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" +umask 077 +if [ ! -f .env ]; then + cp .env.example .env +fi + +if grep -q '^NETBIRD_PROXY_SUBNET=auto$' .env; then + subnet="$(docker network inspect proxy --format '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' | awk '/^[0-9]+\./ { print; exit }')" + if [ -z "$subnet" ]; then + echo "No IPv4 subnet found on the Docker proxy network. Set NETBIRD_PROXY_SUBNET in .env." >&2 + exit 1 + fi + # The detected value must be safe to substitute into the env file. + if [[ ! "$subnet" =~ ^[0-9.]+/[0-9]+$ ]]; then + echo "Unexpected Docker network subnet: $subnet" >&2 + exit 1 + fi + sed -i "s|^NETBIRD_PROXY_SUBNET=auto$|NETBIRD_PROXY_SUBNET=$subnet|" .env +fi + +mkdir -p secrets +chmod 700 secrets +for name in relay-auth-secret datastore-encryption-key; do + path="secrets/$name" + if [ -e "$path" ]; then + if [ ! -s "$path" ]; then + echo "Existing secret is empty: $path. Restore it before continuing." >&2 + exit 1 + fi + else + openssl rand -base64 32 >"$path" + fi + chmod 600 "$path" +done +printf '%s\n' 'Local files are ready. Review .env, then run docker compose config --quiet.' diff --git a/tests/test_netbird_runtime.py b/tests/test_netbird_runtime.py new file mode 100644 index 0000000..373980e --- /dev/null +++ b/tests/test_netbird_runtime.py @@ -0,0 +1,87 @@ +"""Exercise local setup and config rendering without a Docker daemon.""" + +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class NetbirdRuntimeTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.stack = self.root / 'netbird' + self.stack.mkdir() + for name in ('setup.sh', '.env.example', 'config.template.yaml'): + shutil.copy(ROOT / 'netbird' / name, self.stack / name) + binary = self.root / 'bin' + binary.mkdir() + docker = binary / 'docker' + docker.write_text('#!/bin/sh\nprintf "%s\\n" 172.20.0.0/16\n') + docker.chmod(0o755) + self.env = dict(os.environ, PATH=f'{binary}:{os.environ["PATH"]}') + + def setup(self): + return subprocess.run( # noqa: S603 - executes the repository script copied into this test's temp dir + ['/bin/bash', str(self.stack / 'setup.sh')], env=self.env, capture_output=True, check=False + ) + + def test_setup_preserves_existing_secrets_and_env(self): + self.assertEqual(self.setup().returncode, 0) + paths = [self.stack / '.env', *sorted((self.stack / 'secrets').iterdir())] + before = [p.read_bytes() for p in paths] + self.assertIn(b'NETBIRD_PROXY_SUBNET=172.20.0.0/16', before[0]) + self.assertEqual(self.setup().returncode, 0) + self.assertEqual(before, [p.read_bytes() for p in paths]) + for p in paths[1:]: + self.assertEqual(p.stat().st_mode & 0o777, 0o600) + + def test_setup_rejects_empty_existing_secret(self): + (self.stack / 'secrets').mkdir() + secret = self.stack / 'secrets/datastore-encryption-key' + secret.touch() + self.assertNotEqual(self.setup().returncode, 0) + self.assertEqual(secret.read_bytes(), b'') + + def render(self, subnet): + self.assertEqual(self.setup().returncode, 0) + rendered = self.root / 'run/config.yaml' + script = (ROOT / 'netbird/entrypoint.sh').read_text() + replacements = { + '/opt/netbird/config.template.yaml': str(self.stack / 'config.template.yaml'), + '/run/netbird/config.yaml': str(rendered), + '/run/secrets/relay_auth_secret': str(self.stack / 'secrets/relay-auth-secret'), + '/run/secrets/datastore_encryption_key': str(self.stack / 'secrets/datastore-encryption-key'), + '/go/bin/netbird-server': '/bin/true', + } + for original, local in replacements.items(): + script = script.replace(original, local) + result = subprocess.run( # noqa: S603 - repository renderer, with test-local paths + ['/bin/sh', '-c', script, 'entrypoint', '--config', str(rendered)], + env=dict(self.env, NETBIRD_DOMAIN='nb.example.com', NETBIRD_PROXY_SUBNET=subnet), + capture_output=True, + check=False, + ) + return result, rendered + + def test_renderer_replaces_placeholders_and_restricts_file_permissions(self): + result, rendered = self.render('172.20.0.0/16') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn('__NETBIRD_', rendered.read_text()) + self.assertIn('nb.example.com', rendered.read_text()) + self.assertEqual(rendered.stat().st_mode & 0o777, 0o600) + + def test_renderer_rejects_auto_and_default_route(self): + for subnet in ('auto', '0.0.0.0/0', '999.1.1.1/24'): + with self.subTest(subnet=subnet): + result, _ = self.render(subnet) + self.assertNotEqual(result.returncode, 0) + + +if __name__ == '__main__': + unittest.main()