Utilize SvelteKit for Oz #59

Closed
opened 2026-09-15 09:29:49 -07:00 by tepichord · 3 comments
Owner
  • Create a Next.js application to replace Oz's current python implementation
  • Maintain existing functionality
  • Restyle existing components using shadcn/radix
  • Docker image builds to zot for preview deployment (Use nix as the base image and then install dependencies with an oz specific flake)
- Create a Next.js application to replace Oz's current python implementation - Maintain existing functionality - Restyle existing components using shadcn/radix - Docker image builds to zot for preview deployment (Use nix as the base image and then install dependencies with an oz specific flake)
tepichord added this to the MVP project 2026-09-15 09:29:49 -07:00
Author
Owner

Naina:
Updated on 2026-09-15 based on maintainer feedback.

Overview

Rewrite oz/ as a SvelteKit 2.x app (@sveltejs/kit 2.70.3 + @sveltejs/adapter-node 5.5.7, Svelte 5, bits-ui 2.19.2) preserving OIDC login, the preview table, the three actions, CSRF, preview-mode simulation, and the no-delete-verb boundary. SvelteKit replaces the Astro plan: Astro's shadcn islands pulled in React, and the maintainer selected SvelteKit's leaner runtime under the 256Mi limit. router/ and previewlib/k8s.py stay Python; the image stays a two-stage Nix build that bakes /app/PREVIEW-BUILD from --build-arg OZ_PREVIEW=1.

Technical Approach

  1. Add oz/flake.nix + oz/flake.lock; add nodejs_22 to the root dev shell.

    {
      inputs.nixpkgs.url = "github:NixOS/nixpkgs/e5bdc4a41d4c072fe1e3787eaa0320a384741d44"; # same rev as root
      outputs = { self, nixpkgs }:
        let systems = [ "aarch64-linux" "x86_64-linux" ];
            forAll = f: nixpkgs.lib.genAttrs systems (s: f (import nixpkgs { system = s; }));
        in {
          devShells = forAll (pkgs: { default = pkgs.mkShell { buildInputs = [ pkgs.nodejs_22 ]; }; });
          packages  = forAll (pkgs: { node = pkgs.nodejs_22; });
        };
    }
    

    Root flake.nix: add nodejs_22 to buildInputs (22.23.2 at the pinned rev4).

  2. Scaffold SvelteKit in place, then add the runtime deps.

    npx sv create /tmp/oz-scaffold --template minimal --types ts --no-add-ons --install npm
    cp -R /tmp/oz-scaffold/. oz/ && rm -rf /tmp/oz-scaffold
    nix develop ./oz --command bash -lc '
      cd oz && npm ci
      npm i -D @sveltejs/adapter-node@5.5.7 vitest svelte-check
      npm i openid-client@^6 jose@^6 undici bits-ui@2.19.2'
    
  3. oz/svelte.config.js + oz/vite.config.ts. adapter-node is standalone; build/index.js is the entry point and reads HOST/PORT (never HOSTNAME)99b.

    // svelte.config.js
    import adapter from '@sveltejs/adapter-node';
    import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
    export default {
      preprocess: vitePreprocess(),
      kit: { adapter: adapter({ out: 'build', precompress: true }) }
    };
    
    // vite.config.ts
    import { sveltekit } from '@sveltejs/kit/vite';
    import { defineConfig } from 'vitest/config';
    export default defineConfig({ plugins: [sveltekit()], test: { environment: 'node' } });
    
  4. Port the Kubernetes client to oz/src/lib/k8s.ts — same surface as previewlib/k8s.py, same injected-transport seam (the Python transport takes a fifth timeout argument11b).

    export type Transport = (
      method: string, url: string, headers: Record<string, string>, body?: Uint8Array, timeout?: number
    ) => Promise<[number, Record<string, string>, Uint8Array]>;
    
    listNamespaces(selector = "milner.dev/preview-pr")
    getConfigMap("preview-system", "preview-config")
    patchNamespace(name, patch)   // merge-patch only; no DELETE is ever issued
    

    Default transport: undici Agent({ connect: { ca: readFileSync(${SA_DIR}/ca.crt) } }) — Node's global fetch has no per-request ca.

  5. Port the pure preview logic to oz/src/lib/preview.ts — labels and patch shapes unchanged from oz/cluster.py22b.

    const ACTIVE = "milner.dev/preview-active";
    const EXPIRES = "milner.dev/preview-expires";
    validatePr("61");                    // ^[0-9]{1,9}$ only
    activationPatch(target, ttl, now);   // { metadata: { labels: { [ACTIVE]: "true" }, annotations: { [EXPIRES]: ts } } }
    deletePatch(now);                    // { metadata: { annotations: { [EXPIRES]: String(now) } } } — the reaper collects
    

    Port summarise, previewState, mergeRows, describeExpiry, validateBranch, and PreviewStore (TTL from preview-config).

  6. Port auth to oz/src/lib/auth.ts — openid-client v6 authorization-code + PKCE, jose JWKS verify, node:crypto HMAC10.

    const config = await discovery(new URL(issuer), clientId, clientSecret);
    config[client.customFetch] = fetch;   // v6 override is the symbol, not a 5th discovery() arg
    const url = buildAuthorizationUrl(config, { redirect_uri, scope: "openid profile email groups",
      code_challenge, code_challenge_method: "S256", state, nonce });
    const tokens = await authorizationCodeGrant(config, new URL(callbackUrl), { pkceCodeVerifier, expectedState: state });
    const { payload } = await jwtVerify(tokens.id_token!, createRemoteJWKSet(new URL(jwksUri)), { issuer, audience: clientId });
    

    Keep the payload.signature HMAC-SHA256 cookie (oz_session / oz_login), allowed(), and csrfMatches() via timingSafeEqual. oz_login sets sameSite: "lax" (cross-site OAuth redirect).

  7. Route handlers (explicit CSRF; no form actions). +server.ts keeps the existing URL table; the UI posts form-encoded forms like the Python console33b.

    src/routes/healthz/+server.ts            GET  -> text("ok")
    src/routes/+page.server.ts               load -> requireAdmin() else 302 /login; rows + csrf
    src/routes/login/+server.ts              GET  -> set oz_login (maxAge 600) -> 302 authorization URL
    src/routes/callback/+server.ts           GET  -> state + PKCE + ID token + groups -> set oz_session
    src/routes/logout/+server.ts             POST -> clear oz_session, 303 /login
    src/routes/api/{activate,delete,build}/+server.ts  POST + csrfMatches() -> 403 on mismatch
    
  8. oz/src/hooks.server.ts — one handle gates everything and populates event.locals.user.

    const PUBLIC = new Set(["/healthz", "/login", "/callback"]);
    export const handle: Handle = async ({ event, resolve }) => {
      if (isPreviewBuild()) return resolve(event);          // fixtures need no auth
      if (PUBLIC.has(event.url.pathname)) return resolve(event);
      const user = verifySession(event.cookies.get(SESSION_COOKIE));
      if (!user) return redirect(302, "/login");
      if (!allowed(user.groups, requiredGroup)) return new Response(null, { status: 403 });
      event.locals.user = user;
      return resolve(event);
    };
    
  9. Preview mode oz/src/lib/preview-mode.ts — same marker, fixtures, and state machine as oz/app.py; never an environment variable88b.

    export const isPreviewBuild = (marker = "/app/PREVIEW-BUILD") => existsSync(marker);
    const simulated = simulatedState();   // module singleton, like the Python server's
    

    Port previewFixtureRows, applySimulated, simulatedRows; actions return redirect(303, "/?preview=<action>&pr=<n>") and log one line.

  10. UI: oz/src/routes/+page.svelte + oz/src/app.css. SSR table; bits-ui Button/AlertDialog for the interactive primitives; table/badge markup hand-styled (bits-ui ships no Table/Badge/Card).

    <Button type="submit">Build</Button>
    <AlertDialog.Root>
      <AlertDialog.Trigger>Delete</AlertDialog.Trigger>
      <AlertDialog.Content>
        <form method="POST" action="/api/delete">
          <input type="hidden" name="csrf" value={data.csrf} />
        </form>
      </AlertDialog.Content>
    </AlertDialog.Root>
    

    app.css carries the shadcn-svelte :root/.dark variable block plus rules on [data-button-root], [data-alert-dialog-content], etc. — no Tailwind.

  11. Rewrite oz/Dockerfile as a two-stage Nix build.

    ARG NIX_IMAGE=nixos/nix:2.28.3
    FROM ${NIX_IMAGE} AS builder
    RUN printf 'sandbox = false\nexperimental-features = nix-command flakes\n' >> /etc/nix/nix.conf
    WORKDIR /src
    COPY oz/ ./oz/
    RUN nix develop ./oz#default --command bash -lc 'cd oz && npm ci && npm run build && npm prune --omit=dev'
    RUN nix build ./oz#node --out-link /tmp/node && mkdir -p /tmp/closure && cp -a $(nix-store -qR /tmp/node) /tmp/closure/
    
    FROM ${NIX_IMAGE}
    COPY --from=builder /tmp/closure/ /nix/store/     # nixos/nix ships no node
    COPY --from=builder /tmp/node /tmp/node
    RUN ln -s "$(readlink -f /tmp/node)/bin/node" /usr/local/bin/node
    COPY --from=builder /src/oz/build/ /app/build/
    COPY --from=builder /src/oz/node_modules/ /app/node_modules/
    COPY --from=builder /src/oz/package.json /app/package.json
    ARG OZ_PREVIEW=0
    RUN if [ "${OZ_PREVIEW}" = "1" ]; then touch /app/PREVIEW-BUILD; fi
    ENV NODE_ENV=production HOST=0.0.0.0 PORT=8080
    USER 65535:65535
    EXPOSE 8080
    ENTRYPOINT ["node", "/app/build/index.js"]
    

    adapter-node's output is not self-contained: build/index.js + build/client/** + build/server/** + production node_modules.

  12. Update both Deployments (gitops/clusters/milner/preview-system/oz.yaml, apps/preview/oz.yaml).

    env:
      - { name: HOST, value: "0.0.0.0" }
      - { name: PORT, value: "8080" }
      - { name: ORIGIN, value: "https://oz.faun-snapper.ts.net" }   # preview: https://preview.faun-snapper.ts.net
      - { name: NODE_OPTIONS, value: "--max-old-space-size=192" }
    

    Drop OZ_PORT, PYTHONDONTWRITEBYTECODE, PYTHONUNBUFFERED55b. Keep the /tmp emptyDir, OZ_* config, envFrom: oz-secrets, probes and limits.

  13. Update the pipeline and static guards (.woodpecker/gates.yaml, tests/test_static.py).

    # l3-build-oz: replace `python -c "import oz.app …"`; the mounted-marker check stays
    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))"'
    
    # test step
    apk add --no-cache python3 py3-pytest py3-yaml nodejs npm
    (cd oz && npm ci && npm test)
    python3 -m pytest tests/ -v
    

    test_static.py: retarget the oz/app.py assertions to oz/src/lib/preview-mode.ts, replace env["PYTHONUNBUFFERED"] == "1" with env["PORT"] == "8080", replace the oz.app.PREVIEW_BUILD smoke assertion with a PREVIEW-BUILD grep plus the /healthz smoke. Delete tests/test_preview_console.py (its spec moves to Vitest).

  14. Docs — PLAN.md Phase 7 (oz is SvelteKit, router/ stays Python) and the AGENTS.md repo-map row for oz/.

Implementation Details

Path Action
oz/{package.json,package-lock.json,svelte.config.js,vite.config.ts,tsconfig.json} create
oz/flake.nix, oz/flake.lock create (subflake, root nixpkgs rev)
oz/src/routes/**, oz/src/lib/**, oz/src/hooks.server.ts, oz/src/app.css, oz/src/app.d.ts create
oz/src/**/*.test.ts create
oz/Dockerfile rewrite (Nix two-stage; build/ + prod node_modules; marker preserved)
.dockerignore create (exclude oz/node_modules, oz/build, oz/.svelte-kit)
oz/{app,auth,cluster,forge,__main__,__init__}.py delete
flake.nix add nodejs_22
gitops/clusters/milner/preview-system/oz.yaml, apps/preview/oz.yaml env swap (HOST/PORT/ORIGIN/NODE_OPTIONS)
.woodpecker/gates.yaml smoke test, test step66b
tests/test_static.py retarget oz/app.py guards77b
tests/test_preview_console.py delete (ported to Vitest)7c
PLAN.md, AGENTS.md docs

Dependencies. Runtime: @sveltejs/kit@2.70.3, @sveltejs/adapter-node@5.5.7, svelte@5, bits-ui@2.19.2, openid-client@^6, jose@^6, undici. Dev: vite, vitest, typescript, svelte-check.

Constraints. arm64 only; buildah vfs + --isolation=chroot with sandbox = false; runAsUser: 65535, readOnlyRootFilesystem, all caps dropped, no privilege escalation; 50m–300m CPU, 64Mi–256Mi memory; images pinned by tag (policy/k8s.rego); one Ingress per hostname (unchanged); the console still holds namespaces get/list/watch/patch and no delete verb.

Out-of-scope notes. router/ and previewlib/k8s.py unchanged. No registry/retention, hostname, Vault secret or ci/preview-pr.sh changes. Preview mode gains no new behaviour. Flagged risk: the undici Agent({ connect: { ca } }) in-cluster TLS path is runtime-agnostic — if bundling does not preserve it, fall back to NODE_EXTRA_CA_CERTS, then tls.setDefaultCACertificates.

Acceptance Criteria

  • Logging in at oz.faun-snapper.ts.net as a cluster_admins member renders the PR/preview table; a non-member gets 403 at /callback
  • Activate leaves exactly one preview with milner.dev/preview-active=true and extends its expiry; Delete patches the expiry to now; Build triggers Woodpecker with PREVIEW_PR; no DELETE handler or request exists in the code
  • Every POST without a matching session CSRF token returns 403; /healthz returns 200 without a session
  • --build-arg OZ_PREVIEW=1 yields /app/PREVIEW-BUILD; that image renders fixtures, logs each action and redirects, needs no credential, and no pod-spec value can toggle the mode
  • l3-build-oz builds, pushes, verifies and smoke-tests the SvelteKit image, and L3 reaches Kustomization Ready with the image's root filesystem read-only
  • The pod's measured RSS stays under 256Mi under the smoke/load flow, or the Deployment memory limit is raised in this same change
  • Interactive primitives use bits-ui; only the table/badge markup (which bits-ui does not provide) is hand-styled via app.css
  • cd oz && npm test and python3 -m pytest tests/ pass

Testing Plan

  • Vitest unit: session sign/verify/tamper/expiry, csrfMatches, allowed, PKCE S256, ID-token iss/aud/nonce/exp rejection against a fixture JWKS; validatePr/validateBranch, activationPatch/deletePatch, summarise, describeExpiry, mergeRows orphans; openPulls and trigger through a fake transport; preview fixtures and simulated transitions.
  • Route tests (hand-built RequestEvent + injected transport): /healthz → 200; / unauthenticated → 302 /login; non-admin → 403; bad CSRF → 403; preview mode → fixture rows + log line + 303 redirect.
  • Static: updated tests/test_static.py passes; tests/test_preview_console.py removed.
  • Pipeline: L2 dry-run accepts the manifest change; l3-build-oz smoke passes; the L3 preview pod becomes Ready and serves the fixture console.
  • Manual: activate a preview from the new console and confirm the router serves it at preview.faun-snapper.ts.net.
  • Issue #59 — this issue
  • Issue #49 — the four-gate pipeline whose l3-build-oz smoke test this plan edits
  • router/ + previewlib/k8s.py — shared Python, unchanged by this issue
  • PLAN.md Phase 7 — the preview environment this console serves

Research Findings

  • adapter-node reads HOST/PORT (never HOSTNAME) and needs ORIGIN behind the ingress, or legitimate form posts fail with Cross-site POST form submissions are forbidden.
  • SvelteKit's kit.csrf.checkOrigin only guards form content types and is bypassed by application/json, so the explicit per-session token check must stay.
  • bits-ui 2.x ships Button and AlertDialog but no Table, Badge or Card, so that markup is hand-styled with app.css and data-* rules.
  • Node's fetch has no per-request ca; the supported in-cluster path is undici Agent({ connect: { ca } }) via the dispatcher option (undici #2200 remains open).
  • openid-client v6 sets a custom fetch via the exported customFetch symbol on the Configuration, and jose v6 verifies the ID token against a remote JWKS with no dependencies.
  • No authoritative SvelteKit-vs-Next.js RSS benchmark exists, so the 256Mi claim stays a measured acceptance criterion rather than an asserted property of the framework.

Pre-Mortem

  1. The 256Mi memory limit is still breached and preview pods OOMKill again — The plan caps the V8 heap at 192MB but leaves limits.memory: 256Mi unchanged, while adapter-node plus openid-client, jose, undici and bits-ui can push RSS past the limit under sustained load. Because "raise the limit in this same change" is stated as an acceptance criterion but no manifest edit implements it, the memory pivot that motivated this rewrite can simply repeat.
  2. The two-stage Nix Dockerfile does not produce a runnable image — nix build ./oz#node, the nix-store -qR closure copy into /nix/store/, and the symlinked node binary are fragile, and npm ci needs network access under sandbox = false. The plan itself leaves "decide and document" the runtime Node closure unresolved, so a build-stage failure or a broken ENTRYPOINT is likely.
  3. The TypeScript port breaks in-cluster behaviour that unit tests do not cover — The undici Agent({ connect: { ca } }) path is flagged as unsettled (undici #2200 is open), and a missing or mis-set ORIGIN makes every legitimate form POST fail SvelteKit's CSRF check. Either failure yields a console that builds and passes tests but cannot list previews or submit actions in the cluster.
Naina: *Updated on 2026-09-15 based on maintainer feedback.* ## Overview Rewrite `oz/` as a **SvelteKit 2.x** app (`@sveltejs/kit` 2.70.3 + `@sveltejs/adapter-node` 5.5.7, Svelte 5, bits-ui 2.19.2) preserving OIDC login, the preview table, the three actions, CSRF, preview-mode simulation, and the no-delete-verb boundary. SvelteKit replaces the Astro plan: Astro's shadcn islands pulled in React, and the maintainer selected SvelteKit's leaner runtime under the 256Mi limit. `router/` and `previewlib/k8s.py` stay Python; the image stays a two-stage Nix build that bakes `/app/PREVIEW-BUILD` from `--build-arg OZ_PREVIEW=1`. ## Technical Approach 1. **Add `oz/flake.nix` + `oz/flake.lock`; add `nodejs_22` to the root dev shell.** ```nix { inputs.nixpkgs.url = "github:NixOS/nixpkgs/e5bdc4a41d4c072fe1e3787eaa0320a384741d44"; # same rev as root outputs = { self, nixpkgs }: let systems = [ "aarch64-linux" "x86_64-linux" ]; forAll = f: nixpkgs.lib.genAttrs systems (s: f (import nixpkgs { system = s; })); in { devShells = forAll (pkgs: { default = pkgs.mkShell { buildInputs = [ pkgs.nodejs_22 ]; }; }); packages = forAll (pkgs: { node = pkgs.nodejs_22; }); }; } ``` Root `flake.nix`: add `nodejs_22` to `buildInputs` (22.23.2 at the pinned rev<sup>[4](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/flake.nix?display=source#L5)</sup>). 2. **Scaffold SvelteKit in place, then add the runtime deps.** ```sh npx sv create /tmp/oz-scaffold --template minimal --types ts --no-add-ons --install npm cp -R /tmp/oz-scaffold/. oz/ && rm -rf /tmp/oz-scaffold nix develop ./oz --command bash -lc ' cd oz && npm ci npm i -D @sveltejs/adapter-node@5.5.7 vitest svelte-check npm i openid-client@^6 jose@^6 undici bits-ui@2.19.2' ``` 3. **`oz/svelte.config.js` + `oz/vite.config.ts`.** adapter-node is standalone; `build/index.js` is the entry point and reads `HOST`/`PORT` (never `HOSTNAME`)<sup>[9](https://svelte.dev/docs/kit/adapter-node#Environment-variables-PORT-HOST-and-SOCKET_PATH)</sup><sup>[9b](https://svelte.dev/docs/kit/adapter-node#Environment-variables-ORIGIN-PROTOCOL_HEADER-HOST_HEADER-and-PORT_HEADER)</sup>. ```js // svelte.config.js import adapter from '@sveltejs/adapter-node'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; export default { preprocess: vitePreprocess(), kit: { adapter: adapter({ out: 'build', precompress: true }) } }; ``` ```ts // vite.config.ts import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [sveltekit()], test: { environment: 'node' } }); ``` 4. **Port the Kubernetes client to `oz/src/lib/k8s.ts`** — same surface as `previewlib/k8s.py`, same injected-transport seam (the Python transport takes a fifth `timeout` argument<sup>[1](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/previewlib/k8s.py?display=source#L71)</sup><sup>[1b](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/previewlib/k8s.py?display=source#L32)</sup>). ```ts export type Transport = ( method: string, url: string, headers: Record<string, string>, body?: Uint8Array, timeout?: number ) => Promise<[number, Record<string, string>, Uint8Array]>; listNamespaces(selector = "milner.dev/preview-pr") getConfigMap("preview-system", "preview-config") patchNamespace(name, patch) // merge-patch only; no DELETE is ever issued ``` Default transport: `undici` `Agent({ connect: { ca: readFileSync(`${SA_DIR}/ca.crt`) } })` — Node's global `fetch` has no per-request `ca`. 5. **Port the pure preview logic to `oz/src/lib/preview.ts`** — labels and patch shapes unchanged from `oz/cluster.py`<sup>[2](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/oz/cluster.py?display=source#L16)</sup><sup>[2b](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/oz/cluster.py?display=source#L89)</sup>. ```ts const ACTIVE = "milner.dev/preview-active"; const EXPIRES = "milner.dev/preview-expires"; validatePr("61"); // ^[0-9]{1,9}$ only activationPatch(target, ttl, now); // { metadata: { labels: { [ACTIVE]: "true" }, annotations: { [EXPIRES]: ts } } } deletePatch(now); // { metadata: { annotations: { [EXPIRES]: String(now) } } } — the reaper collects ``` Port `summarise`, `previewState`, `mergeRows`, `describeExpiry`, `validateBranch`, and `PreviewStore` (TTL from `preview-config`). 6. **Port auth to `oz/src/lib/auth.ts`** — `openid-client` v6 authorization-code + PKCE, `jose` JWKS verify, `node:crypto` HMAC<sup>[10](https://unpkg.com/openid-client@6.0.0/build/index.d.ts)</sup>. ```ts const config = await discovery(new URL(issuer), clientId, clientSecret); config[client.customFetch] = fetch; // v6 override is the symbol, not a 5th discovery() arg const url = buildAuthorizationUrl(config, { redirect_uri, scope: "openid profile email groups", code_challenge, code_challenge_method: "S256", state, nonce }); const tokens = await authorizationCodeGrant(config, new URL(callbackUrl), { pkceCodeVerifier, expectedState: state }); const { payload } = await jwtVerify(tokens.id_token!, createRemoteJWKSet(new URL(jwksUri)), { issuer, audience: clientId }); ``` Keep the `payload.signature` HMAC-SHA256 cookie (`oz_session` / `oz_login`), `allowed()`, and `csrfMatches()` via `timingSafeEqual`. `oz_login` sets `sameSite: "lax"` (cross-site OAuth redirect). 7. **Route handlers (explicit CSRF; no form actions).** `+server.ts` keeps the existing URL table; the UI posts form-encoded forms like the Python console<sup>[3](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/oz/app.py?display=source#L411)</sup><sup>[3b](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/oz/auth.py?display=source#L109)</sup>. ``` src/routes/healthz/+server.ts GET -> text("ok") src/routes/+page.server.ts load -> requireAdmin() else 302 /login; rows + csrf src/routes/login/+server.ts GET -> set oz_login (maxAge 600) -> 302 authorization URL src/routes/callback/+server.ts GET -> state + PKCE + ID token + groups -> set oz_session src/routes/logout/+server.ts POST -> clear oz_session, 303 /login src/routes/api/{activate,delete,build}/+server.ts POST + csrfMatches() -> 403 on mismatch ``` 8. **`oz/src/hooks.server.ts`** — one `handle` gates everything and populates `event.locals.user`. ```ts const PUBLIC = new Set(["/healthz", "/login", "/callback"]); export const handle: Handle = async ({ event, resolve }) => { if (isPreviewBuild()) return resolve(event); // fixtures need no auth if (PUBLIC.has(event.url.pathname)) return resolve(event); const user = verifySession(event.cookies.get(SESSION_COOKIE)); if (!user) return redirect(302, "/login"); if (!allowed(user.groups, requiredGroup)) return new Response(null, { status: 403 }); event.locals.user = user; return resolve(event); }; ``` 9. **Preview mode `oz/src/lib/preview-mode.ts`** — same marker, fixtures, and state machine as `oz/app.py`; never an environment variable<sup>[8](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/oz/app.py?display=source#L46)</sup><sup>[8b](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/oz/app.py?display=source#L632)</sup>. ```ts export const isPreviewBuild = (marker = "/app/PREVIEW-BUILD") => existsSync(marker); const simulated = simulatedState(); // module singleton, like the Python server's ``` Port `previewFixtureRows`, `applySimulated`, `simulatedRows`; actions return `redirect(303, "/?preview=<action>&pr=<n>")` and log one line. 10. **UI: `oz/src/routes/+page.svelte` + `oz/src/app.css`.** SSR table; bits-ui `Button`/`AlertDialog` for the interactive primitives; table/badge markup hand-styled (bits-ui ships no Table/Badge/Card). ```svelte <Button type="submit">Build</Button> <AlertDialog.Root> <AlertDialog.Trigger>Delete</AlertDialog.Trigger> <AlertDialog.Content> <form method="POST" action="/api/delete"> <input type="hidden" name="csrf" value={data.csrf} /> </form> </AlertDialog.Content> </AlertDialog.Root> ``` `app.css` carries the shadcn-svelte `:root`/`.dark` variable block plus rules on `[data-button-root]`, `[data-alert-dialog-content]`, etc. — no Tailwind. 11. **Rewrite `oz/Dockerfile` as a two-stage Nix build.** ```dockerfile ARG NIX_IMAGE=nixos/nix:2.28.3 FROM ${NIX_IMAGE} AS builder RUN printf 'sandbox = false\nexperimental-features = nix-command flakes\n' >> /etc/nix/nix.conf WORKDIR /src COPY oz/ ./oz/ RUN nix develop ./oz#default --command bash -lc 'cd oz && npm ci && npm run build && npm prune --omit=dev' RUN nix build ./oz#node --out-link /tmp/node && mkdir -p /tmp/closure && cp -a $(nix-store -qR /tmp/node) /tmp/closure/ FROM ${NIX_IMAGE} COPY --from=builder /tmp/closure/ /nix/store/ # nixos/nix ships no node COPY --from=builder /tmp/node /tmp/node RUN ln -s "$(readlink -f /tmp/node)/bin/node" /usr/local/bin/node COPY --from=builder /src/oz/build/ /app/build/ COPY --from=builder /src/oz/node_modules/ /app/node_modules/ COPY --from=builder /src/oz/package.json /app/package.json ARG OZ_PREVIEW=0 RUN if [ "${OZ_PREVIEW}" = "1" ]; then touch /app/PREVIEW-BUILD; fi ENV NODE_ENV=production HOST=0.0.0.0 PORT=8080 USER 65535:65535 EXPOSE 8080 ENTRYPOINT ["node", "/app/build/index.js"] ``` adapter-node's output is **not** self-contained: `build/index.js` + `build/client/**` + `build/server/**` + production `node_modules`. 12. **Update both Deployments** (`gitops/clusters/milner/preview-system/oz.yaml`, `apps/preview/oz.yaml`). ```yaml env: - { name: HOST, value: "0.0.0.0" } - { name: PORT, value: "8080" } - { name: ORIGIN, value: "https://oz.faun-snapper.ts.net" } # preview: https://preview.faun-snapper.ts.net - { name: NODE_OPTIONS, value: "--max-old-space-size=192" } ``` Drop `OZ_PORT`, `PYTHONDONTWRITEBYTECODE`, `PYTHONUNBUFFERED`<sup>[5](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/gitops/clusters/milner/preview-system/oz.yaml?display=source#L52)</sup><sup>[5b](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/apps/preview/oz.yaml?display=source#L74)</sup>. Keep the `/tmp` emptyDir, `OZ_*` config, `envFrom: oz-secrets`, probes and limits. 13. **Update the pipeline and static guards** (`.woodpecker/gates.yaml`, `tests/test_static.py`). ```sh # l3-build-oz: replace `python -c "import oz.app …"`; the mounted-marker check stays 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))"' # test step apk add --no-cache python3 py3-pytest py3-yaml nodejs npm (cd oz && npm ci && npm test) python3 -m pytest tests/ -v ``` `test_static.py`: retarget the `oz/app.py` assertions to `oz/src/lib/preview-mode.ts`, replace `env["PYTHONUNBUFFERED"] == "1"` with `env["PORT"] == "8080"`, replace the `oz.app.PREVIEW_BUILD` smoke assertion with a `PREVIEW-BUILD` grep plus the `/healthz` smoke. Delete `tests/test_preview_console.py` (its spec moves to Vitest). 14. **Docs** — PLAN.md Phase 7 (`oz` is SvelteKit, `router/` stays Python) and the AGENTS.md repo-map row for `oz/`. ## Implementation Details | Path | Action | |---|---| | `oz/{package.json,package-lock.json,svelte.config.js,vite.config.ts,tsconfig.json}` | create | | `oz/flake.nix`, `oz/flake.lock` | create (subflake, root nixpkgs rev) | | `oz/src/routes/**`, `oz/src/lib/**`, `oz/src/hooks.server.ts`, `oz/src/app.css`, `oz/src/app.d.ts` | create | | `oz/src/**/*.test.ts` | create | | `oz/Dockerfile` | rewrite (Nix two-stage; `build/` + prod `node_modules`; marker preserved) | | `.dockerignore` | create (exclude `oz/node_modules`, `oz/build`, `oz/.svelte-kit`) | | `oz/{app,auth,cluster,forge,__main__,__init__}.py` | delete | | `flake.nix` | add `nodejs_22` | | `gitops/clusters/milner/preview-system/oz.yaml`, `apps/preview/oz.yaml` | env swap (`HOST`/`PORT`/`ORIGIN`/`NODE_OPTIONS`) | | `.woodpecker/gates.yaml` | smoke test, `test` step<sup>[6](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/.woodpecker/gates.yaml?display=source#L293)</sup><sup>[6b](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/.woodpecker/gates.yaml?display=source#L470)</sup> | | `tests/test_static.py` | retarget `oz/app.py` guards<sup>[7](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/tests/test_static.py?display=source#L1530)</sup><sup>[7b](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/tests/test_static.py?display=source#L1644)</sup> | | `tests/test_preview_console.py` | delete (ported to Vitest)<sup>[7c](https://git.gloom.garden/tepichord/milner/src/commit/47516af08493322e15525030d71b26508ff0867a/tests/test_preview_console.py?display=source#L1)</sup> | | `PLAN.md`, `AGENTS.md` | docs | **Dependencies.** Runtime: `@sveltejs/kit@2.70.3`, `@sveltejs/adapter-node@5.5.7`, `svelte@5`, `bits-ui@2.19.2`, `openid-client@^6`, `jose@^6`, `undici`. Dev: `vite`, `vitest`, `typescript`, `svelte-check`. **Constraints.** arm64 only; buildah `vfs` + `--isolation=chroot` with `sandbox = false`; `runAsUser: 65535`, `readOnlyRootFilesystem`, all caps dropped, no privilege escalation; 50m–300m CPU, 64Mi–256Mi memory; images pinned by tag (`policy/k8s.rego`); one Ingress per hostname (unchanged); the console still holds `namespaces get/list/watch/patch` and no delete verb. **Out-of-scope notes.** `router/` and `previewlib/k8s.py` unchanged. No registry/retention, hostname, Vault secret or `ci/preview-pr.sh` changes. Preview mode gains no new behaviour. **Flagged risk:** the undici `Agent({ connect: { ca } })` in-cluster TLS path is runtime-agnostic — if bundling does not preserve it, fall back to `NODE_EXTRA_CA_CERTS`, then `tls.setDefaultCACertificates`. ## Acceptance Criteria - [ ] Logging in at `oz.faun-snapper.ts.net` as a `cluster_admins` member renders the PR/preview table; a non-member gets 403 at `/callback` - [ ] Activate leaves exactly one preview with `milner.dev/preview-active=true` and extends its expiry; Delete patches the expiry to now; Build triggers Woodpecker with `PREVIEW_PR`; no `DELETE` handler or request exists in the code - [ ] Every POST without a matching session CSRF token returns 403; `/healthz` returns 200 without a session - [ ] `--build-arg OZ_PREVIEW=1` yields `/app/PREVIEW-BUILD`; that image renders fixtures, logs each action and redirects, needs no credential, and no pod-spec value can toggle the mode - [ ] `l3-build-oz` builds, pushes, verifies and smoke-tests the SvelteKit image, and L3 reaches Kustomization Ready with the image's root filesystem read-only - [ ] The pod's measured RSS stays under 256Mi under the smoke/load flow, **or** the Deployment memory limit is raised in this same change - [ ] Interactive primitives use bits-ui; only the table/badge markup (which bits-ui does not provide) is hand-styled via `app.css` - [ ] `cd oz && npm test` and `python3 -m pytest tests/` pass ## Testing Plan - Vitest unit: session sign/verify/tamper/expiry, `csrfMatches`, `allowed`, PKCE S256, ID-token `iss`/`aud`/`nonce`/`exp` rejection against a fixture JWKS; `validatePr`/`validateBranch`, `activationPatch`/`deletePatch`, `summarise`, `describeExpiry`, `mergeRows` orphans; `openPulls` and `trigger` through a fake transport; preview fixtures and simulated transitions. - Route tests (hand-built `RequestEvent` + injected transport): `/healthz` → 200; `/` unauthenticated → 302 `/login`; non-admin → 403; bad CSRF → 403; preview mode → fixture rows + log line + 303 redirect. - Static: updated `tests/test_static.py` passes; `tests/test_preview_console.py` removed. - Pipeline: L2 dry-run accepts the manifest change; `l3-build-oz` smoke passes; the L3 preview pod becomes Ready and serves the fixture console. - Manual: activate a preview from the new console and confirm the router serves it at `preview.faun-snapper.ts.net`. ## Related Issues/PRs - Issue #59 — this issue - Issue #49 — the four-gate pipeline whose `l3-build-oz` smoke test this plan edits - `router/` + `previewlib/k8s.py` — shared Python, unchanged by this issue - PLAN.md Phase 7 — the preview environment this console serves ## Research Findings - `adapter-node` reads `HOST`/`PORT` (never `HOSTNAME`) and needs `ORIGIN` behind the ingress, or legitimate form posts fail with `Cross-site POST form submissions are forbidden`. - SvelteKit's `kit.csrf.checkOrigin` only guards form content types and is bypassed by `application/json`, so the explicit per-session token check must stay. - bits-ui 2.x ships `Button` and `AlertDialog` but no Table, Badge or Card, so that markup is hand-styled with `app.css` and `data-*` rules. - Node's `fetch` has no per-request `ca`; the supported in-cluster path is `undici` `Agent({ connect: { ca } })` via the `dispatcher` option (undici #2200 remains open). - `openid-client` v6 sets a custom fetch via the exported `customFetch` symbol on the `Configuration`, and `jose` v6 verifies the ID token against a remote JWKS with no dependencies. - No authoritative SvelteKit-vs-Next.js RSS benchmark exists, so the 256Mi claim stays a measured acceptance criterion rather than an asserted property of the framework. ## Pre-Mortem 1. **The 256Mi memory limit is still breached and preview pods OOMKill again** — The plan caps the V8 heap at 192MB but leaves `limits.memory: 256Mi` unchanged, while adapter-node plus `openid-client`, `jose`, `undici` and `bits-ui` can push RSS past the limit under sustained load. Because "raise the limit in this same change" is stated as an acceptance criterion but no manifest edit implements it, the memory pivot that motivated this rewrite can simply repeat. 2. **The two-stage Nix Dockerfile does not produce a runnable image** — `nix build ./oz#node`, the `nix-store -qR` closure copy into `/nix/store/`, and the symlinked `node` binary are fragile, and `npm ci` needs network access under `sandbox = false`. The plan itself leaves "decide and document" the runtime Node closure unresolved, so a build-stage failure or a broken `ENTRYPOINT` is likely. 3. **The TypeScript port breaks in-cluster behaviour that unit tests do not cover** — The undici `Agent({ connect: { ca } })` path is flagged as unsettled (undici #2200 is open), and a missing or mis-set `ORIGIN` makes every legitimate form POST fail SvelteKit's CSRF check. Either failure yields a console that builds and passes tests but cannot list previews or submit actions in the cluster.
Author
Owner

The 256Mi memory limit is insufficient and was left to "measure later" — a standalone Next.js server plus the Vitest/node server.js smoke flow routinely exceeds 256Mi, producing OOMKilled in l3-build-oz or a CrashLooping L3 preview pod. Because the limit change is deferred, it lands late or not at all, and the plan is judged a failure despite meeting the functional criteria.

Ah that is a dealbreaker, rewrite the plan to utilize Astro instead of Next.js.

> The 256Mi memory limit is insufficient and was left to "measure later" — a standalone Next.js server plus the Vitest/node server.js smoke flow routinely exceeds 256Mi, producing OOMKilled in l3-build-oz or a CrashLooping L3 preview pod. Because the limit change is deferred, it lands late or not at all, and the plan is judged a failure despite meeting the functional criteria. Ah that is a dealbreaker, rewrite the plan to utilize Astro instead of Next.js.
Author
Owner

The shift to Astro does not actually fix the memory limit. The 256Mi premise rests on no Astro-vs-Next.js benchmark, and adding @astrojs/react, react and react-dom for shadcn islands brings back a React runtime plus slow lazy-module growth over request volume. If the kubectl top measurement is treated as optional, the pod is OOM-killed exactly as before.

Alright another change of plans then, the final change:

  • @sveltejs/kit 2.x + @sveltejs/adapter-node@5.5.7 (peer @sveltejs/kit ^2.4.0), svelte 5, bits-ui.
  • One app.css with the :root/.dark variable block from shadcn-svelte's theming page, plus rules targeting Bits UI's data-* attributes. No Tailwind required — Tailwind is only
    what shadcn-svelte uses to write those styles.
> The shift to Astro does not actually fix the memory limit. The 256Mi premise rests on no Astro-vs-Next.js benchmark, and adding @astrojs/react, react and react-dom for shadcn islands brings back a React runtime plus slow lazy-module growth over request volume. If the kubectl top measurement is treated as optional, the pod is OOM-killed exactly as before. Alright another change of plans then, the final change: - @sveltejs/kit 2.x + @sveltejs/adapter-node@5.5.7 (peer @sveltejs/kit ^2.4.0), svelte 5, bits-ui. - One app.css with the :root/.dark variable block from shadcn-svelte's theming page, plus rules targeting Bits UI's data-* attributes. No Tailwind required — Tailwind is only what shadcn-svelte uses to write those styles.
tepichord changed title from Utilize Next.js for Oz to Utilize SvelteKit for Oz 2026-09-15 15:19:06 -07:00
Sign in to join this conversation.
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#59
No description provided.