diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index 4448682..4a358f1 100644 --- a/.github/workflows/validate-charts.yml +++ b/.github/workflows/validate-charts.yml @@ -149,13 +149,61 @@ jobs: uses: actions/checkout@v7 - name: "Install Helm" uses: azure/setup-helm@v5 + # freesailarr is the one chart whose install-test needs credentials: + # without a real Wireguard key gluetun never completes a handshake, and + # behind its kill switch not a single app container becomes Ready. The key + # comes from 1Password Connect like the registry credentials in + # publish-helm-chart.yml, and it is a CI-ONLY Mullvad key: Mullvad ties an + # address to a key and sends the tunnel to the endpoint it saw last, so + # reusing the production key would let every PR run pull the tunnel out + # from under the running stack - with the symptom in the cluster and the + # cause in a pull request. Loaded only for this chart; no other chart run + # touches the VPN key. + - name: "Configure OP-Connect" + if: matrix.chart == 'freesailarr' + uses: 1password/load-secrets-action/configure@v5 + with: + connect-host: https://op-connect.jk-dev.de + - name: "Load freesailarr VPN credentials" + id: load-freesailarr-vpn + if: matrix.chart == 'freesailarr' + uses: 1password/load-secrets-action@v5 + env: + OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }} + FREESAILARR_WG_KEY: op://GitHub-Actions/freesailarr-mullvad-test-config/credential + FREESAILARR_WG_ADDRESSES: op://GitHub-Actions/freesailarr-mullvad-test-config/ip-address + # The same trap publish-helm-chart.yml documents at length: v5 of + # load-secrets-action does NOT export the loaded values as environment + # variables for later steps, only its step outputs carry them, and the + # loading step logs "Populating variable" and stays green either way. So + # the values are mapped into each step's own env block via + # steps..outputs.* and read as quoted shell variables. The ":?" guards + # run before the cluster is even created and name the op:// field that is + # missing - an empty key would otherwise reach gluetun and look like a + # broken chart instead of a missing secret. They print the variable name, + # never its value. + - name: "Verify the VPN credentials arrived" + if: matrix.chart == 'freesailarr' + env: + FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }} + FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }} + run: |- + : "${FREESAILARR_WG_KEY:?empty - check op://GitHub-Actions/freesailarr-mullvad-test-config/credential}" + : "${FREESAILARR_WG_ADDRESSES:?empty - check op://GitHub-Actions/freesailarr-mullvad-test-config/ip-address}" + echo "Both VPN values arrived non-empty." - name: "Install k3d" run: curl -sSfL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | TAG="$K3D_VERSION" bash - name: "Create k3d cluster" run: k3d cluster create install-test --image "$K3S_IMAGE" --wait --timeout 180s # Excluded charts (see ci/excluded-charts.txt) are skipped inside the - # script with their documented reason. + # script with their documented reason. The variables below are empty for + # every other chart and are read only by the chart that declares them in + # ci//settings.env (REQUIRED_ENV); the script fails loudly if a + # declared one is missing, it never skips over it. - name: "Install chart and wait for readiness" + env: + FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }} + FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }} run: ci/run-install-test.sh "${{ matrix.chart }}" - name: "Delete k3d cluster" if: always() diff --git a/README.md b/README.md index cdb5ec9..eeb5cf9 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo - `publish-.yml` — one thin workflow per chart; triggers on pushes to `main` touching `/**` (or manually) and calls the reusable workflow below. **A push to a chart directory on `main` publishes that chart**, so the chart version must be bumped in the same change. - `publish-helm-chart.yml` — reusable workflow: `helm package`, then `helm push` to the private JK-Registry. Registry credentials come from 1Password Connect (`OP_CONNECT_TOKEN` secret, items under `op://GitHub-Actions/`). The loaded values are read via **`steps..outputs.*`**, mapped into each step's own `env:` block and used as quoted shell variables — never interpolated into the command line. `load-secrets-action` **v5 no longer exports them as environment variables** for later steps the way v4 did, so after the v5 bump every `${{ env.JK_CR_* }}` reference resolved to an empty string while the loading step still logged `Populating variable` and stayed green. Each value is additionally guarded with `:?`, because an empty one used to collapse into a missing argument rather than an error: `-u` consumed the following `--password-stdin`, helm fell back to an interactive prompt and the job died with `inappropriate ioctl for device` — a terminal error for what was really an unset variable. - `change-app-version.yml` — manually dispatched app-version bump; the chart must be listed in its `chart-name` choice list. -- `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (same changed-charts matrix) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below. +- `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (same changed-charts matrix) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below. Both matrices follow the changed chart directories, so a PR that only changes the harness under `ci/` exercises nothing by itself; the Trivy scan has a fixed-sample fallback for that case, the install-test has none — bump the affected chart's patch version to pull it into the matrix. ### Install-test (PR pipeline stage 2) @@ -51,9 +51,12 @@ Fixtures live at the **repo root** under `ci/` (deliberately *not* inside the ch - `ci/common/` — fixtures applied for every chart (schema-less stub CRD for `OnePasswordItem`) - `ci//test-values.yaml` — values for the CI install (required for every installable chart) - `ci//fixtures.yaml` — dummy secrets with in-cluster connection data, throwaway infrastructure (postgres/redis/dex), static hostPath PVs/StorageClasses where the chart pins a storage class or needs RWX, and seed jobs (e.g. `synapse generate`) -- `ci//settings.env` — optional overrides (e.g. `INSTALL_TIMEOUT` for slow starters) +- `ci//fixtures.env.yaml` — fixtures that need a value which must not be committed. The file holds `${VARIABLE}` placeholders and nothing else; `ci/run-install-test.sh` renders it with `envsubst` and pipes the result straight into `kubectl` (never printed). Substitution is restricted to the names from `REQUIRED_ENV`, so an undeclared variable is never filled in and no unrelated `$…` in the manifest is touched +- `ci//settings.env` — optional overrides: `INSTALL_TIMEOUT` for slow starters, and `REQUIRED_ENV="VAR_A VAR_B"` to declare the variables `fixtures.env.yaml` needs. **A missing or empty declared variable fails the install-test loudly, naming the variable — it never skips the chart.** Skipping would restore exactly the green-but-meaningless signal this mechanism exists to remove; the one legitimate case of absent secrets, a PR from a fork, belongs in an `if` on the workflow job rather than in the script - `ci/excluded-charts.txt` — charts that cannot run in CI, each with a justification. Currently: `satisfactory` (multi-GB image + 10GB+ Steam download), `template-chart` (scaffold with placeholder image) +`freesailarr` is the only chart using `fixtures.env.yaml` today: gluetun needs a real Wireguard key, or it never completes a handshake and no app container behind its kill switch becomes Ready. The install-test job loads the key from 1Password Connect for that chart only, using the same pattern as `publish-helm-chart.yml` — `steps..outputs.*` into the step's own `env:`, quoted, with `:?` guards that name the missing `op://` field before the cluster is even created. The key in that item is a **CI-only Mullvad key**, deliberately not the production one: Mullvad ties an address to a key and routes the tunnel to the endpoint it saw last, so a shared key would make every PR run pull the tunnel out from under the running stack — with the symptom in the cluster and the cause in a pull request. + Fixture images avoid Docker Hub where possible (`public.ecr.aws`/`ghcr.io`) to dodge runner rate limits. The job **blocks the PR** on failure — the stabilization phase of #22 ended when Renovate started automerging patch/minor updates, since a chart that no longer starts must not reach `main` and publish unattended. When adding a new chart, add its `ci//` fixtures (or an entry in `ci/excluded-charts.txt`) — the install-test fails if both are missing. The script also runs locally against any existing cluster: `k3d cluster create t && ci/run-install-test.sh && k3d cluster delete t`. ## Dependency updates (Renovate) @@ -251,5 +254,5 @@ Reference implementation: `template-chart/values.schema.json` and `template-char - `icon:` is mandatory in every `Chart.yaml`. **Documented exception — freesailarr, the only chart without one**: the rule everywhere else is "the icon is this chart's one upstream's logo", and a chart bundling nine independently versioned projects has no such upstream — picking one of the nine would imply a "main app" the stack deliberately does not have. That is the whole criterion, and it applies to no other chart in the repo: a single-upstream chart without an icon is a missing icon, not an exception. The omission costs an INFO line in `helm lint --strict` (`icon is recommended`), which still exits 0. - `helm lint --strict` must pass without findings. - `NOTES.txt` for every chart is planned (#43); seven charts ship one so far (`apache-tika`, `cyberchef`, `freesailarr`, `gotenberg`, `homeassistant`, `patchmon`, `rss-bridge`). -- CI: every installable chart has fixtures under `ci//` (`test-values.yaml`, optionally `fixtures.yaml`/`settings.env`) or a justified entry in `ci/excluded-charts.txt` — the install-test fails if both are missing. +- CI: every installable chart has fixtures under `ci//` (`test-values.yaml`, optionally `fixtures.yaml`/`fixtures.env.yaml`/`settings.env`) or a justified entry in `ci/excluded-charts.txt` — the install-test fails if both are missing. - Charts have no dependencies (`charts/` directories are empty). diff --git a/ci/excluded-charts.txt b/ci/excluded-charts.txt index ca96541..d53b450 100644 --- a/ci/excluded-charts.txt +++ b/ci/excluded-charts.txt @@ -1,5 +1,4 @@ # Charts excluded from the PR install-test, one per line: . # ci/run-install-test.sh skips these with the given reason. -freesailarr gluetun never becomes healthy without a real Wireguard key and VPN connectivity, and every app container sits behind its firewall - the pod cannot become Ready on a CI runner satisfactory the game-server image is multiple GB and Steam downloads 10GB+ game files on first start - impractical on a CI runner template-chart scaffold with placeholder image "ghcr.io/ORG/IMAGE:xxx", never published diff --git a/ci/freesailarr/fixtures.env.yaml b/ci/freesailarr/fixtures.env.yaml new file mode 100644 index 0000000..b170ba1 --- /dev/null +++ b/ci/freesailarr/fixtures.env.yaml @@ -0,0 +1,22 @@ +# The gluetun credentials Secret the 1Password operator materializes on the +# real cluster - here as a template with variable NAMES only. This file must +# never contain a value; ci/run-install-test.sh renders it with envsubst, +# restricted to the names declared in ci/freesailarr/settings.env +# (REQUIRED_ENV), and pipes the result straight into kubectl. +# +# The values come from a 1Password item holding a CI-only Mullvad key, loaded in +# the install-test job of .github/workflows/validate-charts.yml. A CI-only key +# on purpose: Mullvad ties an address to a key and sends the tunnel to the +# endpoint it saw last, so a shared key would let every pull request pull the +# tunnel out from under the running stack. +# +# Name and keys follow the chart: --gluetun-secret with the +# fields the StatefulSet reads via secretKeyRef. Release name is ci. +apiVersion: v1 +kind: Secret +metadata: + name: ci-freesailarr-gluetun-secret +type: Opaque +stringData: + wireguard-private-key: "${FREESAILARR_WG_KEY}" + wireguard-addresses: "${FREESAILARR_WG_ADDRESSES}" diff --git a/ci/freesailarr/fixtures.yaml b/ci/freesailarr/fixtures.yaml new file mode 100644 index 0000000..6179089 --- /dev/null +++ b/ci/freesailarr/fixtures.yaml @@ -0,0 +1,43 @@ +# Fixtures for the freesailarr install-test: the three media claims the chart +# mounts but never creates (media.*.existingClaim). The config volumes come from +# the StatefulSet's own volumeClaimTemplates and need nothing here. +# +# Default StorageClass (k3d's local-path), so the claims stay Pending until the +# pod is scheduled - WaitForFirstConsumer, which is exactly what the chart sees +# on a real cluster. local-path creates its directories mode 0777, so the claim +# roots are writable for PUID/PGID 1000 without a chown job. +# +# The gluetun credentials Secret is NOT here: it carries values that must not be +# committed and lives in fixtures.env.yaml. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ci-freesailarr-downloads +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ci-freesailarr-movies +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ci-freesailarr-tv +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/ci/freesailarr/settings.env b/ci/freesailarr/settings.env new file mode 100644 index 0000000..fdf3a39 --- /dev/null +++ b/ci/freesailarr/settings.env @@ -0,0 +1,10 @@ +# Nine containers in one pod, several GB of images to pull, and the app +# containers only start once gluetun has completed its Wireguard handshake - +# each of them then runs its own startup probe (150s budget, 300s for seerr's +# first-boot migrations). The default of 300s is not enough for that. +INSTALL_TIMEOUT=900 + +# The gluetun credentials for ci/freesailarr/fixtures.env.yaml. Missing or empty +# values fail the install-test loudly instead of skipping it - see +# ci/run-install-test.sh. +REQUIRED_ENV="FREESAILARR_WG_KEY FREESAILARR_WG_ADDRESSES" diff --git a/ci/freesailarr/test-values.yaml b/ci/freesailarr/test-values.yaml new file mode 100644 index 0000000..d86754c --- /dev/null +++ b/ci/freesailarr/test-values.yaml @@ -0,0 +1,39 @@ +# freesailarr: the whole stack in one pod - gluetun plus eight app containers +# that share its network namespace and sit behind its kill switch. Nothing here +# is a shortcut around that: the install-test brings up a real Wireguard tunnel +# with a CI-only Mullvad key (see ci/freesailarr/fixtures.env.yaml), because a +# gluetun without a handshake keeps every app container from becoming Ready. +# +# Release name is ci, so the chart looks for the gluetun credentials in the +# Secret ci-freesailarr-gluetun-secret, which the fixtures create. The chart's +# own OnePasswordItem CR applies against the stub CRD and stays unreconciled - +# there is no 1Password operator in the CI cluster. +secrets: + # Required by the chart. Only the CR's itemPath, never resolved in CI. + gluetunSecretRef: op://ci/freesailarr-gluetun + +media: + # The three media claims are pre-created in fixtures.yaml (one claim per + # category, no subPath - the shared-claim layout is a values question, not a + # startup question). local-path hands out its directories mode 0777, so the + # claim roots are writable for PUID/PGID as the chart requires. + downloads: + existingClaim: ci-freesailarr-downloads + movies: + existingClaim: ci-freesailarr-movies + tv: + existingClaim: ci-freesailarr-tv + +gluetun: + vpn: + # Without this the kill switch drops cluster-internal egress as well, and + # the very first DNS lookup against cluster DNS fails. k3s defaults: + # 10.42.0.0/16 pods, 10.43.0.0/16 services. + firewallOutboundSubnets: "10.42.0.0/16,10.43.0.0/16" + +ingress: + # Shared-domain mode with a placeholder host: the Ingress objects only have to + # be accepted by the API server. There is no ingress-nginx and no cert-manager + # in the CI cluster, and the install-test checks readiness, not routing. + host: freesailarr.example.com + clusterIssuer: ci diff --git a/ci/run-install-test.sh b/ci/run-install-test.sh index c00737c..efd9f3b 100755 --- a/ci/run-install-test.sh +++ b/ci/run-install-test.sh @@ -9,7 +9,8 @@ # ci/common/ fixtures applied for every chart (OnePasswordItem stub CRD) # ci//test-values.yaml values for the CI install (required) # ci//fixtures.yaml dummy secrets / throwaway infra / static PVs (optional) -# ci//settings.env optional overrides, e.g. INSTALL_TIMEOUT=600 +# ci//fixtures.env.yaml fixtures with ${VARS} filled in from the environment (optional) +# ci//settings.env optional overrides, e.g. INSTALL_TIMEOUT=600, REQUIRED_ENV=... # ci/excluded-charts.txt charts that cannot run in CI, with reasons set -euo pipefail @@ -20,8 +21,11 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CI_DIR="$ROOT/ci" # Per-chart override via ci//settings.env (INSTALL_TIMEOUT in seconds). +# REQUIRED_ENV is a space-separated list of environment variables the chart's +# fixtures need; see the check below. INSTALL_TIMEOUT=300 FIXTURE_JOB_TIMEOUT=600 +REQUIRED_ENV="" if [ -f "$CI_DIR/$CHART/settings.env" ]; then # shellcheck disable=SC1090 source "$CI_DIR/$CHART/settings.env" @@ -47,7 +51,52 @@ if [ ! -f "$CI_DIR/$CHART/test-values.yaml" ]; then exit 1 fi +# --- Required environment for ci//fixtures.env.yaml ------------------ +# A chart whose fixtures need a value that must never be committed (freesailarr +# needs a real Wireguard key, or gluetun's kill switch keeps every app container +# from becoming Ready) declares the variable NAMES in settings.env: +# +# REQUIRED_ENV="FREESAILARR_WG_KEY FREESAILARR_WG_ADDRESSES" +# +# The values come from the environment - in CI from 1Password Connect, see the +# install-test job in .github/workflows/validate-charts.yml. +# +# A missing or empty value is a hard error on purpose. Skipping the chart +# instead would restore exactly the green-but-meaningless signal this mechanism +# exists to remove, and an empty key produces a gluetun without a handshake, +# which reads as a broken chart rather than as a missing secret. The one +# legitimate case of absent secrets - a pull request from a fork - belongs in an +# "if" on the workflow job, not in this script. +# Only the variable name is ever printed, never the value. +for VAR in $REQUIRED_ENV; do + if [ -z "${!VAR:-}" ]; then + echo "ERROR: required environment variable '$VAR' is unset or empty." >&2 + echo "ci/$CHART/settings.env declares REQUIRED_ENV=\"$REQUIRED_ENV\"; ci/$CHART/fixtures.env.yaml" >&2 + echo "cannot be rendered without it, and the install-test does not skip charts over missing values." >&2 + exit 1 + fi +done + # --- Failure diagnostics ---------------------------------------------------- +# The diagnostics echo application output, and an app may well print a value it +# was given: gluetun logs its Wireguard address on startup. So everything the +# failure path prints runs through a filter that replaces the values of the +# declared REQUIRED_ENV variables with the variable name. GitHub masks them as +# well, but that is the platform's promise rather than this harness's - and the +# script runs locally too, where nothing masks anything. +redact_secrets() { + if [ -z "$REQUIRED_ENV" ]; then + cat + return + fi + local SED_ARGS=() + local VAR + for VAR in $REQUIRED_ENV; do + SED_ARGS+=(-e "s|${!VAR}||g") + done + sed "${SED_ARGS[@]}" +} + diagnose() { echo "=================== INSTALL-TEST FAILED: DIAGNOSTICS ===================" echo "--- pods ---" @@ -64,7 +113,7 @@ diagnose() { done echo "========================================================================" } -trap 'RC=$?; if [ $RC -ne 0 ]; then diagnose; fi; exit $RC' EXIT +trap 'RC=$?; if [ $RC -ne 0 ]; then diagnose | redact_secrets; fi; exit $RC' EXIT # --- 1. Apply fixtures ------------------------------------------------------ echo "::group::Apply fixtures" @@ -72,6 +121,20 @@ kubectl apply -f "$CI_DIR/common/" if [ -f "$CI_DIR/$CHART/fixtures.yaml" ]; then kubectl apply -f "$CI_DIR/$CHART/fixtures.yaml" fi +# Fixtures with values that must not be committed are rendered before they are +# applied: the checked-in fixtures.env.yaml holds ${VARIABLE} placeholders only. +# envsubst is restricted to the names from REQUIRED_ENV (its SHELL-FORMAT +# argument), so a variable that is not declared there is never substituted, no +# unrelated "$..." in the manifest is touched, and no other variable of the +# runner environment can end up in the cluster. The rendered document is piped +# straight into kubectl and never printed - kubectl reports object names only. +if [ -f "$CI_DIR/$CHART/fixtures.env.yaml" ]; then + SHELL_FORMAT="" + for VAR in $REQUIRED_ENV; do + SHELL_FORMAT="$SHELL_FORMAT\${$VAR}" + done + envsubst "$SHELL_FORMAT" < "$CI_DIR/$CHART/fixtures.env.yaml" | kubectl apply -f - +fi echo "::endgroup::" # --- 2. Wait for fixture jobs (node setup, data seeding) and infra ---------- diff --git a/freesailarr/Chart.yaml b/freesailarr/Chart.yaml index 8da9f0c..7b261f3 100644 --- a/freesailarr/Chart.yaml +++ b/freesailarr/Chart.yaml @@ -8,7 +8,7 @@ description: A Helm chart to deploy a Wireguard-tunneled media automation stack type: application -version: 3.0.0+up1.0.0 +version: 3.0.1+up1.0.0 # The chart's own version marker, freely chosen and tied to no upstream. The # stack bundles nine independently versioned projects, so there is no single # "app version" to track here.