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 feab4b1..0000000 Binary files a/.github/workflows/.DS_Store and /dev/null differ 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/.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/.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..455cce1 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,10 +1,120 @@ +# 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. + # + # 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: + 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/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 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/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/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= 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 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/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 ( 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) 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)"