Skip to content

feat(dev): reusable kind dev stack — make dev-kind, with OIDC and gateway routing - #69

Merged
procoduck merged 22 commits into
mainfrom
feat/kind-dev-stack
Sep 15, 2026
Merged

procoduck merged 22 commits into
mainfrom
feat/kind-dev-stack

Conversation

@procoduck

Copy link
Copy Markdown
Owner

The Kubernetes flavour of make dev: a single-node kind cluster shepherd-dev running the real chart with Calico, CloudNativePG, Gateway API + NGINX Gateway Fabric, the existing dev seed, three live Alloy agents from dev/*.alloy, Gitea on a PVC, and the navikt mock OIDC provider — everything on host port 80 under *.localtest.me, with one CoreDNS rewrite so pods and the browser agree on the issuer string. make dev-kind / dev-kind-reload / dev-kind-seed / dev-kind-status / dev-kind-down.

Plan with the four signed decisions and the design review's corrections: docs/plans/2026-09-14-kind-dev-stack.md. Section 11 of docs/kind-test-environment-plan.md documents the stack.

Built as one Fable plan, seven Sonnet slices in parallel worktrees with disjoint file sets, each Opus-reviewed with a fix loop; then integrated and proven live three times from a deleted cluster.

What the live bring-ups found, all fixed here with red runs

  • route.enabled never resolved through NGF: the Service hardcoded appProtocol: kubernetes.io/h2c, which NGF refuses (UnsupportedProtocol, 500 on every request). New chart value service.appProtocol, default unchanged; the dev values clear it.
  • A chart-declared private OIDC issuer passed discovery but failed at the key fetch: the go-oidc Provider was built against the guarded client regardless of source. The provider and the code exchange now follow the issuer's source; admin-supplied issuers stay guarded at every hop (internal/auth).
  • Alloy's remotecfg exits on a failed initial load, so the agents must be applied after the chart and after the seed that mints their token.

Live proof (third cold run, all pods 0 restarts): plan §5 steps 1–13 including Gateway Programmed, NodePort 30080 at the consumed layer, identical issuer strings from host and pod, seed idempotent, reload rolling through the migrate hook, down leaving nothing. OIDC walk via Playwright, 23/23: PKCE+nonce+state, app admin from the groups claim, org admin scoped without the Admin menu, reader as viewer, no-groups user on an empty overview, SSO page read-only with the chart banner. Sandbox runs capture under Calico (21 series; a 30 s run against a 30 s scrape interval can land on 0 — noted in the docs).

Pins: KIND_NODE_IMAGE, CALICO_VERSION, NGF_CHART_VERSION move to deploy/versions.env, read by the suite and the script; kindest/node is Renovate-excluded. This PR therefore triggers e2e-k8s, which is the proof the suite still installs the same stack.

Static gate: make lint 0 issues; repocheck (5 new spec files) green; go vet -tags e2ek8s; make helm-lint now renders the dev values too; docs guards green. internal/spa/dist untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw

Andre Staats and others added 22 commits September 14, 2026 23:38
Seven slices with disjoint file sets for the Kubernetes flavour of
`make dev`: script and cluster config, pins in versions.env, dev values,
Alloy and Gitea workloads, gateway and mock OIDC, Makefile targets, docs.
Constraints are the four decisions signed today (Calico, versions.env
pins Renovate-excluded, Gateway API + NGF, values-declared OIDC) and the
design review's corrections, each re-verified against the code. No slice
runs a kind cluster; the live bring-up and the OIDC walk are the
orchestrator's, in section 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
Adds dev-kind, dev-kind-reload, dev-kind-seed, dev-kind-status and
dev-kind-down after the existing dev-* block, per
docs/plans/2026-09-14-kind-dev-stack.md §K6. dev-kind depends on
preflight-k8s, docker-build-local and docker-build-simulator so a cold
run always builds both local images from this tree before bringing the
cluster up (matching the e2e-k8s target's prerequisite pattern);
dev-kind-reload depends only on docker-build-local since reload only
rebuilds the app image. All five delegate to scripts/dev-kind.sh's
verbs (up/reload/seed/status/down) — that script, its manifests and
its pins are other slices' files and are referenced here only by the
names §1 of the plan fixes, not created.

Red run first (scripts/repocheck/devkind_makefile_test.go): before the
targets existed, mkTargetLine("dev-kind") failed with
`target "dev-kind" not found in Makefile` and 6 more specs failed on
missing .PHONY membership, help comments, dependencies and recipe
verbs (go test ./scripts/repocheck/ -count=1 -run TestRepocheck: 53
passed, 7 failed). All seven are green after the Makefile change, and
the full repocheck suite, make help and make -n dev-kind-down match
the plan's acceptance checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
Slice K2 of docs/plans/2026-09-14-kind-dev-stack.md. KIND_NODE_IMAGE,
CALICO_VERSION and NGF_CHART_VERSION move from Go literals/constants in
e2e/k8s into deploy/versions.env, each commented, so scripts/dev-kind.sh
(a later slice) can read the exact pins the e2e suite installs instead
of copying or grepping them out of route_conformance_test.go.

- main_test.go: kindNodeImage() keeps E2E_K8S_NODE_IMAGE first, then
  reads KIND_NODE_IMAGE via readVersionsEnvValue (log.Fatalf on error —
  this runs in TestMain before any cluster exists); installCNI reads
  CALICO_VERSION the same way. The calicoManifest const is gone.
- route_conformance_test.go: the ngfChartVersion const is gone;
  installGatewayController reads NGF_CHART_VERSION. The NGF-selection
  rationale comment stays, with its kindest/node:v1.31.4 mention
  repointed at "the KIND_NODE_IMAGE pin" and a new sentence explaining
  where the version itself now lives and why.
- renovate.json: a packageRules entry disables kindest/node entirely
  (no matchUpdateTypes, so a digest bump is excluded too) — Renovate's
  custom.regex manager would otherwise treat KIND_NODE_IMAGE as an
  ordinary image:tag pin and propose digest/bump PRs for a kind node
  image, which is a reviewed decision (kind/helm-kind-action support,
  above NGF 2.6.0's and CNPG 0.29.0's kubeVersion floors), not routine.
- scripts/repocheck/kind_pins_test.go (new): red-first spec covering
  all four files.

Red run (scripts/repocheck/kind_pins_test.go, before main_test.go/
route_conformance_test.go/renovate.json were edited):

  [FAILED] e2e/k8s/main_test.go reads KIND_NODE_IMAGE and CALICO_VERSION ...
    Expected <string>: ...kindNodeImage()... not to contain substring
        <string>: kindest/node:
  [FAILED] e2e/k8s/route_conformance_test.go reads NGF_CHART_VERSION ...
    Expected <string>: ...not to contain substring
        <string>: ngfChartVersion = "
  [FAILED] renovate.json disables updates to kindest/node entirely
    Expected <bool>: false to be true
  Ran 57 of 57 Specs -- 54 Passed | 3 Failed

(deploy/versions.env's own lines were written first in this slice, so
that assertion was already green on the first run; the three above are
the literals-and-config red run this slice exists to close.)

Verified: go test ./scripts/repocheck/ -count=1 green (all 57 specs,
including the existing renovate.json spec); go vet -tags e2ek8s ./e2e/k8s/
clean; make lint 0 issues; make guards green (all ten, including
check-docker/check-gateway-pin/check-chartvalues-pin); go build ./...
clean; grep of the three new keys in deploy/versions.env shows bare
alphanumeric/.:/- values, and `make lint`/`make guards` parsing the
Makefile's `include deploy/versions.env` succeeded, confirming the
unquoted values stay shell- and make-safe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
Slice K7 of the kind dev stack plan (docs/plans/2026-09-14-kind-dev-stack.md).
Adds kind-test-environment-plan.md §11, covering what make dev-kind shares
with the e2e/k8s suite (Calico, NGF, CNPG, the deploy/versions.env pins) and
what it deliberately does not (single node, one namespace, no teardown on
failure), the one-CoreDNS-rewrite routing/DNS design and why a single rule is
correct rather than a shortcut, the OIDC walk together with the dialGuard
boundary that makes the SSO settings page read-only against a chart-declared
issuer, the reload build-annotation mechanics, where the manifests live and
why, and the agents-do-not-run-kind rule. Updates the status header's pin
sentence and §9 item 1 to reflect that CALICO_VERSION is now a
deploy/versions.env variable rather than a Go constant.

Also updates dev-guide.md (a "Kubernetes flavour" section after Mode B, five
new Dev stack target-table rows, and a fix to the stale sentence implying
OIDC is compose-only), README.md's command table, e2e/k8s/README.md's setup
list and Known limits to point at the versions.env pin locations, AGENTS.md's
Commands line and image table (kindest/node, projectcalico/calico, and a note
that dev/kind/*.yaml restates the Gitea and mock-oauth2 pins), the
project-status.md document-map row, and one CHANGELOG.md Unreleased line.

This slice has no runtime control of its own; the checks are the existing
guards. Red run before this commit (all pass unmodified, confirming the
guards do not need this slice's changes to stay green — the point is that
they still pass *with* the new docs content and cross-references):

  make check-docs-drift        -> check-docs-drift: OK (21 pages match)
  make check-docs-version      -> check-docs-version: OK
  go test ./scripts/repocheck/ -count=1 -> ok  shepherd/scripts/repocheck

Post-change: `make guards` all ten checks green; `go test
./scripts/repocheck/ -count=1` green; every `make dev-kind*` command quoted
in the docs (dev-guide.md, README.md, kind-test-environment-plan.md,
AGENTS.md, CHANGELOG.md, e2e/k8s/README.md, project-status.md) is one of the
five real Makefile targets (dev-kind, -reload, -seed, -status, -down); `git
status --porcelain site/` is empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
Slice K4 ("workloads") of the reusable kind dev stack plan
(docs/plans/2026-09-14-kind-dev-stack.md). Adds the plain manifests the
forthcoming scripts/dev-kind.sh applies:

- dev/kind/alloy.yaml: three Deployments (alloy-metrics, alloy-logs,
  alloy-staging), one per dev/alloy-*.alloy fixture, each mounting a
  same-named ConfigMap read-only at /etc/alloy and running compose's exact
  args. The image is the literal placeholder __ALLOY_IMAGE__, substituted
  by the script from ALLOY_IMAGE (deploy/versions.env) with sed at apply
  time, so the pin is never restated outside Renovate's files (plan §1).

- dev/kind/gitea.yaml: a 1Gi RWO PVC (C5 — the dev seed is check-then-skip
  on the gitea-demo credential row, so an emptyDir would leave it pointing
  at a repo that no longer exists after any restart), a Deployment with
  Recreate strategy and compose's env/image/ports verbatim, and a Service
  exposing 3000/2222. Admin bootstrap stays the script's job (kubectl exec),
  matching compose's gitea-init one-shot.

- scripts/repocheck/devkind_workloads_test.go: red-run first (see below),
  then a spec parsing both manifests (yaml.v3, multi-document) and
  dev/docker-compose.dev.yaml, checking the shapes the plan pins down —
  Deployment count and naming, the placeholder image and absence of any
  real grafana/alloy reference, ConfigMap mount identity, and the Gitea
  PVC/Deployment/Service shape with env and image equal to compose's
  key-for-key.

Red run (scripts/repocheck/devkind_workloads_test.go against the tree
before dev/kind/ existed):

  open .../dev/kind/alloy.yaml: no such file or directory

(same shape for dev/kind/gitea.yaml) — 13 of 66 specs failed, all in this
new file; every other repocheck spec was unaffected. After adding the
manifests, `go test ./scripts/repocheck/ -count=1` is green (66/66).

Verified statically only, per the plan's rule that no slice creates, uses
or deletes a kind cluster: go vet and golangci-lint (--allow-parallel-runners,
0 issues — make lint's own golangci-lint invocation collided with another
agent's concurrent run on this shared laptop, an expected condition per the
plan) are clean on the new file, and `make lint`'s guards target (docs,
docker, gateway/chart pins, dist) passed. A `kubectl create --dry-run=client
--validate=false` check on the sed-substituted manifests could not be run
here: kubectl's client-side dry-run still needs a reachable API server for
discovery/RESTMapping even with --validate=false, and no cluster context is
available or permitted in this isolated worktree — left for the
orchestrator's live gate (plan §4/§5), where a real kube context exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
K1 slice of docs/plans/2026-09-14-kind-dev-stack.md: the entry point for
the reusable kind dev stack (`make dev-kind`, wired up by a later slice).

scripts/dev-kind.sh implements up/reload/seed/status/down exactly as plan
section K1 specifies: it sources deploy/versions.env and fails loudly if
KIND_NODE_IMAGE, CALICO_VERSION, NGF_CHART_VERSION, CNPG_CHART_VERSION,
GATEWAY_API_VERSION, GATEWAY_API_CHANNEL or ALLOY_IMAGE is missing (the
first three land in the pins slice, K2); every kubectl/helm call goes
through the kc()/hm() wrappers so the kind-shepherd-dev context is always
explicit, never ambient. `up` creates the cluster from dev/kind/cluster.yaml,
installs Calico, waits for nodes+CoreDNS, loads both local images, installs
the Gateway API CRDs and NGF pinned to NodePort 30080/listener 80, installs
CNPG, applies the Gateway and verifies — at the consumed layer, not just the
declared values — that the provisioned data-plane Service actually got
nodePort 30080, inserts the CoreDNS *.localtest.me rewrite idempotently,
creates the Secret and three Alloy ConfigMaps, applies the workload
manifests other slices own (gateway/gitea/oidc/routes/alloy, the last
rendered through the __ALLOY_IMAGE__ placeholder), bootstraps the Gitea
admin tolerating "already exists", then runs the real Helm chart with a
fresh podAnnotations.dev-build so Helm rolls the pods after the migrate
hook instead of racing it (plan C3). `reload` reuses the same install path
with a new build id; `down` is `kind delete cluster --name`.

dev/kind/cluster.yaml is the one-node counterpart to the e2e suite's
three-node testdata/kind-cluster.yaml: disableDefaultCNI, the same
10.244.0.0/16 pod subnet (documented why, condensed from that file), and
the containerPort 30080 -> hostPort 80 mapping that makes every dev-stack
hostname a plain http://*.localtest.me URL.

Red run (scripts/repocheck/devkind_script_test.go, before either file
existed): every one of the 10 new specs failed identically —
`open scripts/dev-kind.sh: no such file or directory` — via readRepoFile,
os.Stat and loadYAML. After adding the two files, `go test
./scripts/repocheck/ -count=1` is green (63 specs), `bash -n
scripts/dev-kind.sh` and `shellcheck scripts/dev-kind.sh` are clean, and
`make lint` (guards + golangci-lint) reports 0 issues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
dev/kind/values.yaml is the Helm values file scripts/dev-kind.sh (a
different slice) will install the `shepherd` release with, on top of
deploy/helm/shepherd's own defaults. It uses only keys
values.schema.json already knows about, so no chart change ships
alongside it:

- image / simulator.image: registry "", tag local, pullPolicy Never —
  the pods can only start from what `kind load docker-image` put on
  the node.
- existingSecret: shepherd-dev-env, the Secret another slice creates
  from dev/shepherd.dev.env plus the OIDC client secret (plan §1) —
  zero copies of the compose env file.
- cnpg.enabled with 1 instance / 1Gi storage: the chart provisions
  Postgres instead of pointing at one that already exists.
- route.enabled for shepherd.localtest.me, attached to the `dev`
  Gateway another slice declares in the same namespace.
- config.server.base_url and config.oidc.*: the mock-oauth2-server
  issuer (http://oidc.localtest.me/default), provider generic (sub
  subject claim, no Graph lookup — mock-oauth2 has no Graph API), and
  the redirect URL. The client secret rides in existingSecret, never
  here.
- simulator stays enabled (the chart's own default since v0.0.1) so
  its default-deny NetworkPolicy is exercised for real once Calico is
  the CNI (plan D1).

scripts/repocheck/devkind_values_test.go is the red-run-first guard:
it shells out to the real `helm template` (not a hand-parsed
approximation) with this file layered on deploy/helm/shepherd and
asserts the rendered Deployment/shepherd, Deployment/shepherd-simulator,
HTTPRoute/shepherd, Cluster/shepherd-db and ConfigMap/shepherd shapes
the plan's K3 section lists, that no Secret/shepherd-secrets renders
(existingSecret must be the only Secret in play), and that
`helm lint --strict` exits 0.

Red run, 2026-09-14 (dev/kind/values.yaml temporarily removed to
capture it): every assertion failed via
`helm template shepherd deploy/helm/shepherd -f dev/kind/values.yaml
--namespace shepherd-dev` returning:

  Error: open dev/kind/values.yaml: no such file or directory

All seven specs are green with the file restored; `helm lint --strict`,
`make helm-lint`, `go vet ./scripts/repocheck/...`, and `make lint`
(guards + golangci-lint) are all clean.

templates/NOTES.txt printing https for an http route hostname is a
known, separately-tracked cosmetic issue (plan §6) — not touched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
…PRoutes

Slice K5 ("gateway-oidc") of docs/plans/2026-09-14-kind-dev-stack.md: the
three manifests scripts/dev-kind.sh applies after NGF and CoreDNS are in
place.

- dev/kind/gateway.yaml: the Gateway `dev` (gatewayClassName nginx, one
  HTTP:80 listener). The header explains NGF's per-Gateway data-plane
  Service, why the script pins its NodePort to 30080 via the NGF chart
  values, and that the single CoreDNS rewrite targets that Service so a
  pod and the host browser resolve every *.localtest.me hostname
  identically (D3).
- dev/kind/oidc.yaml: Deployment/Service `oidc` running navikt
  mock-oauth2-server, with SERVER_PORT and JSON_CONFIG copied verbatim
  from dev/docker-compose.dev.yaml's services.oidc (D2, C7) and a
  readiness probe against /default/.well-known/openid-configuration --
  the same issuer path dev/kind/values.yaml declares. The image tag stays
  pinned here rather than deploy/versions.env, matching the decision that
  dev-only fixture pins should not bill e2e-k8s. No runAsNonRoot is set:
  the image's default user isn't verified, and guessing wrong would only
  fail live, where nothing here could catch it -- security context stays
  hygiene, not a gate (C9).
- dev/kind/routes.yaml: the two hand-written HTTPRoutes, oidc.localtest.me
  -> oidc:8090 and gitea.localtest.me -> gitea:3000, both parented to
  `dev` with a PathPrefix "/" match. The shepherd route is deliberately
  absent -- it's the chart's own (K3).
- scripts/repocheck/devkind_gateway_test.go: red-first Ginkgo spec parsing
  the three manifests with yaml.v3 (no cluster needed, per the plan's
  guidance to avoid kubectl-dependent checks in repocheck) and asserting
  the Gateway shape, both routes' hostnames/backends/parentRefs, the oidc
  image against compose's, JSON_CONFIG parsing with interactiveLogin
  true, the oidc Service port, and that no shepherd.localtest.me hostname
  appears in this slice's files.

Red run, 2026-09-14: with the three manifests moved aside, every assertion
failed with `open <repo>/dev/kind/gateway.yaml: no such file or
directory` (and the equivalent for oidc.yaml / routes.yaml), matching the
plan's stated red run. `go test ./scripts/repocheck/ -count=1` is green
with the files restored; `golangci-lint run ./scripts/repocheck/...`
reports 0 issues.

`kubectl create --dry-run=client --validate=false` on these three files
could not be verified here: in every configuration tried (no context, and
stale kind contexts with a cached but expired discovery cache) kubectl's
RESTMapper needs a live, reachable API server to resolve GVK->GVR, even
for built-in Deployment/Service kinds and even with --validate=false --
contradicting the plan's "no cluster" expectation for that check. This
repo has no live kind cluster in this session and agents are not to
create one; deferred to the orchestrator's live gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
Two review findings against docs/kind-test-environment-plan.md §11:

- The OIDC walk example had the wrong first barrier. Handler.TestSettings
  calls validateIssuer before fetchDiscovery, so pasting
  http://oidc.localtest.me/default into Test connection is refused by the
  https-only rule (settings.go), never by dialGuard. Reworded the example
  to two steps: the https rule refuses the http input, and only an https
  input reaches dialGuard, which then refuses because the mock OIDC
  provider resolves to the NGF ClusterIP. Added a pointer telling the
  orchestrator to correct plan §5 step 10 the same way before the live
  walk (that file is out of this slice's scope).

- The DNS-rewrite bullet misdescribed the mechanism and contradicted the
  paragraph right after it: the browser never touches CoreDNS, so "both
  paths cross the same rewrite" was false, and the per-hostname-drift
  argument built on it doesn't hold. Rewrote the bullet: the rewrite lets
  in-cluster clients resolve *.localtest.me at all (the public wildcard's
  127.0.0.1 answer is a pod's own loopback), and pod/browser issuer
  strings agree because both reach the same Gateway with the same Host
  header, which mock-oauth2-server derives the issuer from. Replaced the
  readiness-probe citation (oidc.yaml doesn't exist yet) with a reference
  to §5 step 7's live curl, the only step that actually proves the
  pod-seen string.

Also folded in three cheap non-blocking fixes: dropped the incorrect
checksum/config claim and tightened the reload section to Helm's
rendered-manifest comparison; noted dev-kind-reload kind-loads both
shepherd:local and shepherd-simulator:local, not just the app image; and
qualified "only one such cluster" to "only one shepherd-dev cluster" so
it doesn't read as a kind limitation.

Verified: make check-docs-drift (21 pages), make check-docs-version,
go test ./scripts/repocheck/, and make guards (10/10) all green. Diff
touches only docs/kind-test-environment-plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
…stop mangling the CoreDNS rewrite regex

Two blocking review findings on the K1 script slice:

1. apply_secret_and_configmaps combined --from-env-file with
   --from-literal in one `kubectl create secret` call. kubectl v1.36.4
   rejects that deterministically ("from-env-file cannot be combined
   with from-file or from-literal"), so every `up` would die at this
   step after Calico/NGF/CNPG/Gateway were already installed.
   Reproduced offline (client-side dry-run, no cluster). Plan C2's
   prescribed command has the same invalid combination and is wrong
   for the same reason — this fix does not touch the plan doc (out of
   this slice's file scope), only the script that actually ships.
   Fixed by appending the OIDC client secret to the env-file's lines
   via process substitution, so there is only ever one env source.
   Verified with --dry-run=client: the rendered Secret carries all
   7 dev/shepherd.dev.env keys plus SHEPHERD_OIDC_CLIENT_SECRET.

2. apply_coredns_rewrite had two bugs that canceled each other out:
   `awk -v line="$rewrite_line"` unescapes backslashes in its value
   (verified on macOS awk; gawk does the same and warns), so the
   Corefile line actually written was the any-char regex
   `(.*).localtest.me ...` rather than the §1 contract line
   `(.*)\.localtest\.me ...`. The idempotency guard
   (`grep -q 'localtest\.me'`) searches for the literal dotted text,
   which the *correct*, backslash-escaped line does not contain — the
   guard only ever passed because bug (1) was stripping the
   backslashes it depends on. Fixing the awk bug alone would have made
   every re-run of `up` append a duplicate rewrite line (reproduced in
   a stubbed harness). Fixed by passing the line through ENVIRON
   instead of -v (single-quoted in bash so backslashes survive), and
   matching the guard on the rule text itself
   (`rewrite name regex .*localtest`) rather than the dotted hostname
   substring. Verified offline across two simulated runs: exactly the
   §1 line, and no duplicate on the second run.

New repocheck specs guard both regressions (red-first: 3 new specs
fail against the pre-fix script — 63 passed/3 failed — then pass
after the fix, 66/66). Also tightened bareKubectlHelmRE to catch a
bare kubectl/helm call after a pipe, `;`, `&`, or `$(...)`/`(...)`,
not just one that starts its own line (non-blocking review remark).

Cheap non-blocking fixes folded in: cmd_status's curl checks now
print "unreachable" on failure instead of exiting silently (bare `&&
echo` was the function's last command, so a down service made
`status` exit 1 with no output), and apply_coredns_rewrite now waits
for `rollout status deploy/coredns` after the restart so CoreDNS is
actually serving before oidc/gitea/shepherd start resolving names
against it.

dev/kind/cluster.yaml needed no change — the review verified it
against the K1 spec (single node, disableDefaultCNI, 30080->80
mapping) and raised no finding there.

Verification: bash -n, shellcheck 0.11.0, gofmt, go vet, golangci-lint
(0 issues), make guards — all clean. go test ./scripts/repocheck/...
66/66 green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
The label-selected Service can briefly lag Gateway condition=Programmed=True
- e2e/k8s/route_conformance_test.go's waitProvisionedServiceName documents
this exact race and retries every 2s for the same reason. find_ngf_service
was a one-shot check run immediately after `kc wait --for=condition=Programmed
gateway/dev`, so a cold `up` could die non-deterministically at this step
right after Calico/NGF/CNPG were already installed - the same failure shape
as the round-1 blocking findings.

find_ngf_service now polls the same label-selected query for up to ~2m,
sleeping 2s between attempts, and only emits the existing "expected exactly
one Service ... found N" error and exits 1 once the deadline passes with the
count still not exactly one.

Red-first: added a repocheck spec asserting find_ngf_service's body matches
\bsleep\b, confirmed it fails against the pre-fix one-shot function (66
Passed | 1 Failed, with the exact "must retry (sleep) instead of checking
the Service count once" message), then made the fix and reran to 67/67.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
The dev stack's values file is the one combination a developer actually
installs, and no ci/ file covers it; the plan's cross-slice seam (§3)
adds this render at integration so a chart change that breaks the dev
stack fails helm-lint, not the next make dev-kind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
The chart's own HTTPRoute never resolved through NGINX Gateway Fabric: the
Service hardcodes appProtocol kubernetes.io/h2c and NGF refuses to proxy an
HTTP route to an h2c upstream ("UnsupportedProtocol"), so every request to
shepherd.localtest.me answered 500 from nginx. The conformance suite routes
to the receiver tier, not to Shepherd's Service, which is why nothing had
caught it. appProtocol is now a value: the h2c default is unchanged for
gateways that honour the hint, and the dev values clear it (Shepherd serves
HTTP/1.1 equally well). The chart spec was red on "values don't meet the
schema" and green after; the generated values reference is regenerated.

The three Alloy agents were applied before the chart, resolved the
`shepherd` Service on their initial load, exited, and crash-looped through
five restarts each until the backoff happened to line up with the Service
appearing. Alloy's remotecfg exits on a failed initial load, so the agents
now go on after install_shepherd, with a wait on their Deployments. The
ordering spec was red against the previous cmd_up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
The first OIDC login on the kind dev stack reached the mock provider on its
Service address, exchanged the code, and then failed with "fetching keys:
address is not a public internet address". Discovery already used the
per-source client (a chart-declared issuer may be private; an admin-supplied
one is guarded), but the go-oidc Provider built from that document — and so
every JWKS fetch for the rest of its life — used the guarded client
regardless of source. The provider now takes the same client that fetched
the document, the runtime keeps it, and the code exchange goes through it
too, so the token endpoint the document named is guarded or not by the
same rule as the rest.

The new test builds a provider from a loopback issuer both ways: for a
chart-declared source the key fetch reaches the server (verification then
fails on the empty JWKS, which is the point), for an admin-supplied source it
is still refused at the key fetch. It failed to compile against the old
two-argument constructor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
Section 11 gains the three findings the cluster showed that no static
check could (the chart route's h2c hint, the chart-declared issuer blocked
at its key fetch, the agents applied before the chart), each now fixed on
this branch, plus the sandbox-window-versus-scrape-interval observation
for the ledger. The changelog gets the two product fixes as user-facing
entries beside the dev-tooling line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
…the chart

The second cold bring-up still crash-looped all three agents, now on
"unauthenticated": they started after the chart, but before the seed that
creates the static agent token their configs authenticate with, and
Alloy's remotecfg exits on a failed initial load. cmd_up now runs the seed
before the agents; the ordering spec asserts both orderings and was red
against the previous sequence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw
@procoduck
procoduck merged commit 0a1a34b into main Sep 15, 2026
23 checks passed
@procoduck
procoduck deleted the feat/kind-dev-stack branch September 15, 2026 08:26
procoduck added a commit that referenced this pull request Sep 15, 2026
… bundle (#73)

* chore(release): prepare v0.7.0 — chart 0.11.0, changelog, rebuilt SPA bundle

The chart moves a minor because a template changed: the Service's
appProtocol became the service.appProtocol value (#69). Its default
renders exactly what 0.10.2 did, so there is no UPGRADING.md section.
The changelog closes Unreleased as v0.7.0 with an upgrade preamble; the
docs, README, landing page and bug template quote the new pair, and the
Kubernetes page no longer says Shepherd is 0.3.x. The embedded bundle is
rebuilt from main at f82957e so the release binary carries the
walkthrough fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw

* chore(release): fold the two header fixes into the v0.7.0 preamble

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw

---------

Co-authored-by: Andre Staats <staats.andre@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
procoduck added a commit that referenced this pull request Sep 15, 2026
…lans (#76)

The verified-baseline table now cites the runs on the v0.7.0 merge commit
(CI 34973037472 on PR #73, e2e-k8s 34973037471, compose e2e 34971974892,
web/test-ui from PR #69's run 34945312703, release 34974782542 with
scan-published green for both images). The pending chart-bump follow-up is
closed by chart 0.11.0.

docs/plans/2026-09-14-walkthrough-fixes.md and 2026-09-14-kind-dev-stack.md
move to docs/archive/plans/ per the policy in docs/archive/README.md, each
with a closing note that names what shipped (PR #67 / #69, v0.7.0) and what
the ledger still carries. Every citation — changelog, ledger, the kind
suite plan, and the Go test comments in repocheck, mgmtapi and the
self-monitoring wizard — now points at the archived path. docs/plans/ is
empty between releases; the three doc maps say so.


Claude-Session: https://claude.ai/code/session_01BPMsuQQBnedAsJ8tJMH3rw

Co-authored-by: Andre Staats <staats.andre@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant