Create oz and preview service #58

Merged
tepichord merged 25 commits from feat/56-oz-preview-service into main 2026-09-15 13:05:48 -07:00
Owner

Closes #56

Automated PR created by forgejo-done adapter.

Closes #56 Automated PR created by forgejo-done adapter.
preview.faun-snapper.ts.net becomes a permanent hostname that serves
whichever preview carries milner.dev/preview-active, and
oz.faun-snapper.ts.net an OIDC-gated console to select, rebuild and
delete them. Both Ingresses use the shared ingress-proxies ProxyGroup, so
no new tailnet device is created.

Target resolution reads the API, not a mounted file: a projected
ConfigMap lags by the kubelet sync period plus cache TTL, which would
make switching slower than doing nothing. State is a label on the
selected namespace rather than a ConfigMap, so it cannot outlive its
target -- a reaped preview clears the selection for free.

preview-console holds namespaces get/list/watch/patch and services
get/list/watch. Patch, not delete: the console's Delete button moves
milner.dev/preview-expires to now and the reaper collects the preview, so
the console can expire but never destroy, and never reach vault or
flux-system.

preview-config carries the single TTL literal (30 days, up from four
hours) that both L3 and the console read. The reaper now finds expired
previews from the namespace annotation alone and deletes all four objects
by milner.dev/preview-pr label, and runs every five minutes instead of
fifteen now that a user-facing delete is in front of it.

Refs: #56
Woodpecker loads either .woodpecker.yaml or the .woodpecker/ directory,
never both, and the gates' root `when` deliberately filters
pull_request_closed out. The close-triggered teardown therefore needs its
own file, so the gates move to .woodpecker/gates.yaml and the required
status check becomes ci/woodpecker/pr/gates.

The preview stand-in is now hashicorp/http-echo, not the pause
container: an activated preview had to show something. http-echo is a
static binary that runs under the base's uid 65535 + read-only root
filesystem (nginx-family images do not), and the preview overlay gives it
args that name the namespace, so the page identifies its PR.

L3 also: resolves the PR number from CI_COMMIT_PULL_REQUEST, then
PREVIEW_PR, then the forge by branch -- which is what lets a manual
branch run rebuild a deleted preview; reads the TTL from preview-config
instead of a four-hour literal; labels all four objects with
milner.dev/preview-pr so every teardown is one by-label delete; and drops
the hard-coded healthChecks in favour of the wait: true it already had.

.woodpecker/cleanup.yaml runs the same four deletes on
pull_request_closed, idempotently, as preview-deployer.

Refs: #56
Two small Python services, standard library only, plus the Dockerfiles
that build them for zot.

The router serves whichever preview carries milner.dev/preview-active at
the root path, resolving the target from the Kubernetes API and watching
namespaces and Services so a switch lands in memory rather than after a
kubelet sync period. It refuses any target that is not a preview-<n>
namespace carrying the preview labels, passes Upgrade/Connection for
WebSockets, sets X-Forwarded-*, and has distinct pages for "nothing
selected" (normal) and "selected but not answering yet" (a deploy still
starting).

The console gates everything on Pocket ID's cluster_admins group,
verifying the ID token's RS256 signature against the issuer's JWKS with
hashlib and pow rather than adding a crypto dependency. Activate clears
the active label from every other preview before setting it and extends
the expiry; Delete patches the expiry to now and shows the row as pending
until the reaper collects it; Build triggers the gates pipeline on the
branch, found from the namespace or, for a deleted preview, from the
forge's open PRs. Sessions are signed cookies and every POST carries a
CSRF token, and the only values any write accepts are a validated PR
number and a validated branch.

Unit tests cover the session signature, the RS256 known-answer vector,
the ID token's iss/aud/nonce/exp checks, activation ordering, expiry
patching, target resolution and the proxy header rules -- all offline,
with injected transports.

Refs: #56
The offline suite has to catch what an applied cluster would be the first
to notice. New guards: preview-console holds no delete verb anywhere, no
secrets, no wildcards and no Ingress verbs; the reaper finds expired
previews from the namespace alone and deletes all four kinds by
milner.dev/preview-pr; the TTL is one numeric literal in preview-config
and L3 reads it rather than re-deriving it; L2 and L3 substitute the same
stand-in image; the close pipeline deletes by the same label and is
idempotent; both new Ingresses reuse the ingress-proxies ProxyGroup and
land on the root path; and the router mounts no file for the selection.

The preview overlay's docstring and the removal test lose the wrong
"must not mint a tailnet node" rationale: with a shared ProxyGroup an
Ingress creates a tailnet Service, not a device. The real reasons are
that the operator allows only one Ingress per hostname per cluster, so
preview.* can only ever point at one namespace, and that exposure is an
explicit admin action.

Refs: #56
The router selects its upstream by milner.dev/preview-service, so the
preview overlay has to apply that label — otherwise a selected preview
resolves to "no service" and serves the not-ready page forever. Prod is
deliberately left unlabelled: its exposure is its own Ingress, and nothing
should route to the prod Service by accident.

Refs: #56
OPERATIONS.md gains §11h: the two hostnames and their one-time approval
(plus the optional autoApprovers.services entries for svc:preview and
svc:oz), the out-of-band oz-secrets, the Pocket ID client, the TTL in
preview-config, the three teardown paths and which identity performs each,
and a preview drill (activate -> verify -> delete -> rebuild the same
namespace -> expire -> verify the selection cleared). §1 gains the layer
and the hand-run Secret.

AGENTS.md's repo map corrects the apps/preview row's "must not mint a
tailnet node" claim and describes the console, router, RBAC, TTL and
reaper. ARCHITECTURE.md gains the data path, the API-vs-file decision, the
teardown table and the images. PLAN.md gains Phase 7 and README a short
pointer, because the preview pipeline is now part of how changes ship.

Refs: #56
The Delete button changes cluster state immediately, and the row then
stays on screen as pending, so two things make it read correctly: a
confirmation before the click, and a clock time rather than "the reaper's
next pass". The time is derived from the reaper's five-minute cadence so
nobody has to hold the CronJob expression in their head.

Refs: #56
The proxy re-added `Connection` and `Upgrade` on the request side of a
WebSocket handshake but filtered them off the upstream's 101 on the way
back, so the bytes were tunnelled and the client was still never told which
protocol the connection switched to. The handshake then fails for every
client that follows RFC 7231 — which is all of them.

`response_headers` is the response-side mirror of `forwarded_headers`: same
hop-by-hop filter, same one exception, and now tested on its own rather than
buried in the handler.

Refs: #56
fix(oz): skip a preview whose PR label is not a number
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
6a56c1aea9
Namespaces are listed by `milner.dev/preview-pr` and `merge_rows` sorts the
result numerically, so a legal-but-non-numeric label value (`abc`) raised
`ValueError` there and 500'd the whole console. No console action could
address such a row anyway: every write goes through `validate_pr`, and the
reaper already skips an expiry it cannot reason about. Skip it at the source
and cover the state in a test.

Refs: #56
tepichord added this to the MVP project 2026-09-12 09:48:53 -07:00
ruff check . flagged four issues the CI test step would fail on once
L3 stops short-circuiting it: an unparenthesised implicit string
concatenation in oz's page renderer, import order in router/app.py and
tests/test_preview_router.py, and an unused unpacked variable in
tests/test_preview_console.py.
L3 reads the TTL from the preview-config ConfigMap in preview-system,
but Flux applies that layer from main, so on the change that adds the
ConfigMap the read races a merge and the run fails with
  Error from server (NotFound): configmaps "preview-config" not found
on a PR that cannot be anything but first. Skip the preview instead,
matching the guard L2 already has for a preview-system namespace it
would have to diff into before it exists. A test asserts the skip
stays, so deleting it cannot silently re-break the introducing PR.
fix(oz): name the client so Cloudflare does not 403 the forge read
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
716ef02899
The forge sits behind Cloudflare, whose bot filtering returns 403 for the
default Python-urllib User-Agent while allowing ordinary client UAs. oz reads
the open-PR list on every page load, so with no User-Agent the console's forge
read failed as a bare 403 that reads like an auth problem. Set
milner-oz/0.1 and bump the image tag to 0.1.1 so the fix actually rolls
(imagePullPolicy: IfNotPresent means a rebuilt 0.1.0 would be reused from the
node's cache). A test asserts the header stays.
The console's session key, OIDC client secret and Woodpecker token were a
hand-run Secret, so nothing in git could reproduce them and a rebuild of the
namespace would silently cost the console its login. Declare them Vault-first
instead: a VaultAuth binding the namespace's `default` ServiceAccount to the
`preview-system` role, and a VaultStaticSecret that syncs
secret/preview-system/oz-secrets into the `oz-secrets` Secret — overwriting the
hand-run one in place and rolling the console whenever the values change.

Both custom resources sit in the preview-system layer rather than `secrets/`:
that layer applies before preview-system's namespace exists, so a VaultAuth
there would race the namespace it binds to.

OPERATIONS.md gains the Vault half — a read-only policy on
secret/data/preview-system/* plus the kubernetes-auth role that hands it to the
namespace — and §11h now describes the Vault to VSO path in place of the
hand-run Secret. AGENTS.md states the rule, so the next credential lands in
Vault by default.
docs: set up host stability on each Mac mini
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
ci/woodpecker/manual/gates Pipeline was successful
f11d3916d6
Every machine this repo runs on is an Apple Silicon Mac mini whose only
built-in network is a BCM4388 driven by brcmfmac, and that firmware can wedge
mid-session (AsahiLinux/linux#494): the interface keeps its IP but stops
carrying traffic, so the host looks frozen while the kernel is fine. A separate
bug leaves the hardware clock in local time, so it starts one UTC offset behind
at every boot and chronyd steps it forward.

README.md now closes both off right after the libvirt deps: wired Ethernet
first, then wifi power save, NetworkManager's MAC-randomisation churn, the
watchdog in wifi-watchdog/, systemd's runtime watchdog, and the RTC. It also
records what a host reboot really costs here — the external /mnt/data disk
holds every VM image and races virtstoraged, so the storage pool fails and both
domains stay off — with the diagnosis and the libvirt-after-mount ordering that
prevents it.

The watchdog pings only the default gateway, so an unreachable ISP or DNS
server does not trip it, and escalates reconnect, then modprobe reload, then
reboot. The RTC steps are documented as measured rather than assumed: writing
UTC does not stick on this host, so the honest end state is local TZ plus the
cosmetic boot step.
feat(preview): build the change set's oz image and deploy it in previews
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
929604485f
A preview used to render the change set's manifests and run a stand-in image,
so "the preview passed" said nothing about the code under review: an edit to
`oz/` could not fail the gate and nobody could see it before merge. PLAN.md
Phase 6 named the cluster-side registry as the fix; this closes that loop.

* `l3-build-oz` builds `oz/Dockerfile` with buildah and pushes
  `zot.faun-snapper.ts.net/preview/oz:pr-<n>`. That destination is forced by
  zot's retention policy: `preview/**` with `pr-*` tags is the only tier that
  gets trimmed, so a tag pushed anywhere else is kept for 90 days or 20 tags.
* The image is smoke-tested *before* it is pushed — `buildah run` starts the
  freshly built image out of local storage — so a Dockerfile that forgot a COPY,
  or an entrypoint that cannot load, fails in seconds instead of surfacing as a
  ten-minute readiness timeout in a namespace nobody is watching. The push is
  then verified against the registry's tag list rather than assumed.
* This is the pipeline's only privileged step, and it has to be. Buildah creates
  a user namespace, these nodes run with `user.max_user_namespaces = 0`, and
  Woodpecker's Kubernetes backend offers nothing narrower (`capabilities.add` is
  explicitly unsupported, so SYS_ADMIN alone cannot be requested). Every
  unprivileged route was measured and closed: rootless and `--userns=host` both
  fail with `unshare(CLONE_NEWUSER): Operation not permitted`, and
  `hostUsers: false` — the Kubernetes field for exactly this — cannot create its
  pod sandbox here at all, with 34 GB free and no DiskPressure.
  tests/test_static.py fails if privilege appears in any other step.
* L3 hands that tag to the preview's values ConfigMap, and
  `apps/preview/oz.yaml` runs it. `${image}` now means exactly one thing — the
  image this change set built — so the stand-in keeps a literal one.
* The router's `milner.dev/preview-service` label moves from the stand-in to the
  preview's own Service. The router requires exactly one match, and server-side
  apply drops the stale label when an existing preview is re-rendered (checked
  against the live cluster, not assumed).
* A preview namespace holds placeholders, never the console's real credentials:
  it runs the change set's code, so a credential there is readable by any pull
  request that asks for it. The console boots and cannot sign anyone in — a
  fixture mode belongs to the service, not to this overlay.
* PR-number resolution moves to `ci/preview-pr.sh`, called by both the build and
  L3 so the tag that gets built is the tag that gets deployed. One fact, one
  place: drift there would surface as an ImagePullBackOff three steps from the
  cause.

Rehearsed against the live cluster with the real build context, short of pushing
the branch: buildah builds `oz/Dockerfile`, the smoke test loads the result and
prints "oz image loads", the push lands and the registry lists the tag; the
negative case (an image that builds but omits the COPYs) exits non-zero with
`ModuleNotFoundError`, so the step would stop before pushing; the kubelet pulls
the preview-tier image; the overlay renders and passes kubeconform, conftest and
real admission; the console pod reaches Ready on the placeholder env, so L3's
readiness wait would pass; and the router serves its login redirect rather than
the stand-in's page.
The first real run of l3-build-oz failed in eight seconds on buildah's
user-namespace error, because privilege was declared under
`backend_options.kubernetes.securityContext` — which does not grant it. In the
agent's `containerSecurityContext` (checked against v3.18.0, the version this
cluster runs, not just main) a container is made privileged only when
`step.Privileged` is set; the securityContext's own `privileged` is consulted
solely as a veto. `privileged: true` on the step is the field that works, and
the comment above the step now records that so the next reader does not have to
rediscover it.

The test asserted the same wrong field, which is how this shipped: it encoded a
reading of the docs rather than the backend's behaviour. It now asserts the
step-level field and fails if anything reaches for the securityContext one.

Everything before the build step passed on that run, so the config was loaded
from the change set and the L2 dry run accepted the new overlay.
feat(preview): make the preview's readiness mean it serves
Some checks failed
ci/woodpecker/manual/gates Pipeline failed
4c1d7a0ac4
L3 waits on `wait: true` for the Kustomization, and a Deployment satisfies that
by having a started container. Without a probe that is all it means, and this
cluster had the counterexample: an `oz` Deployment in preview-58 running a stale
image that listens on a different port — Ready, with a Service endpoint the
router would proxy into nothing — which the pipeline would have called green.

The previewed console now probes /healthz. That is the path the console answers
before any authentication, so the probe carries no session and no credential,
and L3's readiness wait now means "the change set's image serves" rather than
"a container started".
Re-running a preview died in three seconds at `kubectl apply` on the namespace.
`preview-deployer` holds get/list/create/delete on namespaces and no patch, so
once the namespace exists that apply is a patch, which is Forbidden. The first
run of a preview worked and every later run did not — the same failure that
showed up as a three-second `l3-preview` in pipeline #41, while a manual run that
created the namespace fresh succeeded.

It also explains the stale image: the step aborted before the values ConfigMap
below it, so a preview kept whatever image it was first given.

The namespace is now created only when it is absent, and an existing preview
keeps its labels and its expiry on purpose — the console owns both, and CI
silently resetting an operator's TTL would be worse than leaving it. The test
asserts the guard and the RBAC rule that makes it necessary, so granting patch
becomes a deliberate act rather than a new silent failure mode.

Also records the repository trust flag in OPERATIONS.md: Woodpecker's linter
refuses `privileged` unless the repo is trusted for `security`, which reports as
a pipeline-level `error` with no steps rather than a step failure. That flag
gates only `privileged` — `network` and `volumes` stay off — and it lives in
Woodpecker's database, not in git.
docs(operations): how to read a Woodpecker step log from the API
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
4689233630
A step's log is served at /api/repos/<repo>/logs/<pipeline>/<step>, where the
step is the `id` from the pipeline JSON's `workflows[0].children[]` — not the
`pid`. Asking with the pid returns a 404 with an empty body, which reads like a
missing log rather than a wrong id, and that is worth writing down: the failure
cost a detour through attaching a pod to the cluster to reach the same API.

Also records that a pipeline in `error` state never started — its reason is the
pipeline object's `errors` array, not any step log — because "the config was
rejected" and "a step ran and failed" look identical in the pipeline list.
feat(oz): a preview build of the console, baked into the image
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
7b8225a918
A preview of the console ended at a login page that cannot complete. Pocket ID
rejects https://preview.faun-snapper.ts.net/callback, and registering it would
only move the failure to the token exchange, because a preview holds a
placeholder client secret by design — a preview namespace runs the change set's
code, so a real credential there is readable by any pull request.

`--build-arg OZ_PREVIEW=1` builds the variant a preview runs: no login, fixture
rows covering every state the page can render, and the buttons redirecting and
reporting exactly as they do for real, with the action itself a line on stdout —
`kubectl -n preview-<n> logs deploy/oz`. Nothing is disabled: the point of a
preview is to look at the real flow.

Baked into the image rather than read from the environment. A preview simulates
every action, so a pod spec must not be able to switch that off — and must not be
able to switch a preview *on* in the deployed console either. The bake is a marker
file (`/app/PREVIEW-BUILD`), which also makes "which variant is this?" one command
instead of an inference. The cost, stated plainly: a preview does not exercise the
deployed artifact bit for bit, and the real paths are covered by the pipeline's
`test` step rather than by a preview.

The mode needs no credential, no session key and no cluster client — it does not
construct them — so the preview overlay drops the placeholders it used to carry,
and what is not there cannot leak or fail in a namespace that has neither.

Verified by building both variants with podman: the real image has no marker,
reports PREVIEW_BUILD=False and still refuses to start without credentials; the
preview image reports True, boots with a completely empty environment, serves the
fixtures and the banner, and logs one line per click. The pipeline's smoke test
now asserts the bake landed, so a preview image that lost it fails in CI rather
than deploying as a console. The tests set the constant the build sets; ruff,
kubeconform and conftest are clean, and the suite is at 118.
fix(preview): verify the image push, and tag it per commit
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
8278743433
A preview ran an image from an earlier run while every gate passed. The build
step pushed, but nothing checked that the push took effect: it tested that the
tag *existed*, and the tag existed already — pushed by hand minutes earlier — so
the registry kept serving that older image, L3 deployed it, and the first symptom
was a pod crash-looping on the console's real code path in a namespace with no
credentials (`OZ_OIDC_ISSUER is required`). That held l3-preview open for its full
ten-minute health-check timeout, which is the eight minutes.

Two changes, and the second is what makes the first meaningful:

* The tag is per commit (`preview/oz:pr-<n>-<sha7>`). A reused tag cannot be
  distinguished from a stale one, which is exactly what made the failure silent,
  and a per-commit tag also shows up in the pod spec, so "which image is this
  preview running" is answerable at a glance. `pr-*` keeps it in zot's trimming
  tier.
* The step pushes, reads the tag back from the registry, and compares the
  manifest's config digest with the image it just built, failing loudly and
  naming both digests if they differ. The smoke test moved after that comparison:
  while it ran first, a push that did not take effect was invisible, because the
  smoke's was the last line the step printed.

The push itself works: the same sequence in the same pod with the same commands,
run by hand, pushes and lands. What was missing was the verification.

Verified: the comparison chain extracts the right digest from the live registry
(89fad5f23517… for the tag now serving pr-58), an empty local digest fails the
step rather than passing silently, and the suite is at 118 with ruff clean.
fix(preview): make the build step's own checks correct, so its failures are real
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
5773381be5
Two bugs in the verification I added to catch a silent push, both found by running
the commands in a cluster pod rather than by reading them:

* `--retry-delay 2` — buildah wants a duration there, so it exited 125 with a
  usage error before pushing anything. It is `2s`.
* `buildah images <name>` is not a filter: it fails with "image not known", so the
  local digest came back empty. The listing form with the name in the format works,
  and `{{.ID}}` is already the twelve-character prefix of the config digest, which
  is the number the registry's manifest carries.

That second one is worth noting for how it failed rather than that it did: the
guard caught it — an empty local digest fails the step instead of passing quietly
— which is exactly the behaviour that was missing when a preview ran an older
image with every gate green.

Also hardened while here: the bake is checked by mounting the image and looking
for the marker rather than by running it, so the check cannot depend on what the
image does when it starts, and `buildah rm` is gone (the pod is ephemeral, so
removing a container only removed a suspect).

Verified end to end in a pod against the real Dockerfile: build, push, digest
comparison PASS (0cee210daf80 on both sides), marker present in the mounted image,
and the import smoke asserting the bake. Suite at 118, ruff clean.
fix(oz): make a preview's buttons change the page
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
b31ccd5906
They were logged and they redirected, exactly as asked, and they still looked
inert: `_index` re-rendered the same fixture rows every time, so a press reloaded
an identical page with one quiet line added. Rows that never move make a preview
useless for the thing it exists for — reviewing a flow — which is the whole reason
to preview a console rather than the app it administers.

Preview mode now keeps what has been pressed in that process's memory and redraws
with it, mirroring the real console where the mirror is the point: Activate marks
the row active and clears the others, because the real one moves a single label;
Delete leaves the row pending, as it is until the reaper passes; Build gives a PR
that had no preview one. The confirmation is a green banner instead of a quiet grey
line, and it says what is simulated and what is not.

Still nothing leaves the process — no credential, no cluster client, no HTTP call
— so the buttons cannot do anything even if the overlay is edited to ask them to.
The test drives a real server and asserts the page after each press: 61 active and
58 not, then 61 pending with a collection time, then 63 no longer "not built".
docs(operations): the previewed-image paragraph caught up with the step
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
edbfdda855
It still described `preview/oz:pr-<n>` and an order of build, smoke, push. The tag
is per commit and the order is build, push, verify by digest, smoke — which is the
part that matters, since the verification is what catches a push that did not take
effect.
chore: move the wifi watchdog out — the brcmf repo owns it now
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
ci/woodpecker/pull_request_closed/cleanup Pipeline was successful
c1ce5cc93a
The watchdog is about the Broadcom driver, not about the cluster, so its four
files leave this repo and the host-stability section of the README points at the
brcmf repository instead of carrying a copy of the install commands. The repo map
row and the shellcheck list lose their entries with it.

Checked before deleting, because the request came with a warning about symlinks:
this repo contains no symlinks at all (`find -type l` is empty), `wifi-watchdog/`
held four ordinary files, and the installed copies under /usr/local/sbin and
/etc/systemd/system are plain files — not links back into the repo — so the
removal cannot break the running watchdog. It also cannot supersede it: the
installed binary is 5037 bytes from today, while the repo copy was the 3183-byte
version from Sep 12, so the file being deleted had already been replaced on the
host. The timer is still enabled and active.

README.md step 5 (systemd's own runtime watchdog) is a different thing and stays.
tepichord deleted branch feat/56-oz-preview-service 2026-09-15 13:05:48 -07:00
tepichord referenced this pull request from a commit 2026-09-15 13:05:49 -07:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
tepichord/milner!58
No description provided.