Utilize SvelteKit for Oz #63

Merged
tepichord merged 57 commits from feat/59-utilize-sveltekit-oz into main 2026-09-20 17:05:16 -07:00
Owner

Closes #59

Automated PR created by forgejo-done adapter.

Closes #59 Automated PR created by forgejo-done adapter.
Port oz/ from Python stdlib HTTP server to SvelteKit 2.x + adapter-node.
Creates the project structure with package.json, svelte.config.js,
vite.config.ts, and tsconfig.json.

Core library modules:
- k8s.ts: Kubernetes client with transport injection seam (same contract
  as previewlib/k8s.py)
- preview.ts: Preview state, actions, forge/Woodpecker clients (ports
  cluster.py + forge.py)
- auth.ts: OIDC login, session cookie, CSRF (ports auth.py using
  openid-client v6, jose, node:crypto)
- preview-mode.ts: Baked-in preview variant with fixtures (ports
  app.py preview logic)

Refs: #59
Server hooks gate all routes and populate event.locals.user. Preview
mode passes all routes through (fixtures need no auth). Public routes:
/healthz, /login, /callback.

Route handlers:
- healthz: GET returns 200 text('ok')
- +page.server: load with requireAdmin() else 302 /login; rows + csrf
- login: GET sets oz_login cookie (maxAge 600) then 302 authorization URL
- callback: GET state + PKCE + ID token + groups -> set oz_session
- logout: POST clear oz_session, 303 /login
- api/activate,api/delete,api/build: POST + csrfMatches() -> 403 on
  mismatch

Refs: #59
Tests for:
- auth: session sign/verify/tamper/expiry, csrfMatches, allowed, pkcePair
- k8s: validatePr, parseTTLSeconds
- preview: validatePr, validateBranch, summarise, activationPatch,
  deletePatch, mergeRows, describeExpiry, previewState
- preview-mode: isPreviewBuild, previewFixtureRows, simulatedState,
  applySimulated, simulatedRows

50 tests total, all passing.

Refs: #59
- oz/Dockerfile: Two-stage Nix build with adapter-node output
- .dockerignore: exclude oz/node_modules, oz/build, oz/.svelte-kit
- flake.nix: add nodejs_22 to root dev shell
- gitops/clusters/milner/preview-system/oz.yaml: swap OZ_PORT/
  PYTHONDONTWRITEBYTECODE/PYTHONUNBUFFERED for HOST/PORT/ORIGIN/
  NODE_OPTIONS; V8 heap capped at 192MB
- apps/preview/oz.yaml: same env swap for preview deployment
- .woodpecker/gates.yaml: replace Python smoke test with Node.js
  healthz check; add npm test to test step
- tests/test_static.py: retarget oz/app.py assertions to
  preview-mode.ts; replace PYTHONUNBUFFERED with PORT check
- PLAN.md: update Phase 7 description (oz is SvelteKit, router stays
  Python)
- AGENTS.md: update repo map row for oz/

Refs: #59
Delete oz/{app,auth,cluster,forge,__init__,__main__}.py and
tests/test_preview_console.py. All functionality is now ported to
TypeScript in oz/src/lib/ and tested via Vitest.

Refs: #59
The doc comment at the top of the file already covers routing logic
(public routes, preview mode). The four inline comments repeated the
same information and violated minimal focus.

Refs: #59
- Add @types/node to devDependencies for Node.js type definitions
- Fix bits-ui AlertDialog import to use named export
- Remove unsupported variant prop from Button components
- Fix body type casting for fetch API compatibility
- Fix Transport type import in preview.ts
- Cast type assertions for payload properties in auth.ts
- Fix event.locals.user type assignment in hooks.server.ts
- Type assertions in preview.test.ts for patch metadata

All type errors resolved. Build and tests passing.
`oz/.gitignore` carried an unanchored `build/`, which matched the
`oz/src/routes/api/build/` route directory as well as the adapter output.
The Build endpoint therefore never entered a commit, so the button on the
page would have 404'd in every deployment. Anchor all three patterns to
the project root.

Errors from the action routes interpolate request input (the rejected
`pr` goes into `not a pull request number: <value>`), and the Python
console escaped them with `html.escape`; the port returned them raw.
Route them through a shared `htmlError` helper that escapes heading and
message, and parse the form once per request instead of twice.
The Python console checked the session CSRF token before dispatching
*any* POST, logout included; the SvelteKit port dropped that check for
`/logout` while still rendering a hidden `csrf` field. Verify the token
there too, so every POST needs a matching session token.
`pkcePair()` and `rs256Verify()` called `require('node:crypto')` inside
an ES module. Vitest shims `require`, so the tests passed, but the
adapter-node output is ESM and keeps the call verbatim: `/login` and
`/callback` both threw `require is not defined` in production. Import the
functions at the top instead. The dead pre-computed `challenge` and the
unused SHA-256 digest-info constant go with it.

`csrfMatches` fed both tokens to `timingSafeEqual`, which throws on a
length mismatch rather than returning false — a wrong-length token
returned 500 instead of 403. Compare lengths first.

`hooks.server.ts` fell back to a hard-coded session key when
`OZ_SESSION_KEY` was unset, which would have made every non-preview
session forgeable from the repository contents. Let the empty-key guard
in `Session` fail closed, and drop the unused `SessionPayload` import.
`app.css` existed and was never imported, so the build emitted no CSS at
all: the table, the bits-ui Button, and the AlertDialog were unstyled.
Add the missing `+layout.svelte` that imports it.

The page carried its own copy of `describeExpiry`, so the tested version
in `preview.ts` was not the one the table rendered. Move it to a pure
`expiry.ts` module — importing it from `preview.ts` in a component would
drag the Kubernetes client into the browser bundle — and use it from both.
fix(oz): share the simulated preview state and drop dead code
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
a45f0e4253
The Python server held one `simulated` state, so a preview action and the
page it redirected to agreed. The port returned a fresh object from every
`simulatedState()` call, so the actions were logged, redirected, and then
discarded — the notice claiming "the page reflects it" was wrong. Make it
a module singleton with an explicit reset for tests, and cover the
singleton path with a beforeEach.

Also drop the unused `PREVIEW_ACTIONS` export, the unused `newState`
import in the page loader, and merge the three `preview.js` imports.
tepichord added this to the MVP project 2026-09-15 16:47:28 -07:00
Author
Owner

The woodpecker build failed:

+ set -eu
PR=$(sh ci/preview-pr.sh)
case "$PR" in
  '')
    echo "build-oz: no PR for this run — nothing to build"
    exit 0
    ;;
esac
# Immutable per-commit tag, and the reason is a bug this step shipped
# with: a reused tag (`pr-<n>`) makes a push that does not take effect
# invisible, because the tag exists either way. The preview then serves
# whatever that tag already pointed at, and the first symptom is a pod
# crash-looping on an image from an earlier run. `pr-*` is what keeps this
# in zot's trimming tier (gitops/.../zot/README.md).
#
# One literal, also named by L3 and asserted equal by
# tests/test_static.py: if these drift, the preview deploys a tag that
# was never pushed and fails three steps away from the cause.
SHA=$(printf '%s' "a45f0e425391752527947d7dc8ce9d76b97e7230" | cut -c1-7)
IMAGE="zot.faun-snapper.ts.net/preview/oz:pr-$PR-$SHA"
echo "build-oz: building oz/Dockerfile into $IMAGE"
# vfs + chroot: with no user namespace available there is nothing to lose
# by it, and overlay-on-overlay inside a pod is not worth the trouble.
# `-f oz/Dockerfile` is not optional: the context is the repo root, which
# has no Dockerfile of its own.
#
# `--build-arg OZ_PREVIEW=1` is what makes this the *preview variant* of
# the console rather than the console: no login, fixtures, actions
# logged. Baked into the image on purpose, so the preview overlay needs
# no environment to say what it is and a pod spec cannot switch the
# simulation off.
buildah --storage-driver vfs bud --isolation=chroot -f oz/Dockerfile \
  --build-arg OZ_PREVIEW=1 -t "$IMAGE" .

# Push, verify the push by digest, and only then smoke-test. The order is
# the fix: a tag that exists proves nothing — that is exactly how a push
# that never took effect went unnoticed — so the registry's own answer for
# this tag has to match the image this step just built. `buildah images`
# reports the config digest as the image ID, and the config digest is what
# the manifest carries, so the two are comparable.
echo "build-oz: pushing"
# `--retry-delay` takes a duration, not a count: `2` fails the step with
# buildah's usage error (125) before it pushes anything.
buildah --storage-driver vfs push --tls-verify=true --retry 3 --retry-delay 2s "$IMAGE"

echo "build-oz: verifying the registry serves what was built"
# `buildah images <name>` is not a filter — it fails with "image not
# known" — so list with the name in the format and pick our line out.
# `{{.ID}}` is already the 12-character prefix of the config digest, which
# is the number the registry's manifest carries.
LOCAL=$(buildah --storage-driver vfs images --noheading --format '{{.ID}} {{.Name}}:{{.Tag}}' \
  | grep -F " $IMAGE" | head -n1 | cut -d' ' -f1)
REMOTE=$(curl -fsS -H 'Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \
  "https://zot.faun-snapper.ts.net/v2/preview/oz/manifests/pr-$PR-$SHA" \
  | tr -d '[:space:]' | grep -o '"config":{[^}]*}' | grep -o 'sha256:[a-f0-9]*' \
  | head -n1 | sed 's/sha256://' | cut -c1-12)
if [ -z "$LOCAL" ] || [ "$LOCAL" != "$REMOTE" ]; then
  echo "build-oz: the registry does not serve this build (local=$LOCAL remote=$REMOTE)"
  echo "build-oz: refusing to continue with an image nobody can identify"
  exit 1
fi

echo "build-oz: smoke-testing the pushed image"
# `buildah run` works on a working container, not on an image, so make one
# from what was just built and pushed.
CID=$(buildah --storage-driver vfs from "$IMAGE")
# The bake is checked by mounting the image's filesystem and looking for
# the marker, not by running it: this cannot be affected by anything the
# image does when it starts, and it is the check that has to hold — a
# preview image without the bake deploys as the console, which then dies
# on the first missing credential in a namespace that has none.
ROOT=$(buildah --storage-driver vfs mount "$CID")
if [ ! -e "$ROOT/app/PREVIEW-BUILD" ]; then
  echo "build-oz: the pushed image is not a preview build: $ROOT/app/PREVIEW-BUILD is absent"
  exit 1
fi
buildah --storage-driver vfs umount "$CID"
# Running it needs no network and no cluster configuration: start the
# SvelteKit server and check /healthz — this proves the Dockerfile's
# COPYs, the adapter-node output, and the entrypoint are sound.
buildah --storage-driver vfs run --isolation=chroot "$CID" sh -c \
  'node /app/build/index.js & sleep 3; node -e "fetch(\"http://127.0.0.1:8080/healthz\").then(r=>process.exit(r.ok?0:1))"'
echo "build-oz: $IMAGE built, pushed, verified and smoke-tested"

build-oz: building oz/Dockerfile into zot.faun-snapper.ts.net/preview/oz:pr-63-a45f0e4
[1/2] STEP 1/6: FROM nixos/nix:2.28.3 AS builder
Error: creating build container: short-name resolution enforced but cannot prompt without a TTY
The woodpecker build failed: ``` + set -eu PR=$(sh ci/preview-pr.sh) case "$PR" in '') echo "build-oz: no PR for this run — nothing to build" exit 0 ;; esac # Immutable per-commit tag, and the reason is a bug this step shipped # with: a reused tag (`pr-<n>`) makes a push that does not take effect # invisible, because the tag exists either way. The preview then serves # whatever that tag already pointed at, and the first symptom is a pod # crash-looping on an image from an earlier run. `pr-*` is what keeps this # in zot's trimming tier (gitops/.../zot/README.md). # # One literal, also named by L3 and asserted equal by # tests/test_static.py: if these drift, the preview deploys a tag that # was never pushed and fails three steps away from the cause. SHA=$(printf '%s' "a45f0e425391752527947d7dc8ce9d76b97e7230" | cut -c1-7) IMAGE="zot.faun-snapper.ts.net/preview/oz:pr-$PR-$SHA" echo "build-oz: building oz/Dockerfile into $IMAGE" # vfs + chroot: with no user namespace available there is nothing to lose # by it, and overlay-on-overlay inside a pod is not worth the trouble. # `-f oz/Dockerfile` is not optional: the context is the repo root, which # has no Dockerfile of its own. # # `--build-arg OZ_PREVIEW=1` is what makes this the *preview variant* of # the console rather than the console: no login, fixtures, actions # logged. Baked into the image on purpose, so the preview overlay needs # no environment to say what it is and a pod spec cannot switch the # simulation off. buildah --storage-driver vfs bud --isolation=chroot -f oz/Dockerfile \ --build-arg OZ_PREVIEW=1 -t "$IMAGE" . # Push, verify the push by digest, and only then smoke-test. The order is # the fix: a tag that exists proves nothing — that is exactly how a push # that never took effect went unnoticed — so the registry's own answer for # this tag has to match the image this step just built. `buildah images` # reports the config digest as the image ID, and the config digest is what # the manifest carries, so the two are comparable. echo "build-oz: pushing" # `--retry-delay` takes a duration, not a count: `2` fails the step with # buildah's usage error (125) before it pushes anything. buildah --storage-driver vfs push --tls-verify=true --retry 3 --retry-delay 2s "$IMAGE" echo "build-oz: verifying the registry serves what was built" # `buildah images <name>` is not a filter — it fails with "image not # known" — so list with the name in the format and pick our line out. # `{{.ID}}` is already the 12-character prefix of the config digest, which # is the number the registry's manifest carries. LOCAL=$(buildah --storage-driver vfs images --noheading --format '{{.ID}} {{.Name}}:{{.Tag}}' \ | grep -F " $IMAGE" | head -n1 | cut -d' ' -f1) REMOTE=$(curl -fsS -H 'Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \ "https://zot.faun-snapper.ts.net/v2/preview/oz/manifests/pr-$PR-$SHA" \ | tr -d '[:space:]' | grep -o '"config":{[^}]*}' | grep -o 'sha256:[a-f0-9]*' \ | head -n1 | sed 's/sha256://' | cut -c1-12) if [ -z "$LOCAL" ] || [ "$LOCAL" != "$REMOTE" ]; then echo "build-oz: the registry does not serve this build (local=$LOCAL remote=$REMOTE)" echo "build-oz: refusing to continue with an image nobody can identify" exit 1 fi echo "build-oz: smoke-testing the pushed image" # `buildah run` works on a working container, not on an image, so make one # from what was just built and pushed. CID=$(buildah --storage-driver vfs from "$IMAGE") # The bake is checked by mounting the image's filesystem and looking for # the marker, not by running it: this cannot be affected by anything the # image does when it starts, and it is the check that has to hold — a # preview image without the bake deploys as the console, which then dies # on the first missing credential in a namespace that has none. ROOT=$(buildah --storage-driver vfs mount "$CID") if [ ! -e "$ROOT/app/PREVIEW-BUILD" ]; then echo "build-oz: the pushed image is not a preview build: $ROOT/app/PREVIEW-BUILD is absent" exit 1 fi buildah --storage-driver vfs umount "$CID" # Running it needs no network and no cluster configuration: start the # SvelteKit server and check /healthz — this proves the Dockerfile's # COPYs, the adapter-node output, and the entrypoint are sound. buildah --storage-driver vfs run --isolation=chroot "$CID" sh -c \ 'node /app/build/index.js & sleep 3; node -e "fetch(\"http://127.0.0.1:8080/healthz\").then(r=>process.exit(r.ok?0:1))"' echo "build-oz: $IMAGE built, pushed, verified and smoke-tested" build-oz: building oz/Dockerfile into zot.faun-snapper.ts.net/preview/oz:pr-63-a45f0e4 [1/2] STEP 1/6: FROM nixos/nix:2.28.3 AS builder Error: creating build container: short-name resolution enforced but cannot prompt without a TTY ```
fix(oz): fully qualify the Nix base image in the Dockerfile
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
01601426f4
buildah in CI runs with short-name-mode=enforcing and no TTY, so the
unqualified `nixos/nix:2.28.3` could not be resolved and the
l3-build-oz step failed at the first FROM.

Refs #63
Author
Owner

New build errors:

.svelte-kit/output/server/index.js                                      132.21 kB
✓ built in 4.88s

Run npm run preview to preview your production build locally.

> Using @sveltejs/adapter-node
  ✔ done

up to date, audited 64 packages in 456ms

11 packages are looking for funding
  run `npm fund` for details

5 low severity vulnerabilities

To address issues that do not require attention, run:
  npm audit fix

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.
[1/2] STEP 6/6: RUN nix build ./oz#node --out-link /tmp/node && mkdir -p /tmp/closure && cp -a "$(nix-store -qR /tmp/node)" /tmp/closure/
cp: cannot stat '/nix/store/5fmknmxx5s78zbbai817zghzmc5l7d3z-xgcc-15.3.0-libgcc'$'\n''/nix/store/b57ihysnpkp69hsbbw1gr1yjac6xagkf-libunistring-1.4.2'$'\n''/nix/store/929svfrm3z9facma5l7y3pkym56b8gni-libidn2-2.3.8'$'\n''/nix/store/f8q4w2hbjvwy7qqwpnvbf5f4qwyww6cp-glibc-2.42-67'$'\n''/nix/store/xa1brw3bh0lrw4f5d1g74gmm2jv4d39v-pcre2-10.47'$'\n''/nix/store/07gribxm3sky3lw2g64n59l974q7q1bf-gnugrep-3.12'$'\n''/nix/store/3v2w5hdrpzwx3w8svda35lyrq9jwqbc8-zlib-1.3.2'$'\n''/nix/store/947psdd7hsirjhg4vzjsakqck6dfy7z0-sqlite-3.53.3-bin'$'\n''/nix/store/p1zamvivw0l5zf4crr82v6f6rpg014fm-sqlite-3.53.3'$'\n''/nix/store/16rny3zmsdad30dyivimcifkdvpqqwyh-sqlite-3.53.3-dev'$'\n''/nix/store/2i1hgmh5kaa976iqdrgwcclv0mz8jgrf-attr-2.6.0'$'\n''/nix/store/jkry4b00dmfsfx5r8m2n11r7fchz6aj8-gcc-15.3.0-libgcc'$'\n''/nix/store/ylzalvsf8nxhidm1p72k6ckxckpj1wd3-gcc-15.3.0-lib'$'\n''/nix/store/2llfzajrzvc9r5xgz6f84jspl2z3n1xs-simdutf-9.0.0'$'\n''/nix/store/3kaakrwd1jia637aa3cpj3lkgfxwg3k3-bash-5.3p15'$'\n''/nix/store/ah5m9c4ffy0m45h68ls77xr6m4c6rmg9-ncurses-6.6'$'\n''/nix/store/6alwmb2pqwzkvfrhanphmab7487p0qbi-readline-8.3p3'$'\n''/nix/store/a9kda025a17126f14c01xpwscmjqddwb-bash-interactive-5.3p15'$'\n''/nix/store/65448rf5aim0fjsiiqcz6v16yj5igq0a-icu4c-78.3'$'\n''/nix/store/d8ymm5b5m3n4j15g933y7p9shcwd4k6d-libuv-1.52.1'$'\n''/nix/store/78j8mm3dpnj1q6x3813ripjkjklqzyjq-libuv-1.52.1-dev'$'\n''/nix/store/7zy98i52jaifcpgj3ymw6vdx754m0pnn-nghttp2-1.69.0-lib'$'\n''/nix/store/y4kivjss3mg1kxy6p8483s9mi4hf4qmy-brotli-1.2.0-lib'$'\n''/nix/store/ynvd9pad8r220aa3p4ki7g04g2z8rh1g-brotli-1.2.0'$'\n''/nix/store/9h6z0m0j8lpi79z111fy37hn2brmqbm8-brotli-1.2.0-dev'$'\n''/nix/store/bbgmz8s6l8klm21m90hw3ibywhimrsbh-simdutf-6.5.0'$'\n''/nix/store/bvjp82swwzljhjfsnv7jdvr7xyxb0dbc-nghttp3-1.16.0'$'\n''/nix/store/vnazsj33pbi2nklj0g9w8xs6h9wmgb8k-openssl-3.6.3'$'\n''/nix/store/cpwqjn64gzylzqnr2lj7wgdd9fvf29df-ngtcp2-1.23.0'$'\n''/nix/store/abnig2yjbzhisw59m8ann9kwlv3v027a-zlib-1.3.2-static'$'\n''/nix/store/cs4qdrs4wvw71gyqkgslcq0yp4g79gpd-zlib-1.3.2-dev'$'\n''/nix/store/cvgmnc85kzd87hsxrpspap6zvmmwmh9d-uvwasi-0.0.23'$'\n''/nix/store/dgzd0xg1qmk6z4bdaipzlb4fivfh0b78-gmp-with-cxx-6.3.0'$'\n''/nix/store/l5zqppiy8dazh9z96lfj2z7swzx9dsf7-acl-2.4.0'$'\n''/nix/store/dqxknsb71lsgzfzcdmn6x7fzssqbdyyx-coreutils-9.11'$'\n''/nix/store/dk4jxsif42n1asxag0vqfwvjj8ir3gzc-icu4c-78.3-dev'$'\n''/nix/store/ikafwfc3mfisagsclqb8dl58q9qqyky2-llhttp-9.4.3'$'\n''/nix/store/grsq3jn1lprgim1ibdyg1ny1mgvnd2xq-llhttp-9.4.3-dev'$'\n''/nix/store/7fpa007v1xfaz2zq9kmqnahmiy0pnr6n-libev-4.33'$'\n''/nix/store/fcxfhk189ild904pas0z3s9f3igqhbq8-c-ares-1.34.8'$'\n''/nix/store/nw49cbshw8av4h12hhkh907nw6x4y3mp-nghttp2-1.69.0'$'\n''/nix/store/i12w90xf6fqxy35mrndlgn5x33v31z53-nghttp2-1.69.0-dev'$'\n''/nix/store/k2fr8pihnym47m71fij3ns184vbx4v79-zstd-1.5.7'$'\n''/nix/store/f1gagmli6hbbzgmaz2yciv3cql27piwz-openssl-3.6.3-bin'$'\n''/nix/store/kdk1h9zg91ypbnsrd4m82qs191i5rijv-openssl-3.6.3-dev'$'\n''/nix/store/n8wz6bjfkdkpa1qrgnx9fz526qy5ry04-simdjson-4.6.5'$'\n''/nix/store/vs650xhl2qdrmbws0vsf0nsx5qh86d8d-c-ares-1.34.8'$'\n''/nix/store/ncrqb1icmqyxbpfwxawyqlks98dy5nid-c-ares-1.34.8-dev'$'\n''/nix/store/nn5c3lfrjf40ib773cvl3hxjvw86bs36-ngtcp2-1.23.0-dev'$'\n''/nix/store/swvlm5m5jgvasnza487qbgqfas6a1r30-ada-3.4.4'$'\n''/nix/store/57fv53m58x8l051dirhzwlhlk3zr48im-zstd-1.5.7-bin'$'\n''/nix/store/vhkc52wdiwjhy0vkgsswl1bsr7xfphg3-zstd-1.5.7-dev'$'\n''/nix/store/wm6k5qgx2vrkzcvgs9w9cfibxbvb74b1-nghttp3-1.16.0-dev'$'\n''/nix/store/i162s3rczlpp4inzczp7xjchcm16abwl-nodejs-slim-22.23.2'$'\n''/nix/store/6g04a4bbvnjylrw3dcnyyzr25ynhpn6q-nodejs-slim-22.23.2-npm'$'\n''/nix/store/91xq5zy0szyikpbjndacd1gp0yk9a98x-nodejs-slim-22.23.2-corepack'$'\n''/nix/store/3xbw25h18j51wl7xd28ini307d07wfrh-nodejs-22.23.2': No such file or directory
subprocess exited with status 1
subprocess exited with status 1
Error: building at STEP "RUN nix build ./oz#node --out-link /tmp/node && mkdir -p /tmp/closure && cp -a "$(nix-store -qR /tmp/node)" /tmp/closure/": exit status 1
New build errors: ``` .svelte-kit/output/server/index.js 132.21 kB ✓ built in 4.88s Run npm run preview to preview your production build locally. > Using @sveltejs/adapter-node ✔ done up to date, audited 64 packages in 456ms 11 packages are looking for funding run `npm fund` for details 5 low severity vulnerabilities To address issues that do not require attention, run: npm audit fix To address all issues (including breaking changes), run: npm audit fix --force Run `npm audit` for details. [1/2] STEP 6/6: RUN nix build ./oz#node --out-link /tmp/node && mkdir -p /tmp/closure && cp -a "$(nix-store -qR /tmp/node)" /tmp/closure/ cp: cannot stat '/nix/store/5fmknmxx5s78zbbai817zghzmc5l7d3z-xgcc-15.3.0-libgcc'$'\n''/nix/store/b57ihysnpkp69hsbbw1gr1yjac6xagkf-libunistring-1.4.2'$'\n''/nix/store/929svfrm3z9facma5l7y3pkym56b8gni-libidn2-2.3.8'$'\n''/nix/store/f8q4w2hbjvwy7qqwpnvbf5f4qwyww6cp-glibc-2.42-67'$'\n''/nix/store/xa1brw3bh0lrw4f5d1g74gmm2jv4d39v-pcre2-10.47'$'\n''/nix/store/07gribxm3sky3lw2g64n59l974q7q1bf-gnugrep-3.12'$'\n''/nix/store/3v2w5hdrpzwx3w8svda35lyrq9jwqbc8-zlib-1.3.2'$'\n''/nix/store/947psdd7hsirjhg4vzjsakqck6dfy7z0-sqlite-3.53.3-bin'$'\n''/nix/store/p1zamvivw0l5zf4crr82v6f6rpg014fm-sqlite-3.53.3'$'\n''/nix/store/16rny3zmsdad30dyivimcifkdvpqqwyh-sqlite-3.53.3-dev'$'\n''/nix/store/2i1hgmh5kaa976iqdrgwcclv0mz8jgrf-attr-2.6.0'$'\n''/nix/store/jkry4b00dmfsfx5r8m2n11r7fchz6aj8-gcc-15.3.0-libgcc'$'\n''/nix/store/ylzalvsf8nxhidm1p72k6ckxckpj1wd3-gcc-15.3.0-lib'$'\n''/nix/store/2llfzajrzvc9r5xgz6f84jspl2z3n1xs-simdutf-9.0.0'$'\n''/nix/store/3kaakrwd1jia637aa3cpj3lkgfxwg3k3-bash-5.3p15'$'\n''/nix/store/ah5m9c4ffy0m45h68ls77xr6m4c6rmg9-ncurses-6.6'$'\n''/nix/store/6alwmb2pqwzkvfrhanphmab7487p0qbi-readline-8.3p3'$'\n''/nix/store/a9kda025a17126f14c01xpwscmjqddwb-bash-interactive-5.3p15'$'\n''/nix/store/65448rf5aim0fjsiiqcz6v16yj5igq0a-icu4c-78.3'$'\n''/nix/store/d8ymm5b5m3n4j15g933y7p9shcwd4k6d-libuv-1.52.1'$'\n''/nix/store/78j8mm3dpnj1q6x3813ripjkjklqzyjq-libuv-1.52.1-dev'$'\n''/nix/store/7zy98i52jaifcpgj3ymw6vdx754m0pnn-nghttp2-1.69.0-lib'$'\n''/nix/store/y4kivjss3mg1kxy6p8483s9mi4hf4qmy-brotli-1.2.0-lib'$'\n''/nix/store/ynvd9pad8r220aa3p4ki7g04g2z8rh1g-brotli-1.2.0'$'\n''/nix/store/9h6z0m0j8lpi79z111fy37hn2brmqbm8-brotli-1.2.0-dev'$'\n''/nix/store/bbgmz8s6l8klm21m90hw3ibywhimrsbh-simdutf-6.5.0'$'\n''/nix/store/bvjp82swwzljhjfsnv7jdvr7xyxb0dbc-nghttp3-1.16.0'$'\n''/nix/store/vnazsj33pbi2nklj0g9w8xs6h9wmgb8k-openssl-3.6.3'$'\n''/nix/store/cpwqjn64gzylzqnr2lj7wgdd9fvf29df-ngtcp2-1.23.0'$'\n''/nix/store/abnig2yjbzhisw59m8ann9kwlv3v027a-zlib-1.3.2-static'$'\n''/nix/store/cs4qdrs4wvw71gyqkgslcq0yp4g79gpd-zlib-1.3.2-dev'$'\n''/nix/store/cvgmnc85kzd87hsxrpspap6zvmmwmh9d-uvwasi-0.0.23'$'\n''/nix/store/dgzd0xg1qmk6z4bdaipzlb4fivfh0b78-gmp-with-cxx-6.3.0'$'\n''/nix/store/l5zqppiy8dazh9z96lfj2z7swzx9dsf7-acl-2.4.0'$'\n''/nix/store/dqxknsb71lsgzfzcdmn6x7fzssqbdyyx-coreutils-9.11'$'\n''/nix/store/dk4jxsif42n1asxag0vqfwvjj8ir3gzc-icu4c-78.3-dev'$'\n''/nix/store/ikafwfc3mfisagsclqb8dl58q9qqyky2-llhttp-9.4.3'$'\n''/nix/store/grsq3jn1lprgim1ibdyg1ny1mgvnd2xq-llhttp-9.4.3-dev'$'\n''/nix/store/7fpa007v1xfaz2zq9kmqnahmiy0pnr6n-libev-4.33'$'\n''/nix/store/fcxfhk189ild904pas0z3s9f3igqhbq8-c-ares-1.34.8'$'\n''/nix/store/nw49cbshw8av4h12hhkh907nw6x4y3mp-nghttp2-1.69.0'$'\n''/nix/store/i12w90xf6fqxy35mrndlgn5x33v31z53-nghttp2-1.69.0-dev'$'\n''/nix/store/k2fr8pihnym47m71fij3ns184vbx4v79-zstd-1.5.7'$'\n''/nix/store/f1gagmli6hbbzgmaz2yciv3cql27piwz-openssl-3.6.3-bin'$'\n''/nix/store/kdk1h9zg91ypbnsrd4m82qs191i5rijv-openssl-3.6.3-dev'$'\n''/nix/store/n8wz6bjfkdkpa1qrgnx9fz526qy5ry04-simdjson-4.6.5'$'\n''/nix/store/vs650xhl2qdrmbws0vsf0nsx5qh86d8d-c-ares-1.34.8'$'\n''/nix/store/ncrqb1icmqyxbpfwxawyqlks98dy5nid-c-ares-1.34.8-dev'$'\n''/nix/store/nn5c3lfrjf40ib773cvl3hxjvw86bs36-ngtcp2-1.23.0-dev'$'\n''/nix/store/swvlm5m5jgvasnza487qbgqfas6a1r30-ada-3.4.4'$'\n''/nix/store/57fv53m58x8l051dirhzwlhlk3zr48im-zstd-1.5.7-bin'$'\n''/nix/store/vhkc52wdiwjhy0vkgsswl1bsr7xfphg3-zstd-1.5.7-dev'$'\n''/nix/store/wm6k5qgx2vrkzcvgs9w9cfibxbvb74b1-nghttp3-1.16.0-dev'$'\n''/nix/store/i162s3rczlpp4inzczp7xjchcm16abwl-nodejs-slim-22.23.2'$'\n''/nix/store/6g04a4bbvnjylrw3dcnyyzr25ynhpn6q-nodejs-slim-22.23.2-npm'$'\n''/nix/store/91xq5zy0szyikpbjndacd1gp0yk9a98x-nodejs-slim-22.23.2-corepack'$'\n''/nix/store/3xbw25h18j51wl7xd28ini307d07wfrh-nodejs-22.23.2': No such file or directory subprocess exited with status 1 subprocess exited with status 1 Error: building at STEP "RUN nix build ./oz#node --out-link /tmp/node && mkdir -p /tmp/closure && cp -a "$(nix-store -qR /tmp/node)" /tmp/closure/": exit status 1 ```
`nix-store -qR` prints one store path per line, and `$(...)` in the
double-quoted position collapses those newlines into a single argument, so
`cp` was handed one enormous path and failed with
`cp: cannot stat '/nix/store/...'$'\n''/nix/store/...'`.

The list goes through a file, and `xargs -a` (rather than a pipe, which
hadolint rejects as DL4006 without `-o pipefail`) copies it. `cp -t` is
required because xargs appends the paths after the command.

Fixes the build failure in #63.
`ln` does not create parent directories and `nixos/nix` has no
`/usr/local` at all, so the symlink instruction failed outright. Worse,
even once created the symlink would have been invisible: the base image's
`PATH` is `/root/.nix-profile/bin:/nix/var/nix/profiles/default/bin:...`
with no `/usr/local/bin`, so both `ENTRYPOINT ["node", ...]` and the
gates smoke test's bare `node` invocations would have failed to resolve
it.

The closure copy is fixed but the builder stage now gets far enough to hit
this, so it is the next failure in the same #63 build log.
Refers: #59
docs(oz): state the closure copy's trade-offs accurately in the Dockerfile
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
2cc3cc3bbc
The comment said `$(...)` collapses the newline-separated list into one
argument. That is only true of the quoted form the old line used: unquoted,
the substitution word-splits correctly, which is exactly what the `xargs`
call relies on. The reason it is not written that way is that hadolint runs
shellcheck over `RUN` and reports SC2046 on the bare substitution, so the
list has to go through a file.

Spelling both out keeps a future reader from "simplifying" the line back
into the form that broke the build.

Refers: #59
Author
Owner

Some significant architectural changes have been made thanks to the victoria logs spike. Please pull from main and consolidate it. It is necessary that oz is able to be previewed as part of this PR. I await your changes.

Some significant architectural changes have been made thanks to the victoria logs spike. Please pull from main and consolidate it. It is necessary that oz is able to be previewed as part of this PR. I await your changes.
The victoria-logs change set moved the front-door rule into
`previewlib/selection.py`, read by the router and by the Python console.
The SvelteKit console replaces that console, so the rule is ported to
`oz/src/lib/selection.ts` and `tests/test_static.py` holds every
annotation name and refusal reason to the Python definition — the two
languages cannot share a module, so the test is what keeps the page and
the front door deciding by one rule.
The console reads the same two layers the router dials and offers one
button per candidate plus `auto` (story 7), with the rule's own answer
and any refusal reported under the controls (story 8). A change set about
the router itself is named as the first layer, and a pin outranks it —
which is also the way around a preview's router that does not come up.

Preview mode carries the same dimension in its fixtures, so a preview of
the console still shows the state the controls exist for.
The console source moved, so the pinned tag has to move with it or
oz-tag-guard fails the pull request and the kubelet keeps serving an
image from the old tree. 0.1.4 is the SvelteKit console with the ported
front-door rule; the stale note that nothing built this tag is replaced
with what the main-push half and the guard actually do.
Add JSDoc to public exports in auth.ts, http.ts, and expiry.ts that lacked
doc comments. Update ARCHITECTURE.md §9 'The images' to reflect that oz/ is
now SvelteKit 2.x + adapter-node (TypeScript), not Python, and that OIDC
verification uses node:crypto RS256 instead of hashlib/pow.

Refs: #63
`PreviewStore.services()` is superseded by `frontDoorServices()`, which
reads the same Services plus the router label, and `selection.pinnedApp()`
has no caller and no counterpart in `previewlib/selection.py` — the pin is
read straight off the namespace in `summarise`.
The console's `matches` is a hand-rolled port of Python's
`fnmatch.fnmatchcase`, and a JS character class reads two things the other
way round: it ends at the first `]`, so `[]]x` becomes an empty (never
matching) class, and `^` negates, so `[^]x` matches any character. In
`fnmatch` a leading `]` is a member and a leading `^` is a literal, so
both readings are escaped here. Validated against `fnmatch.fnmatchcase` over
a randomized pattern set; the one remaining divergence is CPython 3.13's
`--` set-difference inside a class, which no manifest declares.
The existing simulated actions (activate, delete, build) each have a test, and
the front-door action the console added — the one the pin exists for — had
none. Adds the `frontDoorPatch` tests beside the other two patch tests, and
the `applySimulated`/`simulatedRows` pair for a pin and for clearing one.
docs(oz): correct two comments that contradict the code
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
82e3797cd7
The Dockerfile said nothing in CI builds the deployed tag; `build-oz`'s
main-push half does since the VictoriaLogs merge, which is what
`oz-tag-guard.sh` and the manifest's own note rely on. And `choices()` puts
the empty (auto) value first, not the pin's value.
Adds missing JSDoc on exported classes, interfaces, and functions in
selection.ts (Decision), k8s.ts (KubernetesClient, KubernetesError,
parseTTLSeconds), and preview.ts (PreviewStore, PreviewSummary,
PreviewRow, ValueError, ForgeError, summarise, expiresAt,
previewState, activationPatch, deactivationPatch, deletePatch).

Refs: #63
The merge resolution for main's fallback-removal change repeated the three
lines above `PINNED`, leaving the same paragraph twice in a row.
`# this line` stood alone between two fuller lines; the sentence reads
again when it is joined back up.
f857a40 taught `PreviewStore.previews` to name the change set's own router
only when the preview is the one the front door serves, and nothing held it
there. The test drives the store through a fake transport — the seam
KubernetesClient already documents — and asserts both directions.
docs(operations): describe the console's front-door buttons, not a dropdown
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
a508df4c40
The merge took main's OPERATIONS.md whole, and main's `oz` console renders
a dropdown with a Show button. This console renders one button per app, so
the runbook described a control the page does not have — and disagreed with
AGENTS.md and ARCHITECTURE.md, which say buttons. The other two call sites
are the same sentence in the L3 smoke test.
Author
Owner
  • The woodpecker build timed out because of the timeout limit set on zot, increase the configured timeout.
  • Now on the other hand, for the nix volume, I've merged a change for a cluster-wide shared nix store, utilize that for the build.
- The woodpecker build timed out because of the timeout limit set on zot, increase the configured timeout. - Now on the other hand, for the nix volume, I've merged a change for a cluster-wide shared nix store, utilize that for the build.
Merge branch 'main' into feat/59-utilize-sveltekit-oz
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
f1006cd1da
A Nix store path is an absolute, self-referential name, so a closure can only
work at /nix/store and "use the store without mounting it" is not a thing that
exists. The image stops trying: it carries the app and bakes only the *path* of
the nodejs_22 it was built against (`/usr/local/bin/node` -> that path), and the
kubelet hands it the toolchain by mounting the cluster's shared store at /nix,
read-only, in preview-system and in every preview namespace. What the copy it
replaces cost was a layer per push carrying a closure the cluster already keeps,
and a runtime frozen at build time instead of read from the store the pipeline
keeps warm.

That makes the base tag load-bearing. Seeding copies a whole image store into
the shared one, so an image based on the same tag still finds every path it
shipped under the mount, /bin/sh included, and an image based on any other finds
its own store hidden and its shell dangling. `NIX_IMAGE` moves 2.28.3 -> 2.35.2
to meet the `test` step and the §10g seed job on the tag they already run, and
tests/test_static.py holds the three equal.

The rest follows from the claim:

  * build-oz mounts the store and binds it `--volume /nix:/nix:ro` into the
    smoke test, so that container gets the layout the pod will, and pre-flights
    that the baked path resolves inside the store — naming the path and
    docs/storage.md §10g — instead of letting a missing one surface as a preview
    whose console cannot exec its entrypoint.
  * class `nix` is Retain (Delete would `rm -rf` the shared store), so a
    preview's claim leaves its PV Released for good. The reaper's third pass
    sweeps those, bounded by phase *and* a `preview-<n>` claim so the console's
    own PV is never a candidate; drills/check.sh ignores preview handles for the
    mirror-image reason, because a claim that comes and goes on its own is not a
    drill regression.
  * docs/nix.md §14 is the runbook this was missing: what the store is for, who
    mounts it, how to add a tool or a Nix-built app, and the traps.

Read-only is what keeps story 14 true — a preview runs a stranger's code, and a
writable handle on the store every CI run reads is real power.

Not fixed by this: the push that failed. The base image's own ~166 MB layer is
still pushed either way, so 129273d's readTimeout/writeTimeout is what the
registry needed; docs/nix.md §14c says so rather than implying otherwise.
§14f said "the suite's own step is the seeder", which reads as though the pytest
command were what puts node in the store. It is the other one: the Vitest command
enters `nix develop ./oz#default`, and entering a shell is what realises the
flake's `nodejs_22` into the mounted store. §14d's row and §14a's table now name
the two commands and what each one realises.

Corrected while checking it: the root flake carries `nodejs_22` too (since
1644d80, so a root `nix develop` can work on `oz/`), so the pytest command's dev
shell has it as well — both flakes pin the same nixpkgs revision, so it is the
same store path either way, which only makes the seeding less dependent on which
command a reader means.
feat(oz): build and run the console on Deno, on no base image (#63)
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
e153be6bb2
The runtime stage was `nixos/nix` for one reason: it had a store and a Node in
it. The store is the kubelet's job now and the mount *hides* whatever the image
shipped there, so the base was carrying a closure nothing could reach — and, in
exchange, `NIX_IMAGE` had to stay in step with the tag the shared store was
seeded from, because a shell under that mount had to come from the same store.
Both of those are one decision: the runtime stage is `FROM scratch`.

Deno is what makes a stage with nothing in it possible: one binary, no shell
needed to run it, and the same adapter-node build the console already had. The
whole project moves, not just the image — a console that runs on Deno in
production and on Node in the suite is a console the suite does not test:

  * `flake.nix` and `oz/flake.nix` carry `deno` from the one pinned nixpkgs
    revision, so the dev shell's deno, `nix build ./oz#deno`'s and the suite's
    are the same store path (docs/nix.md §14f holds the three equal).
  * `oz/deno.json` holds the tasks, `oz/deno.lock` replaces `package-lock.json`,
    and `deno install --frozen` is the gate the image builds through.
  * `oz/vite.config.ts` sets `ssr.noExternal`, so the server bundle carries the
    handful of packages the console imports. That is the second half of the
    slimness: no `node_modules` in the image, because nothing looks one up.
  * `oz/src/lib/k8s.ts` drops `undici` for `Deno.createHttpClient({caCerts})`.
    Deno carries its own TLS roots (a scratch image needs no CA bundle), but the
    cluster CA is a file, and an `HttpClient` holds a pool and pins the event
    loop — so it is one lazily built singleton per process, never per request.

The image is the app, a **static** busybox (`oz#busybox`, so the mounted store
cannot hide the shell the smoke test drives), and `/usr/local/bin/deno` as a
link to the store path this build was made with — a path, not a closure: the
mount supplies closure, the path says which deno is this app's. `NIX_IMAGE` is
the builder's Nix and nothing else; the base-tag invariant is gone with the base.
Verified locally with `podman build` (both variants, 4.04 MB, right marker file
in each), and the smoke test's shape — `deno run -A /app/build/index.js`, then
`/healthz` — passes with `-v /nix:/nix:ro` and fails without it, which is the
coupling working.

The manifests say `DENO_V8_FLAGS` where they said `NODE_OPTIONS`: Deno reads the
former and ignores the latter, and the 192 MB heap cap has to keep applying. The
tag rule survives the rewrite untouched — the cluster pins a literal revision and
`build-oz`'s main-push half rebuilds it, so a change to this image is still a
change to that literal.
feat(images): build the console and the router as Nix derivations (#63)
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
c86af47392
The console read its runtime out of the shared store (c6983e2): a `nix` claim, a
read-only `/nix`, a seed that had to run first, and a reaper pass for the PV a
preview namespace would leave behind. That coupling is only needed while an image
is not its own closure, and this is the change that makes it one. The image is
now a derivation — `pkgs.dockerTools.buildLayeredImage` — so there is no
Dockerfile, no base image, and no container builder in the pipeline at all:

  * `oz/flake.nix` carries the console: a fixed-output derivation for the npm
    tree (`deno install --frozen`, pinned by `outputHash`), the SvelteKit build
    from it offline, and `mkImage variant` over `contents = [ (appTree variant)
    pkgs.deno pkgs.pkgsStatic.busybox ]` with no `fromImage` — the image is those
    store paths and their closure, nothing else. `flake.nix` carries the router
    the same way, over `router/` and the `previewlib/` it reads.
  * `/bin/deno` and `/bin/python` are links into that closure, resolved inside
    the image, so nothing is mounted at runtime and no pod depends on the store
    CI writes into. The variant markers (`/app/PREVIEW-BUILD`,
    `/app/.PROD-BUILD`) are files the image carries, so which console a tag holds
    is a property of its bytes (story 17) — `build-oz` builds `image` on a push
    to main and `imagePreview` on a pull request, and never the other way.
  * Layers are store paths, so a push sends only what is missing. Measured with
    a local zot: of the console's two variants, 13 of 15 layers are shared and 2
    are uploaded; re-pushing the same content skips all 15.
  * `build-oz`/`build-router` are `nix build` + `skopeo copy
    docker-archive:` with `--digestfile`, then compare the digest the registry
    serves for the tag against it. A tag that exists still proves nothing.

The smoke test becomes a derivation, `oz#imageCheck`/`imageCheckPreview` and
`#routerCheck`, and it is stronger than the `buildah run` it replaces: it opens
the image's *own* layers (`manifest.json` + every `layer.tar`), asserts each
marker and each `/bin` entry resolves to a path the image carries — `test -e
root$target` is the difference between "self-contained" and "this machine has
the path" — then serves the extracted `/app` and probes it over loopback. The
Nix sandbox has loopback and a writable `/`, so this needs no daemon and no
privilege; being a derivation, it is cached in the shared store and cannot be
skipped by a slow registry. It runs before the push.

That empties the pipeline's privileged tier: no step declares `privileged`, and
the repository's `security` trust flag is documented as vestigial. Buildah needed
a user namespace and these nodes run with `user.max_user_namespaces = 0`, so the
way out was not a narrower grant but a build that needs none.

What the router costs, stated rather than hidden: python's closure is 218 MiB
where `python:3.13-alpine` is 17 MB compressed, so the image is 88 MiB against
17 MB. That is the price of no base image to keep in step, it is paid once per
python change rather than per push, and it is the smaller part of the console's
260 MiB deno (143 MiB binary unstripped, 116 MiB `libdenort`), documented as a
slimming lever in docs/nix.md §15a rather than taken, because it would break "the
image's deno is the flake's deno".

Both manifests, the preview quota's second PVC slot, the reaper's PV sweep and
the reaper's `persistentvolumes` grant are reverted; the tests say why the grant
must not come back. `.dockerignore` stays: `woodpecker-secret-extension/Dockerfile`
still builds with the repo root as its context.

Stories 19 and 20: 19 is restated as a build (the store is where an image is
built, not where it is run) and 20 is new — an app's image is the whole of what
it runs.

Verified locally: 179 pytest (including the step-commands and CI-variable runs),
kustomize + kubeconform `-strict` + conftest on all three trees, `terraform test`
in storage/, and every derivation built and pushed to a throwaway registry —
`oz#imageCheck`, `oz#imageCheckPreview` and `#routerCheck` all pass, and the check
derivations fail when their assertion is broken (checked by breaking one).

Refs #59
fix(oz): name the console's build after its revision, not the clock (#63)
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
77aa967c9f
SvelteKit stamps `version.name` into `build/client/_app/version.json`, and its
default is `Date.now()` — so `oz#app` was not a function of its source. Two
builds of one tree produced two store paths, `nix build ./oz#app --rebuild`
failed with "may not be deterministic: output differs" (and `oz#image`
transitively), and the layer that file lives in would have been new bytes for
the registry on every push. The deps FOD was already reproducible; the app
build was the one place a clock could get in.

`oz/flake.nix` now passes `OZ_VERSION`, the flake's short revision — `-dirty`
while the tree is dirty, and a literal for a checkout with no revision to read —
and `oz/svelte.config.js` names the deploy with it. That is the same fact a
timestamp carried, with the property story 19 needs: one source, one store path.
It is also what SvelteKit's version name is for, so the client's
reload-after-deploy behaviour is unchanged.

The suite gains `test_the_console_build_is_a_function_of_its_source`: the config
reads the revision from the environment, no executable line of it reaches for
the clock, the flake passes the revision to the app derivation, and the runbook
says so (docs/nix.md §15b). ARCHITECTURE.md's images section carries the
one-line version of the same rule.

A `nix build` run from a checkout also leaves a `result` symlink in the working
tree, and the first draft of this commit picked one up. The root and `oz/`
`.gitignore`s now name `/result` and `/result-*`, so the next one cannot.

Verified: `nix build ./oz#app --rebuild` and `./oz#imagePreview --rebuild` both
agree, `./oz#imageCheck` and `./oz#imageCheckPreview` pass, `pytest tests/ -q`
180 passed, `ruff check .` clean.
Merge branch 'main' into feat/59-utilize-sveltekit-oz
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
27170008d5
`nixos/nix:2.35.2` has nix, a shell, coreutils and curl — and no `sed`. Both
image steps read the pinned tag out of a manifest, and the pull request's
number out of the forge, through `ci/*.sh` shared with the tag guards, L2 and
L3, which run in images that *do* have one. So every run of this change set
since the images became derivations died the same way, and never reached the
registry:

    ci/oz-image.sh: line 36: sed: command not found
    ci/oz-image.sh: line 38: sed: command not found

Exit 127, before an image was built: nothing was pushed, so the tag L3 deploys
never appeared, and the last line of the log was about sed rather than about
the registry the run never reached. The timeout fix in `infrastructure/zot`
was necessary and was not this.

The tool comes from the flake — `packages.gnused`, the same bargain `skopeo`
already makes ("the steps build `.#skopeo` alone rather than entering this
shell, because everything else in it would have to be realised first") — and
each step puts it on its own PATH. `tests/test_static.py` holds the invariant
up: a `nixos/nix` step that runs one of those scripts has to build the output
and put it on PATH, so the next script that reaches for a tool the image does
not carry fails the suite instead of the pipeline. docs/nix.md §14e is the
third case it belongs to, beside "a tool for the suite" and "a tool an image
needs at runtime".
The `test` step's first command was `deno install --frozen && deno task test`.
That install is a hundred-odd lookups at `registry.npmjs.org` fired at once,
and this cluster's DNS chain answers a burst that size with REFUSED or SERVFAIL
often enough to fail the step:

    error: Failed caching npm package '@sveltejs/vite-plugin-svelte@5.1.1'
    ... dns error: failed to lookup address information: Temporary failure in
    name resolution

That is a step with no network in its job failing for the network's reasons, on
the same commit whose earlier run passed it. `oz#test` is a derivation over the
`deps` fixed-output derivation instead: `node_modules` comes from the store, no
install runs, and a suite that passed on a tree is a store hit the next time
that tree is gated. It is symmetric with `imageCheck` — a check is a derivation
— and the console's build now reaches the network in exactly one place (`deps`,
fetched once per lockfile) instead of once per run.

The sandbox is what makes this checkable rather than hopeful: the derivation
builds with no network at all and the suite's 87 tests pass in it, against the
same dependency tree the image is built from. docs/nix.md §15c carries the
reason; tests/test_static.py asserts that the step builds `./oz#test` and that
nothing in it installs the console's dependencies.
fix(oz): retry the console's one network fetch
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
b34e65cd8d
`#deps` is now the only place the console's build reaches the network, which
makes it the only place the DNS failure can still land — and it lands there the
same way it landed in the `test` step: `deno install` resolves the whole
lockfile in one burst, so a resolver that answers REFUSED or SERVFAIL for part
of it fails a build that had done nearly all the work.

The retry is the one `ci/apk-add.sh` already carries for `apk`, and its premise
is stronger here: `--frozen` pins every attempt to the committed lockfile and
the output hash is fixed, so a second attempt can neither move a version nor
produce different bytes. Measured: `nix build ./oz#deps --rebuild` re-runs the
fetch and lands on the same store path.

docs/nix.md §15c says why the fetch retries; tests/test_static.py asserts the
loop is there.
`NIX="nix --extra-experimental-features 'nix-command flakes'"` followed by
`$NIX build …` is not the command it looks like. A variable's value is not
re-parsed, so those quotes are characters in the value: nix read `'nix-command`
as an unknown experimental feature and `flakes'` as the subcommand it had been
told to run. The step dies eight seconds in, with exit 1, before it builds
anything:

    warning: unknown experimental feature ''nix-command'
    error: 'flakes'' is not a recognised command

It has been in both image steps since the images became derivations, and it was
never reached: they died at `sed` first, and every `$NIX` call comes after that.
The `test` step spells the flags out in each command, where the shell *does*
parse the quotes, which is why it never had this problem.

The flag is repeated instead — `--extra-experimental-features nix-command
--extra-experimental-features flakes` — which needs no quoting at all. Checked
in the image the step runs: `$NIX eval`, `$NIX flake metadata ./oz` and
`$NIX build` all parse, where the old assignment reproduced the exact CI error.
docs/nix.md §14d carries the trap, and a new test fails any step holding those
flags in a variable with a quote inside the value.
chore(preview): move the front door's router tag to 0.1.3
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
28f55539e5
`ci/router-tag-guard.sh` was already failing this change set, and it was right:
`router/Dockerfile` is deleted (the image is a derivation now) and
`previewlib/selection.py` moved, while the manifest still pinned 0.1.2. Its
sentence is the whole argument — the Deployment is `imagePullPolicy:
IfNotPresent`, so re-pushing 0.1.2 changes nothing the kubelet will re-pull and
the merged fix would not run.

The guard checks the other direction too, and that is why this is not a
formality: a tag that moves with no source change would never be built at all,
because `build-router` is path-filtered on the source.

Verified against the base ref CI uses: before the bump the guard printed the
pipeline's exact message; after it, "router source and tag agree
(zot.faun-snapper.ts.net/milner/router:0.1.3)", the console's guard still
agrees at 0.1.6, and `ci/router-image.sh` — the one reader the main-push build
goes through — names the same tag.
fix(images): keep the image checks inside their build directory
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
73a2c6bbe4
Both `oz#imageCheck` and `#routerCheck` linked the extracted image's `/app` at
the filesystem root, so the entrypoint could be started through the path the
image's `Cmd` spells. That needs a writable `/`: true inside the Nix sandbox,
false in this cluster, where `nixos/nix` runs with `sandbox = false` and drops
builds to an unprivileged `nixbld` user. The first pipeline that got as far as
the check died on it, twenty-five seconds in:

    ln: failed to create symbolic link '/app': Permission denied

A check that needs privileges to run is a check that stops running. Both checks
now start the entrypoint from their extracted root, and assert what the image
itself spells out of the image's own config file — the `Cmd` a runtime reads,
and for the router the `PYTHONPATH` that goes with it. That is a stronger
assertion than the one it replaces, which exercised a path the check created for
itself rather than the path the image carries.

Consequently the console's check no longer probes `/`. It is where the variants
differ for a reader, but the app reads its marker at the absolute
`/app/PREVIEW-BUILD`: run from anywhere else, both variants behave as the real
console, so the check could not put them in the states that differ. Which
variant these bytes are is decided by the marker assertions against the image's
own layers. That the image and the app name one path is now asserted of both
sides in `tests/test_static.py`, and that the predicate reads a marker which *is*
there is a unit test — the two together are what the `/` probe was standing in
for.

Verified by building both real derivations with `--option sandbox false`, which
is this cluster's configuration: they pass, `HOME=/homeless-shelter` and all
(deno degrades to in-memory caches and says so, as it will in a pod with a
read-only root filesystem).
fix(images): give the pusher the trust policy the step image lacks (#63)
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
792d17f142
`containers/image` refuses to copy an unsigned image unless it finds a
`policy.json`, and nixpkgs' `skopeo` ships none: the first push that got
past the image check died on `Error loading trust policy`, in the step whose
whole job is the upload. `nixos/nix` has no `/etc/containers/policy.json`
and its `$HOME` (`/root`) has no `.config`.

The flake's exported `skopeo` is now a wrapper that passes `--policy`
(a global flag, so before the subcommand) and the same output is what the dev
shell uses, so the steps, the dev shell and a hand push cannot disagree. Both
steps keep their bodies; tests/test_static.py asserts the policy stays with the
tool, and docs/nix.md §14d records the trap.
feat(oz): the front door is a dropdown, at the operator's preference (#63)
Some checks failed
ci/woodpecker/pr/gates Pipeline failed
5f40c8d5eb
The SvelteKit rewrite brought back one submit button per contender with the
serving app inert; `afc6096` had made the pre-SvelteKit console a `<select>`.
The row's Front door control is a dropdown again: `<select name="app">` listing
`auto` and every contender, plus a `Show` press to commit the choice.

Two things it keeps, on purpose. The serving app is the *selected* option, so
"which app is this preview showing" is legible in the control itself (story 8,
which the hint under it also states), and story 7's "one press per app" still
reads true — one option per app, one press of Show to switch. A dropdown's
width also does not change with the number of apps that claim the change set.

No script: the console renders plain HTML and the preview build runs the same
markup. The tag the guard requires moves with the source (`milner/oz:0.1.6` →
`0.1.7`), the CSS rule for the removed `.on` class goes with the buttons, and
the documents that said "buttons" follow: AGENTS.md (twice), ARCHITECTURE.md,
PLAN.md, docs/preview-environment.md and the two comments in `oz/src/lib`.

`tests/test_static.py` now holds the markup — the console's own suite is unit
tests over `src/lib`, so nothing else could have caught this control going
missing — and mutation-checked: buttons back, the press removed, and the `.on`
rule restored each fail it.
fix(ci): L3 waits out the reconcile it asked for (#63)
All checks were successful
ci/woodpecker/pr/gates Pipeline was successful
ci/woodpecker/pull_request_closed/cleanup Pipeline was successful
d73c296426
Flux copies `reconcile.fluxcd.io/requestedAt` into
`status.lastHandledReconcileAt` in kustomize-controller's deferred
`finalizeStatus`, so the echo lands when the reconcile *ends* — after
`wait: true` has run its health check, which the same Kustomization allows ten
minutes for. The step waited five.

Pipeline #39 failed on exactly that difference: the change set's oz rollout
spent 3m33s pulling its image (118 MB, on a node that did not have the layers),
the router's surge pod sat in `preview-quota` until that rollout finished, and
Flux echoed the stamp 35 seconds after the step gave up — on a preview that was
Ready and serving a moment later.

One literal now feeds both numbers: `FLUX_TIMEOUT` is the Kustomization's
`timeout` and the base of the wait's deadline, plus a margin for the controller
to pick the request up. `tests/test_static.py` holds them together and rejects
the fixed five-second poll count that failed, mutation-checked both ways.
tepichord deleted branch feat/59-utilize-sveltekit-oz 2026-09-20 17:05:16 -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!63
No description provided.