From be59a85e4174af9c22c564c00e920c32cc393ea1 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:05:10 -0700 Subject: [PATCH 1/7] ci: adopt the fleet CI taskfile, lint configs and gate scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the non-workflow half of jacaudi/template (the copier template for this fleet's pipeline) into the repo. Rendered from the `operator-envtest` example, whose shape this repo matches exactly: variant=service, lang_go=true, has_chart=false, integration_kind=envtest. The detector agrees — go=true, chart=false, docker=true, variant=service. Everything is COPIED rather than referenced. The stages that follow in the workflow PR are local reusable workflows, so a run can never be changed by something outside this repo's commit; `copier update` is what moves it forward. `.copier-answers.yml` is deliberately held back until that PR, so the recorded template state never claims more is applied than actually is. Two configs carry repo-specific additions, both marked and justified in place: .yamllint.yml ignores config/, bundle/ and release.yaml. Same root cause the fleet baseline already documents for crds/, one directory up: controller-gen, kustomize and operator-sdk all marshal through sigs.k8s.io/yaml, whose yaml.v2 backend writes block sequences without indenting them under their key, which `indent-sequences: true` rejects outright. Measured before ignoring: 341 findings, 136 of them indentation inside config/. Reindenting does not stick — the next `make manifests` undoes it. examples/ and .github/ are hand-written and were fixed instead, in a later commit. .golangci.yml the migration ratchet is ENABLED (`new-from-merge-base`), and `build-tags` is added. Measured at adoption: 117 findings — 88 revive, 8 gosec, 8 nolintlint, 5 gocyclo, 3 gocritic, 2 errorlint, 2 modernize, 1 prealloc. The five gocyclo hits are the reconcile loop (Reconcile at cyclomatic complexity 121, updateWireguardPeers at 42), which #2 replaces wholesale, so absorbing them by hand would mean editing code that is about to be rewritten. The 8 gosec findings were NOT ratcheted away — they are fixed outright, later in this branch. `task smoke` is rewritten rather than left stock. The template's body boots the image and polls http://localhost:8080/, which cannot work here on two counts: the manager calls ctrl.GetConfigOrDie() before serving anything, so with no kubeconfig it exits 1; and its :8080 metrics listener is wrapped in WithAuthenticationAndAuthorization, so an unauthenticated GET is 401 while the health probes are on :8081. It now asserts `--help` runs and prints the manager's own flags, which proves the image runs, the binary is the right architecture, and it is the manager — without needing a cluster. Also removes .github/workflows/.DS_Store, which was committed, and anchors the `release.yaml` ignore rule with a leading slash: unanchored, it matched at any depth and silently swallowed .github/workflows/release.yaml. Refs #37 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XcHdnmhuAbyBYG2VfwuPDD --- .dockerignore | 60 +++++++++++++- .github/workflows/.DS_Store | Bin 6148 -> 0 bytes .gitignore | 107 +++++++++++++++++++----- .golangci.yml | 102 ++++++++++++++++++++++- .hadolint.yaml | 19 +++++ .taskfiles/go.yml | 76 ++++++++++++++++++ .taskfiles/repo.yml | 100 +++++++++++++++++++++++ .yamllint.yml | 70 ++++++++++++++++ scripts/chart-appversion.sh | 76 ++++++++++++++++++ scripts/chart-deps.sh | 89 ++++++++++++++++++++ scripts/chart-drift.sh | 83 +++++++++++++++++++ scripts/chart-list.sh | 67 +++++++++++++++ scripts/copier-freshness.sh | 47 +++++++++++ scripts/discover-dirs.sh | 105 ++++++++++++++++++++++++ scripts/dockerfile-list.sh | 75 +++++++++++++++++ scripts/local-refs.sh | 97 ++++++++++++++++++++++ scripts/run-interpolation.sh | 81 +++++++++++++++++++ scripts/run-scripts-exist.sh | 89 ++++++++++++++++++++ taskfile.yml | 152 +++++++++++++++++++++++++++++++++++ 19 files changed, 1471 insertions(+), 24 deletions(-) delete mode 100644 .github/workflows/.DS_Store create mode 100644 .hadolint.yaml create mode 100644 .taskfiles/go.yml create mode 100644 .taskfiles/repo.yml create mode 100644 .yamllint.yml create mode 100755 scripts/chart-appversion.sh create mode 100755 scripts/chart-deps.sh create mode 100755 scripts/chart-drift.sh create mode 100755 scripts/chart-list.sh create mode 100755 scripts/copier-freshness.sh create mode 100755 scripts/discover-dirs.sh create mode 100755 scripts/dockerfile-list.sh create mode 100755 scripts/local-refs.sh create mode 100755 scripts/run-interpolation.sh create mode 100755 scripts/run-scripts-exist.sh create mode 100644 taskfile.yml diff --git a/.dockerignore b/.dockerignore index 0f04682..3d94d1e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,56 @@ -# More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file -# Ignore build and test binaries. -bin/ -testbin/ +# --- managed by copier: regenerated on `copier update`; add your own BELOW the end marker --- +# Goes at the BUILD CONTEXT root, not beside the Dockerfile. + +# Version control — biggest context bloat and a real leak risk +.git +.gitignore +.gitattributes +.github + +# Secrets +.env +.env.* +!.env.example +*.pem +*.key + +# Dependencies and build output — rebuilt inside the image +node_modules +vendor +target +__pycache__ +.venv +venv +bin +dist +coverage + +# Tooling and CI config the image never needs +.golangci.yml +.yamllint.yml +.hadolint.yaml +ruff.toml +eslint.config.mjs +taskfile.yml +.taskfiles +Makefile +renovate.json + +# Docs and local junk +docs +*.md +!README.md +.DS_Store +Thumbs.db +*.swp +.worktrees +.idea +.vscode + +# Test fixtures. +# CAUTION: delete these two lines if this repo's Dockerfile runs `go mod tidy` — +# tidy computes requirements from the whole module INCLUDING tests, so stripping +# *_test.go changes what it resolves. +**/testdata +**/*_test.go +# --- end managed --- diff --git a/.github/workflows/.DS_Store b/.github/workflows/.DS_Store deleted file mode 100644 index feab4b1e4c4525ea4a4bb61f2a983ce86d1a4cfb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~O-lnY5QZmOr0AvIJjvo+i&sxesWsBbVN|70u zyvbx{lDthejfl8b(Z3XFh)5k(tQ}IU5bam3$X>J@fX?UW%el;?kgiNB-exccjDcNa zK+o~FJ&As4#4=zA7Z)ENE{^Qt3&r`-$)9+4acMv+jR9j|nSuS#Z0P-e z{qy^OImlYZfHCl|7;v@rUAx7S;@w(VoZhtw`hcpaUm4JcP{ixlSM(}ALA}tIXa!6o TmI1LK_D8_eV1+U8rwn`mONquE diff --git a/.gitignore b/.gitignore index cf232bd..5e15bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,101 @@ +# --- managed by copier: regenerated on `copier update`; add your own BELOW the end marker --- +# Base fragment; language fragments are appended. -# Binaries for programs and plugins +# OS junk +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Thumbs.db +desktop.ini + +# Editor +*.swp +*.swo +*~ +.idea/ +*.iml +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json + +# Merge/patch leftovers +*.orig +*.rej + +# Secrets +.env +.env.* +!.env.example +*.pem +*.key +!**/testdata/**/*.pem +!**/testdata/**/*.key + +# Local tooling +.worktrees/ +bin/ +dist/ +coverage/ +*.out +!.gitkeep + +# Go *.exe *.exe~ *.dll *.so *.dylib -bin -testbin/* - -# Test binary, build with `go test -c` *.test +vendor/ +go.work +go.work.sum -# Output of the go coverage tool, specifically when used with LiteIDE -*.out +# kubebuilder / operator-sdk local state +*.kubeconfig +config/**/*.local.yaml -# Kubernetes Generated files - skip generated files, except for vendored files +# Node / JS / TS — lockfiles ARE committed (npm ci depends on them) +node_modules/ +.npm +.eslintcache +*.tsbuildinfo +.vite/ +.next/ +.nuxt/ +.turbo/ +npm-debug.log* +yarn-error.log* -!vendor/**/zz_generated.* +# Python — uv.lock IS committed (it is what makes the env reproducible) +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +.venv/ +venv/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.ty_cache/ -# editor and IDE paraphernalia -.idea -*.swp -*.swo -*~ -.DS_Store +# Helm — built subchart tarballs. Chart.lock is the source of truth and +# `helm dependency build` regenerates these, so committing them creates a second +# representation that Renovate CANNOT update: it bumps the version in Chart.yaml +# and the stale .tgz silently stays. All five chart repos in this fleet currently +# commit one. +# Chart.lock itself IS committed — do not add it here. +charts/*.tgz +**/charts/*.tgz +# --- end managed --- +# Repo-specific entries below this line are preserved across regeneration. + +# kubebuilder test assets (predates the managed block) +testbin/* -# -release.yaml -release_it.yaml +# Generated release manifest and release-it config. ANCHORED with a leading +# slash: an unanchored `release.yaml` matches at any depth and silently +# swallowed .github/workflows/release.yaml, which must stay tracked. +/release.yaml +/release_it.yaml diff --git a/.golangci.yml b/.golangci.yml index 0cf9ff5..8c47edd 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,10 +1,108 @@ +# Fleet baseline, golangci-lint v2 schema. A floor, not a ceiling. version: "2" +run: + timeout: 5m + tests: true + allow-parallel-runners: true + # REPO-SPECIFIC. Without this, golangci-lint honours build constraints and + # simply does not see a file tagged out of the default build — so the envtest + # suite (`integration`) and the cluster suite (`e2e`) would go unlinted, and + # the debt count would drop by 16 findings that were never fixed, only hidden. + # Measured: 93 findings without these tags, 109 with them. + build-tags: + - integration + - e2e + linters: + default: none enable: + - bodyclose + - dupl + - errcheck + - errorlint + - gocritic + - gocyclo + - gosec + - govet + - ineffassign + - misspell + - modernize + - nolintlint + - prealloc + - revive + - staticcheck + - unconvert - unparam + - unused + + settings: + revive: + rules: + - name: var-naming + - name: package-comments + - name: exported + - name: error-return + - name: error-naming + - name: indent-error-flow + - name: unexported-return + - name: comment-spacings + - name: import-shadowing + gocyclo: + min-complexity: 15 + errorlint: + asserts: true + comparison: true + errorf: true + nolintlint: + require-explanation: true + require-specific: true + gosec: + excludes: + - G104 # errcheck covers unhandled errors, and better + exclusions: + generated: lax rules: - path: _test\.go - linters: - - unparam + linters: [gocyclo, dupl, gosec, errcheck, unparam, prealloc] + paths: + - third_party$ + - builtin$ + - examples$ + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + # MIGRATION RATCHET — uncomment when adopting this baseline on code that + # predates it. Reports only issues in code changed since the merge base, so the + # standard applies going forward without blocking the migration on existing + # debt. Valid in the v2 schema. + # + # Measure before deciding: a repo with no prior lint config can surface well + # over a hundred findings, and `golangci-lint run --fix` resolves NONE of them + # (tested). See the full debt any time with: + # golangci-lint run --new-from-merge-base="" + # + # ENABLED HERE, DELIBERATELY. Measured on this repo at adoption: 117 findings + # — 88 revive, 8 gosec, 8 nolintlint, 5 gocyclo, 3 gocritic, 2 errorlint, + # 2 modernize, 1 prealloc. The five gocyclo hits are the reconcile loop + # (`Reconcile` at cyclomatic complexity 121, `updateWireguardPeers` at 42), + # which issue #2 replaces wholesale — absorbing them by hand would mean + # editing code that is about to be rewritten. + # + # The 8 gosec findings were NOT ratcheted away: they are fixed in the same + # commit that adds this file, because a leaked key or an unbounded read in a + # VPN data plane is not lint debt. See the commit body for each one. + new-from-merge-base: origin/main + +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 0000000..6f646a2 --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,19 @@ +# Fleet baseline for Dockerfile linting. +failure-threshold: warning + +ignored: + - DL3008 # pin apt versions — Renovate does not manage them + - DL3018 # pin apk versions — same + +trustedRegistries: + - ghcr.io + - docker.io + - gcr.io + - registry.k8s.io + - quay.io + +override: + error: + - DL3006 # always tag the base image explicitly + - DL3007 # never :latest + - DL3020 # COPY, not ADD diff --git a/.taskfiles/go.yml b/.taskfiles/go.yml new file mode 100644 index 0000000..6c55eaf --- /dev/null +++ b/.taskfiles/go.yml @@ -0,0 +1,76 @@ +# Go module — checks fail on drift, never fix it. +version: '3' + +tasks: + ci: + desc: All Go checks + cmds: + - task: fmt:check + - task: tidy:check + - task: lint + - task: vuln + - task: test + + lint: + desc: golangci-lint + cmds: + - golangci-lint run + + fmt: + desc: Format Go sources + cmds: + - golangci-lint fmt + + fmt:check: + desc: Fail if sources are unformatted + cmds: + - golangci-lint fmt --diff + + tidy:check: + desc: Fail if go.mod/go.sum are not tidy + cmds: + - go mod tidy -diff # -diff exits non-zero; a bare `tidy` MUTATES go.mod in CI + + vuln: + desc: Scan dependencies for known vulnerabilities + cmds: + # Installed pinned by the setup action; @latest would be a live fetch on + # every run in a pipeline whose whole thesis is pinning. + - | + set -eu + command -v govulncheck >/dev/null \ + || { echo "govulncheck not installed: go install golang.org/x/vuln/cmd/govulncheck@latest" >&2; exit 1; } + govulncheck ./... + + test: + desc: Run tests with the race detector + # UNIT TESTS ONLY, and that is the same split ci-lint.yml makes: it runs a + # bare `go test ./...` with no envtest assets, so anything needing a + # control plane must be excluded by a build tag or it fails there. + # This repo has two tiers already: + # -tags=integration internal/controller — envtest (downloaded binaries) + # -tags=e2e internal/it — a real cluster + # Neither is reachable from here, deliberately. + cmds: + - go test ./... -race + + integration: + desc: Run the envtest suite (downloads a control plane on first use) + # Mirrors ci-integration.yml. Kept here because tagging the controller suite + # out of `test` would otherwise leave no local way to run it at all. + # + # ENVTEST_K8S_VERSION tracks the version ci-integration.yml pins. The two + # are separate copies of one number; if you change one, change the other. + cmds: + - | + set -eu + command -v setup-envtest >/dev/null \ + || { echo "setup-envtest not installed: go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest" >&2; exit 1; } + assets=$(setup-envtest use "${ENVTEST_K8S_VERSION:-1.30.0}" -p path) + echo "KUBEBUILDER_ASSETS=${assets}" + KUBEBUILDER_ASSETS="${assets}" go test ./... -race -tags=integration + + build: + desc: Build binaries into ./bin/ + cmds: + - go build -o bin/ ./... diff --git a/.taskfiles/repo.yml b/.taskfiles/repo.yml new file mode 100644 index 0000000..10b4bed --- /dev/null +++ b/.taskfiles/repo.yml @@ -0,0 +1,100 @@ +# Cross-cutting repo checks; each is a no-op when its subject is absent. +version: '3' + +tasks: + ci: + desc: All repo-level checks + cmds: + - task: actions:local-refs + - task: actions:run-scripts + - task: actions:run-interpolation + - task: actions + - task: yaml + - task: docker + + actions: + desc: Lint GitHub Actions workflows (only when they changed) + cmds: + - | + set -eu + [ -d .github/workflows ] || { echo "no workflows — skipping actionlint"; exit 0; } + base="${BASE_REF:-HEAD~1}" + if git rev-parse --verify --quiet "${base}" >/dev/null; then + if [ -z "$(git diff --name-only "${base}" HEAD -- .github/workflows)" ]; then + echo "no workflow changes since ${base} — skipping actionlint" + exit 0 + fi + fi + command -v actionlint >/dev/null \ + || { echo "actionlint not installed: go install github.com/rhysd/actionlint/cmd/actionlint@latest" >&2; exit 1; } + actionlint + + actions:local-refs: + desc: "Assert every local `uses:` reference actually exists" + cmds: + # DELEGATES to scripts/local-refs.sh — the SAME script ci-lint.yml runs. + # Under deviation D1 CI does not call this taskfile, so the check lives in + # scripts/ where both paths reach the one copy. Real logic goes in + # scripts/; thin tool invocations stay inline. Do not re-inline this: two + # copies of a non-trivial check is exactly the drift D1 risks. + # + # Unconditional, unlike `actions:` above — actionlint is skipped when no + # workflow changed, but a MISSING stage file is a change to the tree that + # `git diff -- .github/workflows` does not always show. + - sh scripts/local-refs.sh + + actions:run-scripts: + desc: "Assert every repo-relative script named in a `run:` step exists" + cmds: + # ci-lint.yml gained this gate and this file did not, so `task ci` stopped + # predicting CI the moment it was added — a rename that left a caller + # behind passed locally and failed on push, which is the exact drift + # deviation D1 risks and the reason tests/taskfile-parity-test.sh now + # derives its list from ci-lint.yml instead of trusting this one. + - sh scripts/run-scripts-exist.sh + + actions:run-interpolation: + desc: "Assert no expression is interpolated into a shell body" + cmds: + # Shipped uncalled here for the same reason and with a worse consequence: + # what it catches is a crafted input reaching bash. + - sh scripts/run-interpolation.sh + + yaml: + desc: yamllint, when a config is present + cmds: + # yamllint, NOT `uvx yamllint` — yamllint ships on the runner and uv does not. + - | + set -eu + if [ ! -f .yamllint.yml ] && [ ! -f .yamllint.yaml ]; then + echo "no .yamllint config — skipping"; exit 0 + fi + command -v yamllint >/dev/null \ + || { echo "yamllint not installed: uv tool install yamllint" >&2; exit 1; } + yamllint . + + docker: + desc: hadolint every Dockerfile / Containerfile + cmds: + # DELEGATES to scripts/dockerfile-list.sh — the SAME list ci-lint.yml's + # hadolint step reads and .github/actions/setup/detect.sh decides on. This + # was the THIRD hand-written copy of that glob, and every difference was a + # defect: `-name 'Dockerfile*'` also matched `Dockerfilexyz`, the prunes + # missed .venv/ and .worktrees/, and a bare `xargs` splits on ANY + # whitespace, so `my images/Dockerfile` became two arguments and hadolint + # failed on two files nobody wrote. Both of the other two sites carry a + # comment forbidding exactly this; see that script's header for what it + # excludes and why. + - | + set -eu + files=$(sh scripts/dockerfile-list.sh) + [ -n "${files}" ] || { echo "no Dockerfile/Containerfile — skipping hadolint"; exit 0; } + command -v hadolint >/dev/null \ + || { echo "hadolint not installed: brew install hadolint" >&2; exit 1; } + # NUL-delimited, matching ci-lint.yml: a path containing a space must + # reach hadolint as one argument. + printf '%s\n' "${files}" | tr '\n' '\0' | xargs -0 hadolint + + # chart:deps and helm MOVED to .taskfiles/chart.yml. A chart is a domain of its + # own, like a language, and repo.yml is for cross-cutting repo checks. Keeping + # chart logic here meant every repo carried it whether or not it had a chart. diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..f256151 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,70 @@ +# Fleet baseline, tuned for Actions workflows and Helm YAML. +extends: default + +rules: + document-start: + present: false + + line-length: disable + + truthy: + allowed-values: ['true', 'false', 'yes', 'no', 'on', 'off'] + check-keys: false + + comments: + min-spaces-from-content: 1 + + indentation: + spaces: 2 + indent-sequences: true + + empty-lines: + max-end: 1 + +ignore: | + # Machine-managed by copier — its dumper does not indent sequence items, and + # style-linting a generated file is not our business. + .copier-answers.yml + .worktrees/ + node_modules/ + vendor/ + # NOT all of `charts/`, which is what this used to say. Ignoring the whole + # directory left values.yaml and Chart.yaml — the two hand-written files in a + # chart, and the ones most likely to carry a typo — as the only YAML in a + # chart repo that nothing linted. Only the two generated or non-YAML + # subdirectories are skipped: + # + # Helm chart templates are Go templates, not YAML — `{{- if }}` is a hard + # syntax error to yamllint. `helm lint` + `helm template` cover them instead. + templates/ + # crds/ is controller-gen output — regenerated and diffed by + # scripts/chart-drift.sh rather than hand-edited. It marshals through + # sigs.k8s.io/yaml, whose yaml.v2 backend writes block sequences WITHOUT + # indenting them under their key, and `indent-sequences: true` above rejects + # that outright. Linting a generated file can only produce findings nobody is + # in a position to act on. + # + # THIS LIST HAS SILENTLY REVERTED ONCE. tests/yamllint-config-test.sh now RUNS + # this config over a chart-shaped tree and demands it complain: a config that + # lints too little exits 0, which is indistinguishable from a clean repo. + crds/ + # --- repo-specific additions below; everything above is the fleet baseline --- + # + # SAME ROOT CAUSE AS crds/ ABOVE, one directory up. This is a kubebuilder + # project, so controller-gen, kustomize and operator-sdk own these trees and + # all three marshal through sigs.k8s.io/yaml — whose yaml.v2 backend writes + # block sequences WITHOUT indenting them under their key, which + # `indent-sequences: true` rejects outright. Measured before ignoring: 341 + # findings, 136 of them indentation inside config/ alone. + # + # Fixing them does not stick. `make manifests` regenerates config/crd/bases + # and config/rbac/role.yaml, `make generate-release-file` regenerates + # release.yaml, and operator-sdk regenerates bundle/ — so a hand-reindented + # file is undone by the next codegen run, and the drift gate in ci-test would + # then fail on the difference. + # + # Deliberately NOT ignored: examples/ and .github/, which are hand-written and + # were fixed rather than exempted. + config/ + bundle/ + release.yaml diff --git a/scripts/chart-appversion.sh b/scripts/chart-appversion.sh new file mode 100755 index 0000000..780fb38 --- /dev/null +++ b/scripts/chart-appversion.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env sh +# Verify the container image named by a chart's appVersion actually exists. +# +# usage: chart-appversion.sh +# +# WHY THIS GATE EXISTS: appVersion is COMMITTED, not computed. A chart whose +# appVersion names an image that was never published installs cleanly and then +# leaves every pod in ImagePullBackOff — a failure that surfaces in the cluster, +# hours later, instead of in the pipeline that caused it. Publishing is refused +# instead. +# +# WHY A SCRIPT AND NOT AN INLINE STEP: logic a test could cover never lives in +# workflow YAML, because a shell harness cannot reach it there. This is two guards and a normalisation with two branches, and +# tests/chart-appversion-test.sh covers every one of them, which it could not +# have while the block sat inside ci-chart.yml. +# +# THE NORMALISATION RUNS THE OPPOSITE WAY FROM THE CHART TAG. Both directions are +# correct and neither may be "harmonised" with the other — see the README +# section "Two tag conventions, opposite on purpose": +# +# chart tag <- inputs.version BARE semver charts/foo:1.2.3 +# image tag <- appVersion V-PREFIXED ghcr.io/owner/repo:v1.2.3 +# +# Helm resolves "latest" by highest valid SemVer and SemVer forbids a leading +# `v`, so chart tags must stay bare. Images are tagged by ci-build from +# release-please's tag_name, which IS v-prefixed. appVersion is conventionally +# written bare in Chart.yaml, so the `v` is added back here — and NOT added twice +# when a repo happens to write it v-prefixed already. +# +# NO `cd` HERE, deliberately — the same trap chart-deps.sh and chart-drift.sh +# document. The chart directory arrives as an ARGUMENT and is resolved against +# $PWD, which is $GITHUB_WORKSPACE in CI; a `cd "$(dirname "$0")/.."` would +# reinterpret that argument against the script's own directory instead of the +# caller's. +# +# POSIX sh — runs identically on a runner and on macOS. +set -eu + +[ $# -eq 2 ] || { echo "usage: chart-appversion.sh " >&2; exit 2; } +chart=$1 +image=$2 + +# THE TOOL GUARDS ARE NOT CEREMONY. Without them an absent `docker` makes the +# inspect below fail and the script blames the IMAGE, sending whoever reads the +# log to hunt a registry problem that does not exist. Name the real cause. +command -v yq >/dev/null || { echo "yq not installed: brew install yq" >&2; exit 1; } +command -v docker >/dev/null || { echo "docker not installed" >&2; exit 1; } + +# BOTH TESTS ARE LOAD-BEARING, and they catch different spellings. Measured with +# yq v4.53.2: an ABSENT key prints the four-letter string `null`, while a present +# but null key (`appVersion:`) and an empty one (`appVersion: ""`) print nothing. +# An emptiness check alone lets the absent case through, and the ref then becomes +# a literal `:vnull`. +app=$(yq '.appVersion' "${chart}/Chart.yaml") +if [ -z "${app}" ] || [ "${app}" = "null" ]; then + echo "::error::${chart}/Chart.yaml has no appVersion" >&2 + exit 1 +fi + +case "${app}" in + v*) ref="${image}:${app}" ;; + *) ref="${image}:v${app}" ;; +esac + +echo "chart appVersion: ${app}" +echo "checking ${ref}" + +# `imagetools inspect` reads the registry's manifest API — no pull, no daemon +# storage, and it understands multi-arch manifest lists, which `docker manifest +# inspect` handles less predictably. +docker buildx imagetools inspect "${ref}" >/dev/null 2>&1 || { + echo "::error::${ref} does not exist — refusing to publish a chart pointing at a missing image" >&2 + exit 1 +} + +echo "ok: ${ref} exists" diff --git a/scripts/chart-deps.sh b/scripts/chart-deps.sh new file mode 100755 index 0000000..858b109 --- /dev/null +++ b/scripts/chart-deps.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env sh +# Register classic (non-OCI) Helm dependency repos, then build chart +# dependencies from Chart.lock. +# +# WHY A SCRIPT AND NOT AN INLINE STEP: this is the single source of truth for +# dependency resolution, and it has THREE callers — `task chart:deps` locally, +# `task chart:lint` (which cannot template without it), and ci-chart.yml. Under +# deviation D1 CI does not read the taskfile, so an inline taskfile-only version +# would leave CI resolving dependencies differently from local. Same reasoning as +# scripts/local-refs.sh. +# +# `helm dependency build`, NOT `update`: +# build — installs exactly what Chart.lock pins. Reproducible. +# update — re-resolves and REWRITES Chart.lock, so CI would silently drift the +# lock file and publish a chart nobody tested. +# +# This is what replaces a vendored dependency tarball. Renovate can bump a +# dependency's version in Chart.yaml but cannot regenerate a committed +# charts/*.tgz, so the two drift apart silently. Commit Chart.lock, gitignore +# charts/*.tgz, and let this script rebuild them. +# +# POSIX sh — runs identically on a runner and on macOS. +set -eu + +# NO `cd` HERE, deliberately. A dependency resolver must follow the CWD — which +# is $GITHUB_WORKSPACE in CI and the repo root under Task — never wherever the +# script file happens to sit. `cd "$(dirname "$0")/.."` is the defect: it +# resolves a tree the caller did not ask about and, finding no chart there, +# prints "nothing to resolve" and EXITS 0. Silently green, having done nothing — +# which is why tests/chart-deps-test.sh asserts on the chart list produced and +# not on the exit code. +# +# local-refs.sh is the deliberate exception: validating the tree that contains it +# is that gate's whole job. + +# Chart discovery is single-sourced in chart-list.sh — see the trap documented there. +here=$(cd "$(dirname "$0")" && pwd) +dirs=$("${here}/chart-list.sh") +[ -n "${dirs}" ] || { echo "no Chart.yaml — nothing to resolve"; exit 0; } + +# THE TOOL CHECKS COME AFTER DISCOVERY, and the order is the point. +# +# ci-lint.yml runs this stage unconditionally for every repo in the fleet, +# because "does this repo have a chart" is a question only this script can +# answer. Asserting helm and yq first meant a pure-Go library — which never +# reaches a single line below — failed lint the day the runner image stopped +# shipping either one, over tools its pipeline does not use. A stage that is +# meant to be a no-op cannot mean "a no-op, provided two unrelated binaries +# happen to be installed". +# +# Still an assertion and not a skip: once a chart IS found, a missing helm has +# to be loud. Silently declining to resolve dependencies would leave the chart +# templated, linted and published with subcharts that were never built. +command -v helm >/dev/null || { echo "helm not installed: brew install helm" >&2; exit 1; } +command -v yq >/dev/null || { echo "yq not installed: brew install yq" >&2; exit 1; } + +# Iterate WITHOUT a subshell so a `helm` failure still aborts the script under +# `set -e` — piping into `while read` would swallow it. IFS is narrowed to +# newline so a path containing spaces survives the split. +oldifs=$IFS +IFS=' +' +# shellcheck disable=SC2086 # deliberate newline-only split +set -- ${dirs} +IFS=$oldifs + +for d in "$@"; do + c="${d}/Chart.yaml" + count=$(yq '.dependencies | length // 0' "${c}") + if [ "${count}" -eq 0 ]; then + echo "${d}: no dependencies" + continue + fi + + i=0 + while [ "${i}" -lt "${count}" ]; do + name=$(yq ".dependencies[${i}].name" "${c}") + repo=$(yq ".dependencies[${i}].repository" "${c}") + case "${repo}" in + # OCI refs need no `helm repo add` — helm resolves them directly. + oci://*|""|null) : ;; + *) echo "helm repo add ${name} ${repo}"; helm repo add "${name}" "${repo}" >/dev/null ;; + esac + i=$((i + 1)) + done + + echo "helm dependency build ${d}" + helm dependency build "${d}" +done diff --git a/scripts/chart-drift.sh b/scripts/chart-drift.sh new file mode 100755 index 0000000..3c2cbaa --- /dev/null +++ b/scripts/chart-drift.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env sh +# Regenerate the chart from its hack/ generators, then FAIL on any diff. +# +# Operator charts derive their CRDs and RBAC from config/, so a committed chart +# can silently fall behind the API types. This regenerates and then fails — +# it never fixes the drift, because a CI step that mutates the tree hides the +# problem it exists to surface. +# +# WHY A SCRIPT AND NOT AN INLINE STEP: two reasons, and the second is the one +# that forced it. +# +# 1. Logic a test could cover lives here, never in workflow YAML. This +# is three branches and a git verdict; tests/chart-drift-test.sh covers all +# of them, and could not have if it stayed inline. +# 2. scripts/run-scripts-exist.sh resolves a workflow's `run:` script paths +# against THIS repo, and correctly reported `hack/sync-helm-crds.sh` as +# missing when this block was inline in ci-lint.yml. It is not wrong: the +# generators belong to the CONSUMER's workspace and this repo has no hack/. +# Naming them here — in a script, where the gate does not look, because a +# script is not a workflow — states that ownership instead of suppressing +# the gate. +# +# NO `cd` HERE, deliberately — the same trap chart-deps.sh documents. A drift +# gate that resolved its own directory instead of the CWD would regenerate and +# diff whichever tree the script file sits in: always clean, always green, +# checking nothing. It operates on $PWD, which is $GITHUB_WORKSPACE in CI and the +# repo root under Task. +# +# The generators are OPTIONAL and named exactly, not globbed: `hack/` in an +# operator repo also holds install-tools.sh and friends, and running everything +# in it would execute arbitrary consumer scripts. A repo with neither generator +# has a hand-maintained chart and is skipped. +# +# POSIX sh — runs identically on a runner and on macOS. +set -eu + +ran=0 +for g in hack/sync-helm-crds.sh hack/generate-helm-rbac.sh; do + # -x, not -f: a generator committed without its exec bit cannot be run, and + # `./x` would die with a permission error instead of skipping. + [ -x "${g}" ] || continue + "./${g}" + ran=1 +done + +if [ "${ran}" = 0 ]; then + echo "chart is hand-maintained (no hack/ generators) — skipping drift gate" + exit 0 +fi + +# `git status`, NOT `git diff`. THE VERDICT MUST INCLUDE UNTRACKED FILES. +# +# `git diff --quiet -- charts` compares TRACKED files only, and the ordinary way +# a chart falls behind its API types is a file that does not exist yet: +# controller-gen emits ONE FILE PER CRD, so adding a type adds a file. Left +# untracked, it is invisible to `git diff`, the gate exits 0, and the chart +# publishes without the CRD — `helm install` succeeds and the operator fails in +# the cluster, hours later. Measured on a scratch repo: after +# `printf 'new: crd\n' > charts/crds-brand-new.yaml`, `git diff --quiet -- charts` +# exits 0 while `git status --porcelain -- charts` prints `?? charts/…`. +# +# `--untracked-files=all` lists the individual files rather than collapsing a +# new directory to one entry, so the diagnostic names what actually appeared. +# +# THIS IS ONE MECHANISM WITH .gitignore, not two. Everything the gate tolerates +# is what .gitignore excludes — and in CI chart-deps.sh has already run +# `helm dependency build charts`, which writes charts/charts/.tgz. That +# path is NOT matched by `charts/*.tgz`, so `.gitignore` carries `**/charts/*.tgz` +# alongside it — verified against the shipped .gitignore, which has both. Narrowing one without the other fails every chart repo +# that has a dependency. tests/chart-drift-test.sh covers both directions with +# THIS repo's .gitignore copied into the fixture, so the pair cannot drift. +# +# `-- charts` is plural and hardcoded: one sanctioned layout fleet-wide. +# An unmatched pathspec exits 0, so a repo still using `chart/` would pass this +# gate while checking nothing — which is why `chart-path` is not an input. +drift=$(git status --porcelain --untracked-files=all -- charts) +if [ -n "${drift}" ]; then + echo "::error::chart is stale — run: task chart:generate" + printf '%s\n' "${drift}" + exit 1 +fi + +echo "chart is up to date with config/" diff --git a/scripts/chart-list.sh b/scripts/chart-list.sh new file mode 100755 index 0000000..7b9f032 --- /dev/null +++ b/scripts/chart-list.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env sh +# Print each of THIS repo's chart directories, one per line, relative to $PWD. +# +# THE SINGLE SOURCE OF TRUTH for "which Chart.yaml files are ours". This glob +# previously existed in two places — ci-lint.yml and chart-deps.sh — and produced +# two separate fail-silent bugs, so it lives here and is tested. +# +# The exclusion must skip SUBCHARTS without skipping the chart itself: +# +# ./charts/Chart.yaml KEEP — the fleet-standard location +# ./charts/charts/dep/Chart.yaml DROP — an extracted dependency +# +# The stock template used `-not -path '*/charts/*'`, which ALSO excludes +# ./charts/Chart.yaml, so every chart task silently no-opped and still exited 0. +# Anchoring to '*/charts/*/Chart.yaml' keeps the chart and drops its subcharts, +# because a subchart always sits one directory deeper. +# +# THE VENDORED TREES ARE PRUNED TOO, and the list is the one discover-dirs.sh +# already carries. Pruning only `.git` returned SIX directories from +# tests/fixtures/pruned where one is the repo's own, so a dependency's chart in +# node_modules/ or vendor/ was linted, templated and dependency-resolved as if +# the consumer owned it. +# +# THE LIST IS SHARED KNOWLEDGE with discover-dirs.sh and dockerfile-list.sh — +# "directories whose contents belong to someone else" — so keep the three in +# step. `.ci-shared` is a legacy name from a CI layout this pipeline no longer +# uses; it stays because pruning a directory that never appears costs nothing, +# while resolving a foreign chart as this repo's own is the exact damage the list +# exists to prevent. +# +# `testdata` IS IN THE LIST FOR A DIFFERENT REASON FROM THE REST, and it is not +# a vendored tree. The Go toolchain defines a directory of that name as excluded +# from the build, so its contents are FIXTURES — a repo's own, but not its +# shipped source. Left unpruned, a Go repo with testdata/**/package.json was +# reported as a Node project, and the setup action then ran actions/setup-node +# against a .node-version that copier's `_exclude` never wrote, killing the job +# outright. The same file as a Chart.yaml gets a fixture chart linted and +# dependency-resolved; as a Dockerfile it gets hadolint findings nobody can act +# on. All four callers of this list prune it now. +# +# POSIX sh — runs identically on a runner and on macOS. +set -eu + +charts=$(find . -name Chart.yaml \ + -not -path '*/.git/*' \ + -not -path '*/.ci-shared/*' \ + -not -path '*/node_modules/*' \ + -not -path '*/vendor/*' \ + -not -path '*/.venv/*' \ + -not -path '*/.worktrees/*' \ + -not -path '*/testdata/*' \ + -not -path '*/charts/*/Chart.yaml') +[ -n "${charts}" ] || exit 0 + +# Split on NEWLINE ONLY so a path containing spaces survives, and stay in the +# current shell so a caller's `set -e` still aborts on failure — piping into +# `while read` would swallow it (SC2044). +oldifs=$IFS +IFS=' +' +# shellcheck disable=SC2086 # deliberate newline-only split, per IFS above +set -- ${charts} +IFS=$oldifs + +for c in "$@"; do + dirname "${c}" +done diff --git a/scripts/copier-freshness.sh b/scripts/copier-freshness.sh new file mode 100755 index 0000000..3886d60 --- /dev/null +++ b/scripts/copier-freshness.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env sh +# Report whether this repo's copier-generated files are behind the template. +# +# WHY THIS EXISTS: every file here is COPIED — the stages, the composite action, +# these scripts, and every config a tool reads. That is what makes a run +# reproducible from one commit, and it is also what lets a repo fall arbitrarily +# far behind the template with nothing anywhere saying so. +# +# copier records in .copier-answers.yml the template version a repo generated +# from; this stage runs in every repo. Neither half can detect the drift alone. +# +# WARNS, NEVER FAILS. A hard failure would turn every repo red the moment the +# template moves — reintroducing exactly the fleet-wide blast radius this design +# works to avoid. The gap being closed is "undetected", not "unblocked". Every +# path out of this script exits 0, deliberately. +# +# POSIX sh — runs identically on a runner and on macOS. Operates on $PWD. +set -eu + +ANSWERS=.copier-answers.yml +[ -f "${ANSWERS}" ] || { echo "no ${ANSWERS} — repo is not copier-managed, skipping"; exit 0; } + +have=$(grep '^_commit:' "${ANSWERS}" | head -1 | sed 's/^_commit:[[:space:]]*//; s/["'"'"']//g') +src=$(grep '^_src_path:' "${ANSWERS}" | head -1 | sed 's/^_src_path:[[:space:]]*//; s/["'"'"']//g') +[ -n "${have}" ] || { echo "::warning::${ANSWERS} has no _commit — cannot check freshness"; exit 0; } + +# Resolve owner/repo from a gh: shorthand or an https URL; anything else (a local +# path, as used while developing the template) is unresolvable and skipped. +case "${src}" in + gh:*) slug=${src#gh:} ;; + https://github.com/*) slug=${src#https://github.com/}; slug=${slug%.git} ;; + *) echo "template source '${src}' is not a GitHub repo — skipping freshness check"; exit 0 ;; +esac + +# `|| true` covers every way this can fail — no network, no auth, no releases, +# no `gh` on PATH — because none of them is this repo's problem to fail on. +latest=$(gh api "repos/${slug}/releases/latest" --jq .tag_name 2>/dev/null || true) +if [ -z "${latest}" ]; then + echo "::warning::could not resolve the latest release of ${slug} — freshness unknown" + exit 0 +fi + +if [ "${have}" = "${latest}" ]; then + echo "copier template ${slug} is current (${have})" +else + echo "::warning file=${ANSWERS}::template ${slug} is at ${latest}, this repo was generated from ${have}. Run: uvx copier update --trust" +fi diff --git a/scripts/discover-dirs.sh b/scripts/discover-dirs.sh new file mode 100755 index 0000000..13a4241 --- /dev/null +++ b/scripts/discover-dirs.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env sh +# Print this repo's Python and JS/TS project directories as two env-style lines: +# +# PY_DIRS= +# JS_DIRS= +# +# Two lines, nothing else on stdout. ci-lint.yml redirects them straight into +# $GITHUB_ENV and every later step word-splits the value: +# +# [ -n "${PY_DIRS}" ] || { echo "no Python project — skipping"; exit 0; } +# for d in ${PY_DIRS}; do (cd "${d}" && ruff check .); done +# +# So the contract is exact, and every way of missing it is silent and GREEN: a +# dropped key leaves the variable unset and lints nothing, and a value of " " +# instead of "" is non-empty to `[ -n ]`, so it skips the "no project" branch +# and then loops zero times. Emit empty, not blank. +# +# WHY A SCRIPT AND NOT AN INLINE STEP: this logic used to live inline in +# ci-lint.yml. A shell harness cannot test logic embedded in workflow YAML +# without re-implementing it, and a gate that tests its own copy of the code +# stays green while the workflow drifts away from it. +# +# NOTHING here hardcodes a project directory. Repos in this fleet put Python in +# radar/ (tempestwx) or renderer/ (dras), and JS at the repo root (ws4kp, +# tempest-display) or in web/ (tempestwx). A `working-directory:` per step would +# make the calling workflow repo-specific, which defeats sharing it unchanged. +# +# The prune list is shared knowledge with chart-list.sh and dockerfile-list.sh — +# "directories whose contents belong to someone else" — so keep the three in +# step. `.ci-shared` is a legacy name from a CI layout this pipeline no longer +# uses; it stays because pruning a directory that never appears costs nothing, +# while discovering a dependency's project as this repo's own is what the list +# exists to prevent. +# +# `testdata` IS IN THE LIST FOR A DIFFERENT REASON FROM THE REST, and it is not +# a vendored tree. The Go toolchain defines a directory of that name as excluded +# from the build, so its contents are FIXTURES — a repo's own, but not its +# shipped source. Left unpruned, a Go repo with testdata/**/package.json was +# reported as a Node project, and the setup action then ran actions/setup-node +# against a .node-version that copier's `_exclude` never wrote, killing the job +# outright. The same file as a Chart.yaml gets a fixture chart linted and +# dependency-resolved; as a Dockerfile it gets hadolint findings nobody can act +# on. All four callers of this list prune it now. +# +# Chart discovery is NOT here — that is scripts/chart-list.sh's single job. +# +# POSIX sh — runs identically on a runner and on macOS. +set -eu + +prune='/(\.git|\.ci-shared|node_modules|vendor|\.venv|\.worktrees|testdata)/' + +# Reduce a newline-separated list of marker files to the sorted, de-duplicated, +# space-separated list of the directories holding them. +# +# NOT `xargs -n1 dirname`, which the inline original used: xargs splits its input +# on ANY whitespace, so a marker at "./my web/package.json" becomes two arguments +# and the result is "." plus "web" — two directories that were never found, one +# of which is the repo root. Verified on macOS. Splitting on newline only, the +# way chart-list.sh does, is what keeps a real path intact. +dirs_of() { + oldifs=$IFS + IFS=' +' + # shellcheck disable=SC2086 # deliberate newline-only split, per IFS above + set -- $1 + IFS=$oldifs + + out='' + for f in "$@"; do + d=$(dirname "${f}") + # A space-separated list cannot carry a path containing a space. REFUSE + # rather than emit a mangled one: the caller would `cd` into directories + # that do not exist, or worse, into the repo root and lint the whole tree. + case "${d}" in + *[[:space:]]*) + echo "discover-dirs: ${d}: project directory contains whitespace" >&2 + echo "discover-dirs: PY_DIRS/JS_DIRS are space-separated and cannot carry it" >&2 + exit 1 + ;; + esac + out="${out}${d} +" + done + + printf '%s' "${out}" | sort -u | tr '\n' ' ' +} + +# `|| true` because grep exits 1 when it filters everything out, which is the +# ordinary "this repo has no Python" case, not an error. +py_files=$(find . \( -name pyproject.toml -o -name requirements.txt \) \ + | grep -Ev "${prune}" || true) +js_files=$(find . -name package.json \ + | grep -Ev "${prune}" || true) + +# Assign FIRST, print second. `echo "PY_DIRS=$(dirs_of …)"` looks equivalent and +# is not: dirs_of's `exit 1` only leaves the command substitution's subshell, and +# the surrounding `echo` then succeeds — so the refusal above wrote its message to +# stderr, printed "PY_DIRS=" to stdout anyway, and exited 0. A bare assignment +# takes the substitution's exit status, so `set -e` aborts before anything is +# written. Caught by tests/discovery-test.sh; do not re-inline these. +py=$(dirs_of "${py_files}") +js=$(dirs_of "${js_files}") + +echo "PY_DIRS=${py}" +echo "JS_DIRS=${js}" diff --git a/scripts/dockerfile-list.sh b/scripts/dockerfile-list.sh new file mode 100755 index 0000000..5851145 --- /dev/null +++ b/scripts/dockerfile-list.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env sh +# Print each of THIS repo's Dockerfiles and Containerfiles, one per line, +# relative to $PWD. +# +# THE SINGLE SOURCE OF TRUTH for "which Dockerfiles are ours", the same job +# chart-list.sh does for charts. This glob previously existed in THREE places — +# .github/actions/setup/detect.sh, ci-lint.yml's hadolint step, and a copy of +# the extension filter inside each — and the copies disagreed: +# +# detect.sh pruned every dot-directory (`-name '.*'`) +# ci-lint.yml's find pruned none +# +# So a repo with `.devcontainer/Dockerfile`, a very common layout, got +# docker=false from detect.sh — the setup action therefore never installed +# hadolint — while ci-lint.yml's own find handed that same file to +# `xargs hadolint`. The step died with `hadolint: command not found`, exit 127, +# propagated by `set -euo pipefail`, blaming a Dockerfile that was fine. Both +# callers now read this one answer, so they cannot disagree again. +# +# DOT-DIRECTORIES ARE NOT PRUNED, deliberately. Of the two ways to make the +# callers agree, this is the loud one: a .devcontainer/ or .docker/ Dockerfile +# is found, hadolint is installed, and it gets linted. Pruning them instead +# would have made the disagreement disappear by silently linting nothing, which +# is the failure class this repo exists to remove. The vendored trees below are +# named explicitly instead — the same list discover-dirs.sh and chart-list.sh +# carry, `.ci-shared` included: it is a legacy name from a CI layout this +# pipeline no longer uses, kept because pruning a directory that never appears +# costs nothing. +# +# `testdata` IS IN THE LIST FOR A DIFFERENT REASON FROM THE REST, and it is not +# a vendored tree. The Go toolchain defines a directory of that name as excluded +# from the build, so its contents are FIXTURES — a repo's own, but not its +# shipped source. Left unpruned, a Go repo with testdata/**/package.json was +# reported as a Node project, and the setup action then ran actions/setup-node +# against a .node-version that copier's `_exclude` never wrote, killing the job +# outright. The same file as a Chart.yaml gets a fixture chart linted and +# dependency-resolved; as a Dockerfile it gets hadolint findings nobody can act +# on. All four callers of this list prune it now. +# +# THE EXTENSION FILTER IS ANCHORED TO /. and must stay that way. A blanket +# `\.(json|md|txt)$` also strips every package.json, which is how Node became +# undetectable the first time this was written. Dockerfile. is a real +# Dockerfile and is kept; Dockerfile.json is tooling junk and would fail +# hadolint as a parse error, blaming the repo for a file nobody wrote. +# +# Containerfile is the OCI/Podman spelling and hadolint parses it identically. +# Omitting it was FAIL-SILENT: docker=false gave variant=library, and build, +# smoke and release-image vanished from that repo's pipeline with no error. +# +# NO `cd` HERE, deliberately — the same trap chart-deps.sh and chart-drift.sh +# document. It operates on $PWD, which is $GITHUB_WORKSPACE in CI. +# +# POSIX sh — runs identically on a runner and on macOS. +set -eu + +# `|| true` because grep exits 1 when it filters everything out, which is the +# ordinary "this repo's only Dockerfile is a Dockerfile.json" case, not an error. +files=$(find . -type f \ + \( -name 'Dockerfile' -o -name 'Dockerfile.*' \ + -o -name 'Containerfile' -o -name 'Containerfile.*' \) \ + -not -path '*/.git/*' \ + -not -path '*/.ci-shared/*' \ + -not -path '*/node_modules/*' \ + -not -path '*/vendor/*' \ + -not -path '*/.venv/*' \ + -not -path '*/.worktrees/*' \ + -not -path '*/testdata/*' \ + | grep -Ev '/(Docker|Container)file\.(json|md|txt|ya?ml|lock)$' || true) + +[ -n "${files}" ] || exit 0 + +# SORTED, so both callers see the same list in the same order — detect.sh takes +# the FIRST entry, and an unstable order would make repo-shape detection depend +# on the filesystem. +printf '%s\n' "${files}" | sort diff --git a/scripts/local-refs.sh b/scripts/local-refs.sh new file mode 100755 index 0000000..d3ebcc2 --- /dev/null +++ b/scripts/local-refs.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env sh +# Assert every local `uses:` reference in .github/workflows/ points at something +# that actually exists. +# +# WHY THIS EXISTS: actionlint has a verified blind spot, but a NARROWER one +# than this comment used to claim. Measured against v1.7.12: +# +# uses: ./.github/workflows/missing.yml -> actionlint DOES catch it +# ("could not read reusable workflow file", exit 1) +# uses: ./.github/actions/missing -> actionlint says NOTHING, exit 0 +# +# So the composite-action case is the real gap, and it is the one that bites: +# a consumer that excludes a stage but keeps its `uses:` gets caught by +# actionlint, while a typo'd action path ships silently. A bogus *input* to a +# local composite action IS caught, but `uses: ./path` where the path does not +# exist is SILENT. Every stage in this pipeline is a local reference, so a stage +# file or .github/actions/setup/ the template did not write passes lint cleanly +# and fails only at run time — on main, after merge. +# +# THIS IS THE GATE ON THE TEMPLATE'S PER-VARIANT EXCLUDES. A library repo gets no +# ci-build.yml; a repo with no chart gets no ci-chart.yml. Should ci.yaml ever +# call a stage its own variant excluded, nothing else in the tree notices — not +# actionlint, not yamllint. This does. +# +# Called by ci-lint.yml and by `task repo:actions:local-refs`, so CI and local +# run the one copy. +# +# POSIX sh, no bashisms — runs identically on a runner and on macOS. +set -eu + +# "Validate the tree that contains me" is this gate's whole job, so it resolves +# its own root rather than trusting the CWD — it behaves identically run from the +# repo root or from a subdirectory, and tests/gates-test.sh copies it into a +# scratch tree precisely because of this line, which is what pins the behaviour. +# +# Do NOT copy it into a script that operates on the workspace: chart-deps.sh had +# to lose exactly this line, because a chart resolver must follow the CWD. +cd "$(dirname "$0")/.." + +[ -d .github/workflows ] || { echo "no .github/workflows — nothing to check"; exit 0; } + +# `find -print` into a variable, NOT a bare `*.yml *.yaml` glob pair: when one +# glob has no match, awk dies on the literal unexpanded argument, 2>/dev/null +# hides the error, and the pipe swallows the exit status — so an all-.yaml repo +# silently "passed" while checking nothing. +files=$(find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \)) +[ -n "${files}" ] || { echo "no workflow files — nothing to check"; exit 0; } + +fail=0 + +# THE MATCH IS ANCHORED AT LINE START, and this is load-bearing. +# +# An optional leading `#` is allowed, so a COMMENTED-OUT directive is still +# checked — a commented reference to a stage file you never copied is a live +# landmine the moment someone uncomments it. If you do not ship the stage, +# DELETE the lines rather than commenting them. +# +# But the match must not be a bare substring search. An unanchored version +# matches PROSE that merely mentions the string — e.g. this file's own comment +# above, "a bogus `uses: ./path` where the path does not exist" — and then emits +# `./path``, `where`, and `the` as missing actions. That is not hypothetical: +# it is what the unanchored version did when first run against this tree. +# +# Anchoring to (indent)(optional #)(optional "- ")uses: admits real YAML, +# commented or not, and rejects prose, where `uses:` is always preceded by other +# words or a backtick. +# shellcheck disable=SC2086 # deliberate word split over the file list +refs=$(awk ' + /^[[:space:]]*#?[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*\.\// { + sub(/^[[:space:]]*#?[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*/, "") + sub(/[[:space:]].*$/, "") # first token only — drop trailing comments + print + }' ${files} | sort -u) + +for ref in ${refs}; do + case "${ref}" in + *.yml|*.yaml) + # A reusable workflow — must be a file. + [ -f "${ref#./}" ] || { echo "MISSING workflow: ${ref}"; fail=1; } + ;; + *) + # A composite action — must be a DIRECTORY containing action.yml. + d="${ref#./}" + if [ ! -f "${d}/action.yml" ] && [ ! -f "${d}/action.yaml" ]; then + echo "MISSING action: ${ref} (no action.yml)" + fail=1 + fi + ;; + esac +done + +if [ "${fail}" != "0" ]; then + echo "::error::a local uses: reference points at nothing" + exit 1 +fi + +echo "local uses: references ok" diff --git a/scripts/run-interpolation.sh b/scripts/run-interpolation.sh new file mode 100755 index 0000000..9eacc0e --- /dev/null +++ b/scripts/run-interpolation.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env sh +# Assert that no `run:` block interpolates a GitHub expression. +# +# WHY THIS EXISTS: ci-lint.yml already states the rule — "a published +# `workflow_call` input must never be pasted straight into a shell command" — +# and every stage but one obeyed it. A rule with no gate is a rule that holds +# until someone is in a hurry, which is what happened in ci-retag.yml: +# +# for t in ${{ steps.tags.outputs.list }}; do +# +# GitHub substitutes expressions into the run: script as TEXT, before any shell +# sees it, so whatever the expression contains becomes shell source. That list +# is derived from a `workflow_call` input a consumer supplies, and the shape +# check it passed through was `case "${v}" in *.*.*)`, which `v1.2.3$(id -un)` +# satisfies. The correct form is an `env:` entry and a "${VAR}" reference, which +# reaches the shell as DATA. +# +# Note that even a trusted expression belongs in `env:`: an image digest or an +# action path is not attacker-controlled, but the reviewer of the next diff +# cannot tell the safe interpolations from the unsafe ones at a glance, and a +# rule with exceptions is a rule nobody applies. +# +# COMPOSITE ACTIONS ARE INCLUDED. actionlint cannot lint them at all (see +# self-test.yml), so for `.github/actions/*/action.yml` this is the only static +# check there is. +# +# yq, NOT grep: the question is "is this expression inside a run: value", and +# answering it by hand means re-implementing YAML block-scalar parsing. `run:` +# steps also sit at different depths in a workflow and in a composite action, so +# the recursive descent below covers both without an allowlist of paths. A +# comment inside a run: block is deliberately still matched — GitHub expands +# expressions across the whole block, comments included, which ci-retag.yml +# documents having been bitten by. +# +# NO `cd` HERE. It operates on $PWD, the way run-scripts-exist.sh does, which is +# what lets tests/gates-test.sh point it at a fixture tree. +# +# POSIX sh — runs identically on a runner and on macOS. +set -eu + +dirs='' +if [ -d .github/workflows ]; then dirs="${dirs} .github/workflows"; fi +if [ -d .github/actions ]; then dirs="${dirs} .github/actions"; fi +[ -n "${dirs}" ] || { echo "no workflows or composite actions — nothing to check"; exit 0; } + +# shellcheck disable=SC2086 # deliberate word split over the directory list +files=$(find ${dirs} -type f \( -name '*.yml' -o -name '*.yaml' \)) +[ -n "${files}" ] || { echo "no workflow files — nothing to check"; exit 0; } + +# Fail loudly rather than checking nothing. yq ships on ubuntu-latest. +command -v yq >/dev/null || { echo "yq not installed: brew install yq" >&2; exit 1; } + +# Split on NEWLINE ONLY so a path containing spaces survives, and stay in the +# current shell so `set -e` still aborts on failure (SC2044). +oldifs=$IFS +IFS=' +' +# shellcheck disable=SC2086 # deliberate newline-only split, per IFS above +set -- ${files} +IFS=$oldifs + +bad=0 + +for wf in "$@"; do + # Every `run:` value in the document, however deeply nested. The `!!map` guard + # is required: `has()` errors out on a scalar, and `..` visits every scalar. + runs=$(yq -r '.. | select(type == "!!map" and has("run")) | .run' "${wf}") + [ -n "${runs}" ] || continue + + # `[$]` is a bracket expression, not an escape. It keeps the dollar literal + # for grep AND out of shellcheck's SC2016 heuristic. + hits=$(printf '%s\n' "${runs}" | grep '[$]{{' || true) + [ -n "${hits}" ] || continue + + echo "::error file=${wf}::a run: block interpolates an expression — pass it through env: instead" >&2 + printf '%s\n' "${hits}" >&2 + bad=1 +done + +[ "${bad}" = 0 ] || exit 1 +echo "run-interpolation: ok" diff --git a/scripts/run-scripts-exist.sh b/scripts/run-scripts-exist.sh new file mode 100755 index 0000000..c7bce20 --- /dev/null +++ b/scripts/run-scripts-exist.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env sh +# Assert every repo-relative script named in a workflow's `run:` step exists. +# +# WHY THIS EXISTS: static analysis has a hole exactly this shape. The library +# repo shipped a `run:` step invoking a script that was never copied; it passed +# actionlint, yamllint AND local-refs.sh cleanly, because local-refs.sh checks +# `uses:` and nothing checks `run:`. It failed at run time with exit 127 — on +# main, after merge. `uses: ./missing` and `run: sh missing.sh` are the same +# defect wearing different syntax, so they get the same gate. +# +# Comments are blanked first so prose mentioning a script name — including this +# header if it is ever copied into a workflow — cannot false-positive. +# +# WHAT IS DELIBERATELY NOT CHECKED, because the path is not knowable statically: +# +# /usr/local/bin/x.sh absolute — not this repo's to guarantee +# ${ANY_VAR}/x.sh an expansion — skipped rather than guessed at +# scripts/*.sh a glob; `*` is outside the token class, so it +# never matches in the first place +# --jq '.object.sha' a jq FIELD PATH — see the word boundary below +# +# NO EXPANSION IS RESOLVED, and there is nothing left for one to mean: the stages +# live in the same tree as the scripts they call, so every reference is a plain +# repo-relative `sh scripts/foo.sh`. +# +# POSIX sh — runs identically on a runner and on macOS. +set -eu + +[ -d .github/workflows ] || { echo "no .github/workflows — nothing to check"; exit 0; } + +# `find` into a variable, NOT a bare `*.yml *.yaml` glob pair — see the trap +# documented in local-refs.sh, which this repeats deliberately rather than +# coupling the two gates together. +files=$(find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \)) +[ -n "${files}" ] || { echo "no workflow files — nothing to check"; exit 0; } + +# Split on NEWLINE ONLY so a path containing spaces survives, and stay in the +# current shell so `set -e` still aborts on failure (SC2044). +oldifs=$IFS +IFS=' +' +# shellcheck disable=SC2086 # deliberate newline-only split, per IFS above +set -- ${files} +IFS=$oldifs + +missing=0 + +for wf in "$@"; do + # `|| true` because grep exits 1 when a workflow names no script at all, + # which is the ordinary case, not an error. + # THE TRAILING WORD BOUNDARY IS LOAD-BEARING, exactly as local-refs.sh's + # leading anchor is. Without it, `[token]+\.sh` matches INSIDE a longer word: + # `--jq '.object.sha'` yields `.object.sh`, and the gate fails a correct + # workflow over a script nobody named. ci-retag.yml resolves annotated tags + # that way, so this was not hypothetical. A gate that cries wolf gets + # disabled, which costs more than the defect it was catching. + # + # `.sh` must therefore be followed by a NON-token character or end-of-line. + # grep has no lookahead, so that character is captured and then stripped — + # a quote, a space, a `&`. It cannot hide a second reference: grep -o + # resumes scanning immediately after each match. + # + # shellcheck disable=SC2016 # the '${}' below are CHARACTER CLASS members, so + # an expansion is matched as literal workflow text. + # Do not requote. + refs=$(sed 's/[[:space:]]*#.*$//' "${wf}" \ + | grep -oE '[A-Za-z0-9_./${}-]+\.sh([^A-Za-z0-9_./${}-]|$)' \ + | sed 's|[^A-Za-z0-9_./${}-]$||' \ + | sort -u || true) + [ -n "${refs}" ] || continue + + # Safe to word-split: the token class above admits no whitespace. + for r in ${refs}; do + case "${r}" in + /*) continue ;; + *) p=${r} ;; + esac + # An expansion makes the path unresolvable — skip it, do not guess. + case "${p}" in *'$'*) continue ;; esac + + if [ ! -f "${p}" ]; then + echo "::error file=${wf}::run: references '${r}' which does not exist at '${p}'" >&2 + missing=1 + fi + done +done + +[ "${missing}" = 0 ] || exit 1 +echo "run-scripts-exist: ok" diff --git a/taskfile.yml b/taskfile.yml new file mode 100644 index 0000000..2527e54 --- /dev/null +++ b/taskfile.yml @@ -0,0 +1,152 @@ +# LOCAL DEVELOPER ENTRY POINT. Lowercase filename — `Taskfile.yml` is invisibly +# different on macOS and genuinely different on a Linux runner. +# +# DEVIATION D1: CI does NOT call this file. ci-lint.yml invokes each tool as its +# own step so GitHub can attribute annotations and timing per check. This file +# exists so you can run the same checks locally, before pushing. +# +# THE CONTRACT THAT MATTERS: `task ci` here must stay equivalent to the steps in +# .github/workflows/ci-lint.yml. If they drift, this file stops predicting CI and +# becomes worse than useless — you would trust a green local run that CI rejects. +# +# What keeps them honest without duplicating knowledge: +# - The RULES live in the config files, read by both paths: +# .golangci.yml .yamllint.yml .hadolint.yaml ruff.toml eslint.config.mjs +# - Tool VERSIONS live once, in .github/actions/setup/action.yml. Install +# locally to match; do not float. +# - Real logic lives in scripts/, which BOTH the stages and this file call. +# This file is LOCAL DEV ONLY; it never reimplements a script. +# Only the invocation is duplicated, and an invocation is shared shape, not +# shared knowledge. +# +# `task smoke` is the ONE target CI still calls (from ci-smoke.yml), because what +# "the image behaves" means is repo knowledge, not pipeline knowledge. +version: '3' + +includes: + repo: + taskfile: .taskfiles/repo.yml + + # --- one entry per language present --- + # + # Where the lint configs live differs BY TOOL, and it is not a style choice: + # ruff.toml REPO ROOT. ruff walks up from each file, so one root + # config covers every Python project. Verified. A project + # needing different rules drops its own with + # `extend = "../ruff.toml"` rather than a second full copy. + # eslint.config.* BESIDE package.json, never the root — a flat config + # `import`s its plugins and Node resolves those relative to + # the config file, so it must sit where that project's + # node_modules is. One per JS project, unavoidably. + # + # Rust would be added the same way; nothing in this fleet uses it. + go: + taskfile: .taskfiles/go.yml + # dir: svc # only if go.mod is NOT at the repo root — leave commented otherwise. + # Every target in go.yml is a module-root operation, the same reason + # ci-lint.yml gives each of its Go steps `working-directory:` from the setup + # action's `godir` output. Unlike node/python this one fails loudly when it + # is wrong (`go.mod file not found in current directory or any parent + # directory`), so it is a note rather than a trap. + + # `dir:` names THIS repo's project directory, and it is RENDERED COMMENTED OUT + # ON PURPOSE — copier cannot know where your code lives, and a guess here is + # not a harmless placeholder. Task CREATES a `dir:` that does not exist, so a + # rendered `dir: web` in a repo whose JS is at the root silently made an empty + # web/, ran eslint over nothing and reported success, while the actual sources + # went unlinted. Left off, the project is the repo root and a wrong guess is + # immediately loud: `npm ci` and `uv sync` both fail with ENOENT. + # + # So: leave it commented when the project IS the repo root (ws4kp, + # tempest-display), and uncomment with the real directory otherwise. + # ci-lint.yml does NOT hardcode these; it discovers them with + # scripts/discover-dirs.sh, because that file is copied in verbatim while this + # one is per-repo config. + + # Chart repos only — delete this include if the repo ships no Helm chart. + # NOT given a `dir:`: chart.yml runs from the repo root because generation + # reads config/ and the drift gate diffs against the whole worktree. + +tasks: + default: + desc: List all tasks + cmds: + - task --list + + ci: + desc: Everything ci-lint.yml runs. Run this before pushing. + cmds: + - task: repo:ci + - task: go:ci + + lint: + desc: Static checks only — no tests + cmds: + - task: repo:ci + - task: go:lint + + test: + desc: Unit tests only + cmds: + - task: go:test + + fmt: + desc: Apply every available autofix + cmds: + - task: go:fmt + + build: + desc: Build binaries into ./bin/ + cmds: + - task: go:build + + # CALLED BY CI (ci-smoke.yml). Fill in per repo — what "behaves" means is + # repo-specific and is exactly the knowledge the pipeline should not hold. + # Locally: IMAGE=ghcr.io/owner/repo@sha256:... task smoke + smoke: + desc: Boot the built image and assert it behaves + requires: + vars: [IMAGE] + # Accepts BOTH `IMAGE=… task smoke` and `task smoke IMAGE=…`. Without this, + # the CLI form satisfies `requires` but leaves $IMAGE empty in the shell. + env: + IMAGE: '{{.IMAGE}}' + # NOT the stock "boot it and curl :8080" body, which cannot work here and + # would have failed on the first release. Two reasons, both structural: + # + # 1. The manager calls ctrl.GetConfigOrDie() before it serves anything, so + # with no kubeconfig and no in-cluster service account it exits 1. A + # readiness poll would time out against a container that was never + # going to listen. + # 2. Its metrics listener on :8080 is wrapped in + # WithAuthenticationAndAuthorization, so even in-cluster an unauthenticated + # GET is a 401 — `curl -sf` treats that as failure. The health probes are + # on :8081, not :8080, so the stock port is wrong as well. + # + # What is left that is still worth asserting without a cluster: the image + # runs, the binary inside it is the right architecture, and it is the manager + # rather than some other binary that got copied in. `--help` proves all three + # and is deterministic — Go's flag package prints usage and exits 0 (verified; + # it is ErrHelp, not the exit-2 path an unknown flag takes). + cmds: + - | + set -eu + echo "smoke testing ${IMAGE}" + out=$(docker run --rm "${IMAGE}" --help 2>&1) || { + echo "${out}" + echo "::error::manager did not run in ${IMAGE}"; exit 1 + } + printf '%s\n' "${out}" | grep -q -- '-agent-image' || { + printf '%s\n' "${out}" + echo "::error::binary ran but does not look like the manager (no -agent-image flag)"; exit 1 + } + echo "ok: manager binary runs and exposes its flags" + + # Convenience only — CI builds images itself via ci-build.yml. + image: + desc: Build the image locally (single-arch, not pushed) + cmds: + - docker build -t "{{.REPO_NAME}}:dev" . + vars: + REPO_NAME: + sh: basename "$(git rev-parse --show-toplevel)" From 0b5252d4569cf059ad01a0d14a6446e4bd8e9f2b Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:05:54 -0700 Subject: [PATCH 2/7] test: split the envtest suite out of the default test run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags internal/controller's suite with `//go:build integration`, matching the `e2e` tag internal/it already carries. The repo now has three tiers, separated by what infrastructure each needs: (untagged) pure unit — internal/ipam, internal/iptables, internal/agent -tags=integration envtest — a real kube-apiserver and etcd from downloaded binaries, resolved by setup-envtest -tags=e2e a real cluster This is not cosmetic. ci-lint.yml runs a bare `go test ./...` with no envtest assets and no KUBEBUILDER_ASSETS, and the controller-runtime test environment FAILS LOUDLY rather than skipping when it cannot find them — which is the behaviour you want, but it means an untagged envtest suite turns that stage red for a reason that has nothing to do with the change under test. ci-integration.yml is the stage that provides the assets, and it selects its work with `-tags=integration`. The trap in the other direction is worse and silent: a stage that builds with a tag no file carries compiles cleanly, runs ZERO tests, and reports success. That is why `task go:integration` exists alongside `task go:test` rather than the suite simply being dropped from local runs — it resolves the assets itself and runs the tagged suite, so there is still one obvious command that exercises the controller. Both were verified after the split: `task ci` runs the unit tier and reports `internal/controller [no test files]`, and `task go:integration` runs the suite to completion in 77s. Both now run under `-race`, which this repo had never done; no data races surfaced. Note that `build-tags` was added to .golangci.yml in the previous commit for exactly this: golangci-lint honours build constraints, so without it a tagged file is not merely unlinted, it is invisible — the reported debt would have fallen by 16 findings that were hidden rather than fixed. Refs #37, #41 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XcHdnmhuAbyBYG2VfwuPDD --- internal/controller/suite_test.go | 2 ++ internal/controller/wireguard_controller_test.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 9ed6a5b..a411a8a 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -1,3 +1,5 @@ +//go:build integration + /* Copyright 2021. diff --git a/internal/controller/wireguard_controller_test.go b/internal/controller/wireguard_controller_test.go index bb37011..9842775 100644 --- a/internal/controller/wireguard_controller_test.go +++ b/internal/controller/wireguard_controller_test.go @@ -1,3 +1,5 @@ +//go:build integration + package controllers import ( From 3b4b9ed96d0827e399fb4a62c82254db5f0f8eeb Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:06:13 -0700 Subject: [PATCH 3/7] fix: make the hand-written YAML and Dockerfiles pass the new gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config/, bundle/ and release.yaml trees are generated and are exempted in .yamllint.yml. Everything else is hand-written and is fixed here rather than exempted — 341 yamllint findings down to zero errors. examples/*.yaml missing trailing newline (2 files) build-images.yaml trailing whitespace after `jobs:`, and the `steps:` sequence was not indented under its key manual-dev-release-*.yaml doubled space after `repository:`, and `run :` with a space before the colon Two of these turned up real defects rather than style noise: **`docker/setup-buildx-action@v4` was being passed an input it does not have.** actionlint: `input "install" is not defined in action "docker/setup-buildx-action@v4"`. That input existed in earlier majors and was removed; it has been silently ignored ever since the pin moved to v4. Removed. Nothing depended on it — the build goes through docker/build-push-action, which selects the buildx builder itself. **`images/test/Dockerfile` installed setup-envtest at `@latest`.** hadolint DL3062. An unpinned install makes the image's contents depend on the day it was built, and lets the test tool drift away from the controller-runtime the module actually uses. Pinned to v0.18.5, matching go.mod, with a renovate annotation so it tracks that dependency rather than rotting. The reindentation of build-images.yaml is whitespace only; actionlint parses both workflows cleanly afterwards. Both files are replaced wholesale by the workflow PR, but leaving them failing would have meant landing a taskfile whose own gates this repo could not pass — which is the one thing this commit exists to prevent. Refs #37 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XcHdnmhuAbyBYG2VfwuPDD --- .github/workflows/build-images.yaml | 114 +++++++++--------- .../manual-dev-release-workflow.yaml | 4 +- examples/server.yaml | 2 +- examples/serverWithNodePortService.yaml | 2 +- images/test/Dockerfile | 10 +- 5 files changed, 68 insertions(+), 64 deletions(-) diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index 7fd75b2..517b412 100644 --- a/.github/workflows/build-images.yaml +++ b/.github/workflows/build-images.yaml @@ -40,7 +40,7 @@ permissions: contents: read packages: write -jobs: +jobs: build-images: strategy: matrix: @@ -50,68 +50,66 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - repository: ${{ inputs.repository }} - ref: ${{ inputs.ref }} - submodules: true + - name: Checkout repository + uses: actions/checkout@v6 + with: + repository: ${{ inputs.repository }} + ref: ${{ inputs.ref }} + submodules: true - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v4 - with: - install: true + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v4 - - name: Login to GitHub Container Registry - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Generate docker metadata - id: image-meta - uses: docker/metadata-action@v6 - with: - tags: | - type=raw,value=latest, enable=${{ inputs.latest}} - type=raw,value=${{ inputs.tag }} - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/${{ matrix.image }} + - name: Generate docker metadata + id: image-meta + uses: docker/metadata-action@v6 + with: + tags: | + type=raw,value=latest, enable=${{ inputs.latest}} + type=raw,value=${{ inputs.tag }} + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/${{ matrix.image }} - - uses: actions/setup-go@v6 - with: - go-version-file: "go.mod" + - uses: actions/setup-go@v6 + with: + go-version-file: "go.mod" - - name: Build and push docker images - if: ${{ inputs.push == true }} - uses: docker/build-push-action@v7 - with: - context: . - file: images/${{ matrix.image }}/Dockerfile - platforms: ${{ inputs.platforms }} - push: true - tags: ${{ steps.image-meta.outputs.tags }} - labels: ${{ steps.image-meta.outputs.labels }} + - name: Build and push docker images + if: ${{ inputs.push == true }} + uses: docker/build-push-action@v7 + with: + context: . + file: images/${{ matrix.image }}/Dockerfile + platforms: ${{ inputs.platforms }} + push: true + tags: ${{ steps.image-meta.outputs.tags }} + labels: ${{ steps.image-meta.outputs.labels }} - - name: Build and upload docker image to job artifact - if: ${{ inputs.upload_images == true }} - uses: docker/build-push-action@v7 - with: - context: . - file: images/${{ matrix.image }}/Dockerfile - platforms: ${{ inputs.platforms }} - outputs: type=docker,dest=/tmp/${{matrix.image}}.tar - push: false - tags: ${{ steps.image-meta.outputs.tags }} - labels: ${{ steps.image-meta.outputs.labels }} + - name: Build and upload docker image to job artifact + if: ${{ inputs.upload_images == true }} + uses: docker/build-push-action@v7 + with: + context: . + file: images/${{ matrix.image }}/Dockerfile + platforms: ${{ inputs.platforms }} + outputs: type=docker,dest=/tmp/${{matrix.image}}.tar + push: false + tags: ${{ steps.image-meta.outputs.tags }} + labels: ${{ steps.image-meta.outputs.labels }} - - name: Upload artifact - if: ${{ inputs.upload_images == true }} - uses: actions/upload-artifact@v7 - with: - name: image-${{ matrix.image }} - path: /tmp/${{matrix.image}}.tar + - name: Upload artifact + if: ${{ inputs.upload_images == true }} + uses: actions/upload-artifact@v7 + with: + name: image-${{ matrix.image }} + path: /tmp/${{matrix.image}}.tar diff --git a/.github/workflows/manual-dev-release-workflow.yaml b/.github/workflows/manual-dev-release-workflow.yaml index 4b110d0..f705361 100644 --- a/.github/workflows/manual-dev-release-workflow.yaml +++ b/.github/workflows/manual-dev-release-workflow.yaml @@ -38,7 +38,7 @@ jobs: push: true latest: false ref: ${{ inputs.branch }} - repository: ${{ inputs.repository }} + repository: ${{ inputs.repository }} tag: dev-${{ inputs.tag }} save-release: @@ -61,7 +61,7 @@ jobs: env: MANAGER_IMAGE: ghcr.io/${{ inputs.repository }}/manager:dev-${{ inputs.tag }} AGENT_IMAGE: ghcr.io/${{ inputs.repository }}/agent:dev-${{ inputs.tag }} - run : | + run: | make generate-release-file AGENT_IMAGE="$AGENT_IMAGE" MANAGER_IMAGE="$MANAGER_IMAGE" - name: upload release uses: actions/upload-artifact@v7 diff --git a/examples/server.yaml b/examples/server.yaml index 924b2d6..2877cc6 100644 --- a/examples/server.yaml +++ b/examples/server.yaml @@ -3,4 +3,4 @@ kind: Wireguard metadata: name: vpn spec: - mtu: "1380" \ No newline at end of file + mtu: "1380" diff --git a/examples/serverWithNodePortService.yaml b/examples/serverWithNodePortService.yaml index 2f9f29d..1a609c7 100644 --- a/examples/serverWithNodePortService.yaml +++ b/examples/serverWithNodePortService.yaml @@ -4,4 +4,4 @@ metadata: name: vpn spec: mtu: "1380" - serviceType: "NodePort" \ No newline at end of file + serviceType: "NodePort" diff --git a/images/test/Dockerfile b/images/test/Dockerfile index 19eac78..2628c43 100644 --- a/images/test/Dockerfile +++ b/images/test/Dockerfile @@ -2,8 +2,14 @@ FROM golang:1.26 RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git curl bash && rm -rf /var/lib/apt/lists/* -# setup-envtest to fetch apiserver/etcd assets -RUN go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest +# setup-envtest to fetch apiserver/etcd assets. +# PINNED, not @latest: an unpinned install makes this image's contents depend on +# the day it was built, which is the opposite of what a test image is for — and +# it silently drifts away from the controller-runtime the module actually uses. +# Keep in step with the sigs.k8s.io/controller-runtime version in go.mod. +# renovate: datasource=go depName=sigs.k8s.io/controller-runtime +ARG CONTROLLER_RUNTIME_VERSION=v0.18.5 +RUN go install "sigs.k8s.io/controller-runtime/tools/setup-envtest@${CONTROLLER_RUNTIME_VERSION}" WORKDIR /workspace From 31f33e0045851cdd5175e4417807e53d623feec5 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:06:33 -0700 Subject: [PATCH 4/7] deps: bump the transitive dependencies carrying reachable vulnerabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit govulncheck reported four vulnerabilities reachable from WireguardReconciler.Reconcile, via client.subResourceClient.Update and wgtypes.GeneratePrivateKey: GO-2026-6061 google.golang.org/grpc 1.58.3 -> 1.82.1 GO-2026-5970 golang.org/x/text 0.15.0 -> 0.39.0 GO-2026-5026 golang.org/x/net 0.25.0 -> 0.55.0 GO-2026-4918 golang.org/x/net 0.25.0 -> 0.53.0 This is pre-existing rot; the lint gate did not introduce it, it exposed it. **The Kubernetes stack does not move.** All three modules are `// indirect`, pulled in through k8s.io/client-go v0.30.3, sigs.k8s.io/controller-runtime v0.18.5 and k8s.io/apiserver — but minimal version selection permits raising an indirect above what its parent requires, so the fix is three `go get`s and a tidy. `k8s.io/*` and controller-runtime are byte-identical after this commit. Transitively it also raises otel 1.19 -> 1.43, protobuf 1.33 -> 1.36.11, x/crypto, x/oauth2, x/sync, x/sys, x/term and genproto. The separate and larger question — whether to bring client-go and controller-runtime off the 2024-era 0.30/0.18 line — is deliberately left alone. It is not needed to clear this gate and deserves its own change; #40 asks for it to be filed. Verified: go build, go vet, `task ci` and the envtest suite all pass, and `govulncheck ./...` reports zero module vulnerabilities afterwards. **One caveat worth recording.** With the modules fixed, govulncheck's remaining findings come from the Go standard library and depend on the toolchain patch level: on go1.26.4 it reports 7 (crypto/tls, net/http, net/url, encoding/asn1, html/template, fixed in 1.26.5 and 1.26.6); on go1.26.6 it reports zero. CI resolves its toolchain from `go 1.26` in go.mod via setup-go, so it installs the newest 1.26.x and should see none — but that does mean the lint gate is sensitive to the runner image's freshness, and a stdlib CVE published before the image catches up turns lint red with no code change. Pinning a patch version in go.mod would make it deterministic at the cost of manual bumps. Left undecided here on purpose; it is an acceptance criterion on #40. Closes #40 Refs #37 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XcHdnmhuAbyBYG2VfwuPDD --- go.mod | 38 +++++++++++----------- go.sum | 101 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 69 insertions(+), 70 deletions(-) diff --git a/go.mod b/go.mod index fcf3f64..cd4e984 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/onsi/gomega v1.33.1 github.com/prometheus/client_golang v1.16.0 github.com/vishvananda/netlink v1.1.0 - golang.org/x/sys v0.20.0 + golang.org/x/sys v0.45.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 k8s.io/api v0.30.3 k8s.io/apimachinery v0.30.3 @@ -27,7 +27,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect @@ -41,10 +41,10 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/cel-go v0.17.8 // indirect github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -71,31 +71,31 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 // indirect - go.opentelemetry.io/otel v1.19.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 // indirect - go.opentelemetry.io/otel/metric v1.19.0 // indirect - go.opentelemetry.io/otel/sdk v1.19.0 // indirect - go.opentelemetry.io/otel/trace v1.19.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect - golang.org/x/crypto v0.23.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/oauth2 v0.12.0 // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/term v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.3.0 // indirect golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect - google.golang.org/grpc v1.58.3 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 62c07d4..5404be9 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -48,12 +48,11 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE= -github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= @@ -70,8 +69,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -79,8 +78,8 @@ github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQN github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2 h1:SJ+NtwL6QaZ21U+IrK7d0gGgpjGGvd2kz+FzTHVzdqI= github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2/go.mod h1:Tv1PlzqC9t8wNnpPdctvtSUOPUUg4SHeE6vR1Ir2hmg= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -153,8 +152,8 @@ github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdO github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= @@ -170,8 +169,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0= github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= @@ -179,20 +178,24 @@ github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695AP github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 h1:KfYpVmrjI7JuToy5k8XV3nkapjWx48k4E4JOtVstzQI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0/go.mod h1:SeQhzAEccGVZVEy7aH87Nh0km+utSpo1pTv6eMMop48= -go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= -go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I= -go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE= -go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= -go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= -go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= -go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= -go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -204,30 +207,29 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw= golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= -golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -240,15 +242,14 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -256,8 +257,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= -golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -268,24 +269,22 @@ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvY golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 h1:L6iMMGrtzgHsWofoFcihmDEMYeDR9KN/ThbPWGrh++g= -google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5/go.mod h1:oH/ZOT02u4kWEp7oYBGYFFkCdKS/uYR9Z7+0/xuuFp8= -google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e h1:z3vDksarJxsAKM5dmEGv0GHwE2hKJ096wZra71Vs4sw= -google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= -google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= -google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 99d1cf4ea75a42b016aa48d7e515a36f73af2d0e Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:06:57 -0700 Subject: [PATCH 5/7] fix: resolve every gosec finding the new lint baseline reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet .golangci.yml enables gosec, which reported 8 findings. These were deliberately NOT ratcheted away with the rest of the migration debt: a weak digest over key material, or an unbounded read on the endpoint the kubelet probes, is not the same kind of thing as a naming convention. They split three ways. **Real hardening — two listeners had no timeouts at all.** cmd/agent/main.go G112 health server, `&http.Server{Addr:...}` internal/agent/wireguard_metrics.go G114 metrics, `http.ListenAndServe` Neither could set a ReadHeaderTimeout — the second because ListenAndServe offers no way to. A client that opens a connection and dribbles header bytes holds a goroutine indefinitely (Slowloris); enough of them and the health endpoint stops answering, which the kubelet reads as an unhealthy pod and restarts the tunnel. Both now use an explicit http.Server with Read/ReadHeader/Write/Idle timeouts. The values are set independently in each rather than shared: the two endpoints serve different traffic and are free to diverge, so this is shared shape, not shared knowledge. **A genuine upgrade — MD5 to SHA-256.** internal/agent/agent.go G501 (import) and G401 (use) GetDesiredState hashes the state file to detect that the desired state changed. The digest is in-process only: computed at startup, compared against the next read in the watcher loop, never persisted and never compared across restarts — so the algorithm was free to change. Collision resistance is not strictly required for change detection, but this file carries the server private key and every peer's configuration, and shipping a known-broken primitive over that material is not worth the two lines it saves. Driven by tests, which internal/agent had none of. Five were written first; TestGetDesiredStateHashIsSHA256 failed as expected on `hash length = 32, want 64` before the change and passes after. The other four pin the properties that must survive it — the hash is stable across reads, differs when content differs, the state still decodes, and a missing file still errors. **Five where the code is correct and the pattern is what is flagged.** Each carries its reasoning inline rather than a bare suppression: agent.go:140 G304 path is the operator's own --state flag, a fixed mount point in the pod, not attacker-controlled wireguard.go:89 G301 0755 on /dev/net is deliberate — it must stay world-traversable to reach /dev/net/tun; 0750 breaks the userspace tunnel it exists to enable wireguard.go:98 G115 major 10, minor 200 are compile-time constants; Mkdev packs them into 0x0A000200, which fits an int everywhere this builds. Nothing derives from input wireguard.go:113 G204 argv comes from the agent's own --wg-userspace-implementation-fallback and --wg-iface flags, set by the operator in the Deployment it renders. exec.Command takes argv directly, so there is no shell to inject into Verified with the ratchet disabled, so the whole tree is scanned rather than only changed lines: `golangci-lint run --new-from-merge-base="" --enable-only=gosec` reports 0 issues. `task ci` and `task go:integration` both pass. Refs #37 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XcHdnmhuAbyBYG2VfwuPDD --- cmd/agent/main.go | 14 ++++- internal/agent/agent.go | 13 +++- internal/agent/agent_test.go | 98 +++++++++++++++++++++++++++++ internal/agent/wireguard_metrics.go | 16 ++++- internal/wireguard/wireguard.go | 19 +++++- 5 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 internal/agent/agent_test.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index da4dfc7..638a75d 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -9,6 +9,7 @@ import ( "os" "os/signal" "syscall" + "time" "github.com/go-logr/stdr" "github.com/nccloud/wireguard-operator/internal/agent" @@ -148,7 +149,18 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() - srv := &http.Server{Addr: ":8080"} + // Timeouts are not optional here. This listener answers the kubelet's health + // probes, and with no ReadHeaderTimeout a client that opens a connection and + // dribbles header bytes holds a goroutine open indefinitely (Slowloris) — + // enough of them and the probe endpoint stops answering, which the kubelet + // reads as an unhealthy pod and restarts the tunnel. + srv := &http.Server{ + Addr: ":8080", + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } go func() { <-ctx.Done() log.Info("Shutting down agent") diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c5f8fc9..47a2d14 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -1,7 +1,7 @@ package agent import ( - "crypto/md5" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -137,6 +137,10 @@ func OnStateChange(path string, logger logr.Logger, onFileChange func(State)) (f func GetDesiredState(path string) (State, string, error) { var state State + // #nosec G304 -- path is the operator's own --state flag (a fixed mount + // point inside the pod), not attacker-controlled input. The agent must be + // able to read whatever file it was pointed at; constraining it to a literal + // would break the flag it exists to serve. jsonFile, err := os.ReadFile(path) if err != nil { return State{}, "", err @@ -145,7 +149,12 @@ func GetDesiredState(path string) (State, string, error) { if err != nil { return State{}, "", err } - hash := md5.Sum(jsonFile) + // SHA-256 rather than MD5. The digest is only used to detect that the + // desired state changed, so collision resistance is not strictly required — + // but this file carries the server private key and every peer's + // configuration, and shipping a known-broken primitive over that material is + // not worth the two lines it saves. + hash := sha256.Sum256(jsonFile) return state, hex.EncodeToString(hash[:]), nil } diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..2e6c845 --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,98 @@ +package agent + +import ( + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +// The hash GetDesiredState returns is compared against the previous hash to +// decide whether the desired state changed (see Run and the watcher loop). It is +// never persisted and never compared across process restarts, so the digest +// algorithm is free to change — but it must stay a stable, content-addressed +// value, which is what these tests pin. + +func writeState(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "state.json") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("writing fixture: %v", err) + } + return path +} + +const validState = `{"ServerPrivateKey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=","Peers":[]}` + +func TestGetDesiredStateHashIsSHA256(t *testing.T) { + path := writeState(t, validState) + + _, hash, err := GetDesiredState(path) + if err != nil { + t.Fatalf("GetDesiredState: %v", err) + } + + // SHA-256 is 32 bytes, so 64 hex characters. MD5 would be 32 — which is + // what this asserted against before the algorithm was changed, and is the + // reason the change is observable rather than cosmetic. + if len(hash) != 64 { + t.Errorf("hash length = %d, want 64 (sha256); got %q", len(hash), hash) + } + if _, err := hex.DecodeString(hash); err != nil { + t.Errorf("hash %q is not hex: %v", hash, err) + } +} + +func TestGetDesiredStateHashIsStable(t *testing.T) { + path := writeState(t, validState) + + _, first, err := GetDesiredState(path) + if err != nil { + t.Fatalf("first read: %v", err) + } + _, second, err := GetDesiredState(path) + if err != nil { + t.Fatalf("second read: %v", err) + } + + if first != second { + t.Errorf("hash is not stable across reads of identical content: %q != %q", first, second) + } +} + +func TestGetDesiredStateHashChangesWithContent(t *testing.T) { + // Differs from validState only in the private key, so a hash that ignored + // content would still collide here. + const altered = `{"ServerPrivateKey":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=","Peers":[]}` + + _, a, err := GetDesiredState(writeState(t, validState)) + if err != nil { + t.Fatalf("reading first fixture: %v", err) + } + _, b, err := GetDesiredState(writeState(t, altered)) + if err != nil { + t.Fatalf("reading second fixture: %v", err) + } + + if a == b { + t.Errorf("different content produced the same hash %q — change detection would miss the update", a) + } +} + +func TestGetDesiredStateDecodesState(t *testing.T) { + // Guards the fix for the digest change against silently breaking the parse: + // the function returns both, and only the hash is changing. + state, _, err := GetDesiredState(writeState(t, validState)) + if err != nil { + t.Fatalf("GetDesiredState: %v", err) + } + if got := state.ServerPrivateKey; got != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=" { + t.Errorf("ServerPrivateKey = %q, want the fixture's key", got) + } +} + +func TestGetDesiredStateReportsMissingFile(t *testing.T) { + if _, _, err := GetDesiredState(filepath.Join(t.TempDir(), "absent.json")); err == nil { + t.Error("expected an error for a missing state file, got nil") + } +} diff --git a/internal/agent/wireguard_metrics.go b/internal/agent/wireguard_metrics.go index 90a490a..1b3b7a5 100644 --- a/internal/agent/wireguard_metrics.go +++ b/internal/agent/wireguard_metrics.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/go-logr/logr" "github.com/nccloud/wireguard-operator/api/v1alpha1" @@ -130,5 +131,18 @@ func StartMetricsServer(bindAddress string, log logr.Logger) error { } } log.Info("starting metrics endpoint", "addr", addr) - return http.ListenAndServe(addr, mux) + // An explicit http.Server rather than http.ListenAndServe, which offers no + // way to set timeouts at all — leaving this endpoint open to a client that + // holds connections without completing a request. Same reasoning as the + // health listener in cmd/agent; the values are set independently because the + // two endpoints are free to diverge. + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + return srv.ListenAndServe() } diff --git a/internal/wireguard/wireguard.go b/internal/wireguard/wireguard.go index 0cf7aa0..e61694e 100644 --- a/internal/wireguard/wireguard.go +++ b/internal/wireguard/wireguard.go @@ -85,7 +85,11 @@ func syncAddress(iface string, ipWithMask *net.IPNet, family int) error { } func createLinkUsingUserspaceImpl(iface string, wgUserspaceImplementationFallback string) error { - // Ensure /dev/net exists + // Ensure /dev/net exists. + // #nosec G301 -- 0755 is deliberate and not a permissive default. This is + // the conventional mode for /dev/net, and the directory must stay + // world-traversable so a non-root process can reach /dev/net/tun through it. + // Tightening it to 0750 breaks the userspace tunnel it exists to enable. if err := os.MkdirAll("/dev/net", 0o755); err != nil { return err } @@ -95,6 +99,11 @@ func createLinkUsingUserspaceImpl(iface string, wgUserspaceImplementationFallbac if err != nil { if os.IsNotExist(err) { mode := uint32(syscall.S_IFCHR | 0o666) + // #nosec G115 -- both arguments are compile-time constants: major 10, + // minor 200 is the fixed device number for /dev/net/tun. Mkdev packs + // them into 0x0A000200, which fits an int on every platform this + // builds for, so the uint64 -> int conversion cannot overflow. No + // value here is derived from input. dev := int(unix.Mkdev(10, 200)) if err := unix.Mknod("/dev/net/tun", mode, dev); err != nil { return fmt.Errorf("mknod /dev/net/tun failed: %w", err) @@ -109,7 +118,13 @@ func createLinkUsingUserspaceImpl(iface string, wgUserspaceImplementationFallbac } } - // Launch userspace implementation (e.g., wireguard-go) to create the interface + // Launch userspace implementation (e.g., wireguard-go) to create the interface. + // #nosec G204 -- neither argument is user input. The binary name comes from + // the agent's own --wg-userspace-implementation-fallback flag, set by the + // operator in the Deployment it renders, and iface is the --wg-iface flag + // from the same place. A peer cannot influence either. exec.Command takes + // argv directly rather than going through a shell, so there is nothing to + // quote or inject into. cmd := exec.Command(wgUserspaceImplementationFallback, iface) if err := cmd.Start(); err != nil { return fmt.Errorf("starting userspace implementation %q failed: %w", wgUserspaceImplementationFallback, err) From b89f5526614fed6862bbcb04a92d3c37175e7040 Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:21:46 -0700 Subject: [PATCH 6/7] fix: give the linter the history its ratchet needs, and say so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lint job checked out at the default depth of 1, so `origin/main` was not present and `new-from-merge-base: origin/main` had no merge base to diff against. **It failed open.** golangci-lint does not error when it cannot compute the diff — it emits level=warning msg="[runner] Can't process results by diff processor: can't prepare diff by revgrep: could not read git repo: ..." and then reports the FULL tree: 113 pre-existing findings, on a branch whose own changed lines were clean. The gate went red while saying nothing at all about the change under review, and the only signal that the ratchet had stopped working was a warning buried above the findings. Two changes, because the fix and the trap are separate problems: - `pull-request-workflow.yaml` now checks out with `fetch-depth: 0`. The template's own ci-lint.yml already does this for its own reasons, so the workflow PR inherits the correct behaviour — but this legacy file is what gates the repo until that lands. - `.golangci.yml` documents the dependency next to the line that creates it. A ratchet that silently becomes a no-op is worse than no ratchet, because the protection is believed to be there. The comment names the exact warning string and tells the next person to check checkout depth before touching the config. Verified: actionlint and yamllint clean, `task ci` exit 0. Refs #37 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XcHdnmhuAbyBYG2VfwuPDD --- .github/workflows/pull-request-workflow.yaml | 9 +++++++++ .golangci.yml | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/pull-request-workflow.yaml b/.github/workflows/pull-request-workflow.yaml index 0c71310..d1890e4 100644 --- a/.github/workflows/pull-request-workflow.yaml +++ b/.github/workflows/pull-request-workflow.yaml @@ -19,6 +19,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + # REQUIRED BY THE RATCHET, and its absence fails OPEN rather than loud. + # .golangci.yml sets `new-from-merge-base: origin/main`, which needs the + # merge base to exist locally. At the default depth of 1 it does not, + # and golangci-lint downgrades that to a warning — + # "Can't process results by diff processor: ... could not read git repo" + # — then reports the FULL tree: 113 pre-existing findings, and a red + # gate that says nothing about the change under review. + fetch-depth: 0 - uses: actions/setup-go@v6 with: diff --git a/.golangci.yml b/.golangci.yml index 8c47edd..455cce1 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -94,6 +94,18 @@ issues: # The 8 gosec findings were NOT ratcheted away: they are fixed in the same # commit that adds this file, because a leaked key or an unbounded read in a # VPN data plane is not lint debt. See the commit body for each one. + # + # THIS LINE REQUIRES `fetch-depth: 0` IN EVERY WORKFLOW THAT RUNS THE LINTER, + # AND IT FAILS OPEN. golangci-lint needs the merge base present locally to + # compute the diff; at the default checkout depth of 1 it is not, and rather + # than erroring, golangci-lint emits + # "Can't process results by diff processor: ... could not read git repo" + # as a WARNING and then reports the entire tree. Observed on this repo: 113 + # findings and a red gate that said nothing about the change under review. + # + # A ratchet that silently becomes a no-op is worse than no ratchet, because + # the protection is believed to be there. If you see the full debt reported on + # a small diff, check the checkout depth before touching this file. new-from-merge-base: origin/main formatters: From 7a638d7e87aa9c2f622fc768bdacf8d5baa3da0e Mon Sep 17 00:00:00 2001 From: jacaudi <47005674+jacaudi@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:25:46 -0700 Subject: [PATCH 7/7] fix: keep the envtest suite running in the Makefile test targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tagging internal/controller with `//go:build integration` in 0b5252d silently removed it from `make test` and `make test-ci`, which run `go test ./...` with no tags. Both targets already export KUBEBUILDER_ASSETS, so they HAD been running the envtest suite; afterwards they ran strictly less and still reported success. This is the exact failure mode the commit that introduced the tag warned about, walked into from the other side: a tag mismatch does not error, it just quietly tests nothing. Demonstrated rather than assumed: $ go test -list '.*' ./internal/controller/ ? internal/controller [no test files] $ go test -list '.*' ./internal/controller/ -tags=integration TestAPIs ok internal/controller 0.410s The legacy pull-request-workflow.yaml calls `make test-ci`, so without this the envtest suite would have run nowhere in CI between this PR merging and the workflow PR landing ci-integration.yml. Restores exactly the coverage those targets had before the split — untagged unit tests plus the tagged suite, in one run — and both targets now carry a comment saying why the flag is load-bearing. Refs #37 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XcHdnmhuAbyBYG2VfwuPDD --- Makefile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 77a0999..bd121a5 100644 --- a/Makefile +++ b/Makefile @@ -109,10 +109,15 @@ vet: ## Run go vet against code. go vet ./... test: manifests generate fmt vet envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" go test ./... -coverprofile cover.out +# -tags=integration is REQUIRED, not optional. internal/controller carries +# //go:build integration, so without it `go test ./...` silently skips the +# entire envtest suite and still reports success. + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" go test ./... -tags=integration -coverprofile cover.out test-ci: manifests generate fmt vet envtest ## Run tests with JUnit output for CI. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" go test -json ./... -coverprofile cover.out > test-report.json +# See the note on `test` above: -tags=integration or the envtest suite does +# not run and CI goes green having tested strictly less. + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" go test -json ./... -tags=integration -coverprofile cover.out > test-report.json TEST_RUNNER_IMAGE ?= wireguard-operator-test:local