WIP: build(dev-rig): a disposable server to test the compose path against #2
Loading…
Reference in a new issue
No description provided.
Delete branch "build/dev-rig"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
First of four. Stands up a throwaway server with its own dockerd and OpsDeck nested inside it using the production mounts, so the compose path,
--progress jsonand the pin route are actually exercised.No app code.
docker/dev-rig/up.shand one paragraph in CLAUDE.md.Verified: rig builds and serves main unchanged, four seeded stacks in a git work tree.
The rig concept is sound and the README is genuinely good, but the scripts read as written once and never run through their failure paths. Requesting changes.
Must fix:
OPSDECK_AUTH=oidcpointed at a dead issuer. Exit 1 like the dockerd loop directly above it.mount --make-rshared /failure is a WARNING, but the README says the whole rig premise depends on that propagation. A failed precondition must be fatal.OPSDECK_*ENV block configures a process that never runs in this container (the rig runsrig-entrypoint; nested OpsDeck gets its own-elist). TheHOST_ROOT=/comment describes a code path that never executes. Delete everything exceptDOCKER_HOSTbefore someone "fixes" the nested run to match this fiction.docker rm -fleaves theopsdeck-rig-dataandopsdeck-rig-dockervolumes behind forever — that second one is the entire nested image store. Ship adown.shor a--cleanflag.Everything else is inline. None of it is optional; all of it is cheap.
@ -0,0 +15,4 @@&& curl -fsSL https://download.docker.com/linux/debian/gpg \-o /etc/apt/keyrings/docker.asc \&& chmod a+r /etc/apt/keyrings/docker.asc \&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" \"bookworm" is hardcoded while the base is whatever the production image ships. The day production moves to trixie this apt line breaks or, worse, quietly installs bookworm packages. Use
$(. /etc/os-release && echo "$VERSION_CODENAME")— the line already shells out for the arch.@ -0,0 +31,4 @@# HOST_ROOT is "/" because this container IS the host it manages: the chroot# strategy in hostexec.ts then runs against a real root that has docker, git# and the compose files where a real deployment would put them.ENV OPSDECK_ENV=development \This entire ENV block configures a process that never runs here. The rig container runs rig-entrypoint; the nested OpsDeck runs opsdeck:rig-base with its own -e list in entrypoint.sh. OPSDECK_HOST_ROOT=/ and the three-line chroot explanation above describe a code path that never executes. Keep DOCKER_HOST, delete the rest — dead config with a confident comment is worse than no config.
@ -0,0 +23,4 @@```Restart the rig, never the app inside it:`docker exec opsdeck-rig dockerInline code span split across three lines mid-command — this renders broken. Put
docker exec opsdeck-rig docker restart opsdeckon one line.@ -0,0 +13,4 @@# rslave on the nested mount needs a shared or slave root to propagate from,# and a container root is private by defaultmount --make-rshared / 2>/dev/null || log "WARNING: could not make / rshared"Warn-and-continue on the one precondition the whole rig depends on. If
--make-rsharedfails, the rslave bind below silently misbehaves and every conclusion drawn from the rig is suspect. Make it|| { log "..."; exit 1; }.@ -0,0 +34,4 @@# Compose files live in a git work tree, because the pin route's whole point# is committing the line it rewrites — an untracked directory exercises only# half of it.if [ ! -d /srv/stacks/.git ]; thenDead guard. /srv/stacks is on no volume, so it is fresh on every
docker runand this condition is never false. Either persist the work tree or delete the check — as written it implies persistence that does not exist, which will mislead the next person debugging seed state.@ -0,0 +46,4 @@filog "pulling seed images (once)"for img in nginx:1.27-alpine nginx:1.25-alpine redis:7.2-alpine alpine:3.20; doThe pre-pull list omits python:3.12-slim — the one 130 MB image, the whole reason the
heavyservice exists.compose upabsorbs that pull silently instead, so "pulling seed images (once)" is false and the offline warning never covers the image most likely to fail. Add it to the list.@ -0,0 +86,4 @@openssl x509 -req -in /certs/idp.csr -CA /certs/ca.pem -CAkey /certs/ca-key.pem \-CAcreateserial -days 3650 -extfile /certs/idp.ext -out /certs/idp.pem \>/dev/null 2>&1chmod 644 /certs/idp-key.pemchmod 644 on a private key. The stub IdP runs as root in this container and can already read it — nothing needs this. Drop the line.
@ -0,0 +93,4 @@/srv/idp/stub.ts >/var/log/idp.log 2>&1 &for i in $(seq 1 30); docurl -ksS https://localhost:9443/jwks >/dev/null 2>&1 && break[ "$i" = 30 ] && { log "IdP did not start:"; tail -20 /var/log/idp.log >&2; }This loop times out, logs "IdP did not start", and then falls through to start OpsDeck against a dead issuer anyway. The dockerd loop twenty lines up gets
exit 1on timeout; this one must too. As written, a broken IdP surfaces as an unexplained login failure ten minutes later instead of a failed boot.@ -0,0 +101,4 @@# OpsDeck trust the self-signed certificate, which openid-client requires# because it refuses a plain-http issuer outrightIDP_ARGS="--add-host idp:host-gateway \-v /certs:/certs:ro \This mounts the whole /certs directory into the nested container — including ca-key.pem and idp-key.pem. OpsDeck needs exactly one file: ca.pem. Mount that file, not the keyring.
@ -0,0 +121,4 @@docker run -d --name opsdeck \-p 8080:8080 \-e OPSDECK_ENV=development \-e OPSDECK_AUTH=disabled \OPSDECK_AUTH=disabledis always passed here, and in OIDC mode$IDP_ARGSappends-e OPSDECK_AUTH=oidcafterwards, betting on docker's last-flag-wins behavior forever. Build the env list once and set OPSDECK_AUTH exactly once, conditionally.@ -0,0 +125,4 @@-e OPSDECK_MODULES=docker \-e OPSDECK_LOG_LEVEL=debug \-e OPSDECK_DATA_DIR=/data \-e OPSDECK_HOST_PROC=/host/root/proc \HOST_PROC and HOST_SYS are set explicitly but OPSDECK_HOST_ROOT is not — the nested container silently rides an image default. Set it explicitly like its two siblings, or comment which default is being relied on.
@ -0,0 +135,4 @@log "opsdeck container: $(docker inspect -f '{{.State.Status}}' opsdeck)"log "following its log"exec docker logs -f opsdeckexec docker logs -f opsdeckas the foreground process means any restart of the nested container kills the whole rig — the README documents this wart instead of fixing it. Adocker waitloop (orwhile docker logs -f; do sleep 1; donewith a liveness check) survives restarts and costs three lines.@ -0,0 +93,4 @@id_token_signing_alg_values_supported: ["RS256"],code_challenge_methods_supported: ["S256"],scopes_supported: ["openid", "profile", "email"],token_endpoint_auth_methods_supported: [Discovery advertises client_secret_basic and client_secret_post, but the token endpoint never checks the secret, the client_id, or the redirect_uri. Fine for a stub — but the file's doc comment inventories what IS checked, so add one line saying these are deliberately unchecked, or the next reader assumes the stub validates what it advertises.
@ -0,0 +107,4 @@// which role the next login gets: the whole point of the stubif (url.pathname === "/role") {const value = url.searchParams.get("value");if (value) role = value;State mutation on GET. Pasting https://localhost:9443/role?value=opsdeck-viewer into a browser bar changes the next login. Gate the write on req.method === "POST" — the curl examples already use POST, so nothing else changes.
@ -0,0 +3,4 @@# session in the cookie jar $2. The browser leg is done here with curl: the# stub approves immediately, so the flow is redirect -> redirect -> cookie.set -euo pipefailROLE=$1; JAR=$2Under set -u a missing argument dies with "ROLE: unbound variable". Two lines: check $# and print usage.
@ -0,0 +8,4 @@curl -ksS -X POST "https://localhost:9443/role?value=${ROLE}" >/dev/null# 1. OpsDeck hands us the authorize URL (and sets the flow cookie)AUTH=$(curl -sS -c "$JAR" -o /dev/null -D - http://localhost:8080/auth/login \If /auth/login ever stops answering with a Location header, AUTH is empty and the next curl fails on an empty URL with an unrelated error. Guard with [ -n "$AUTH" ] || { echo "no authorize URL from /auth/login" >&2; exit 1; }.
@ -0,0 +18,4 @@| awk 'tolower($1)=="location:"{print $2}' | tr -d '\r')# 3. OpsDeck exchanges the code server-side and sets the session cookiecurl -sS -b "$JAR" -c "$JAR" -o /dev/null -w '' "$BACK"Every step here can return a 4xx body and the script still exits 0 with a jar full of nothing — set -e only catches transport failures. Finish with one authed request and check the status, e.g. curl -fsS -b "$JAR" .../api/core/health, so a broken flow fails here instead of in whatever consumes the jar.
@ -0,0 +11,4 @@RIG=$(cd "$(dirname "$0")" && pwd)NAME=opsdeck-rigif [ "${1:-}" != "--rerun" ]; thenAnything except the exact string --rerun silently triggers the full build. A typo like --rerun= or -rerun costs the user a ten-minute rebuild with no error. Reject unknown arguments with a usage line.
@ -0,0 +17,4 @@fiecho "[up] exporting the app image for the nested engine"docker save -o "$RIG/opsdeck.tar" opsdeck:rig-baseThis re-exports the identical multi-hundred-MB tar on every launch, including --rerun where the image cannot have changed, and then the build below ships it as context again. Skip the save when --rerun is set and the tar exists. Longer term the app is stored three times (host tar, rig image layer, nested engine) — worth a TODO at least.
@ -0,0 +29,4 @@# container only and grants nothing on the developer's own daemon.docker run -d --name "$NAME" --privileged \-p 8080:8080 \-p 9443:9443 \9443 is published unconditionally but the IdP only exists under RIG_OIDC=1. Dead port in the default case; publish it inside the conditional.
@ -0,0 +32,4 @@-p 9443:9443 \-e RIG_OIDC="${RIG_OIDC:-0}" \-v "$NAME-data:/data" \-v "$NAME-docker:/var/lib/docker" \"Disposable by design", but nothing ever deletes these volumes.
docker rm -fleaves opsdeck-rig-data and opsdeck-rig-docker behind — the latter is the whole nested image store, gigabytes per dev box, growing on every rebuild. Add a down.sh or a --clean flag that runsdocker rm -fplusdocker volume rm.build(dev-rig): a disposable server to test the compose path againstto WIP: build(dev-rig): a disposable server to test the compose path againstAnswering a depends_on prompt started the update without waiting for it and presented the next queued prompt in the same microtask. But the server reserves the whole compose project for the length of an update, and that check runs before the dependents check — so the second acknowledgement came back 409 "update already running", with no needsConfirmation field to route it. The human read a destructive warning, accepted it, and got a raw HTTP 409 in the row's error line: the exact outcome the queue was added to prevent. Queue entries now carry their project, and only a prompt whose project is free is shown. The ones waiting say so in their row instead of going quiet, and the dialog holds a busy state while the update it just acknowledged is being started. The same reservation explains two more. stackLocked only tracked stack updates although /container/:id/update takes the identical lock, so sibling buttons stayed enabled and 409'd; it becomes projectBusy(), which also counts per-container updates. And subscribeLive("docker:update") registered no onResync, so a dropped channel — routine, the socket closes after a minute hidden — lost the terminal event that is the only thing clearing the in-flight flags, wedging every button in the project until a reload.`deno lint` reported 23 problems and nothing enforced it, so they had accumulated. Seven of them were one function: safeUpgradeWebSocket, which took `any` for the handler, the context, the options, the events object and every callback argument, and was then cast to `any` again at the call site behind a deno-lint-ignore. It is typed now: hono's own WSEvents for the shape it must produce, the SDK's ModuleContext["upgradeWebSocket"] for the contract it fills, and a single documented assertion where those two meet — which is the only place they genuinely differ, because the SDK describes the callbacks in its own terms so modules never import hono. The rest: three require-await middlewares now `return await next()`, which is what makes them honestly async (the MiddlewareHandler contract needs the promise, so dropping `async` was not an option); a regex with two literal spaces says ` {2}`; DuckDBConnection is a type-only import; and an unused type import is gone.Every hand-written page fetched its data as `apiFetch(...).then(...)` with no rejection path. A 500, a dropped connection, a restarting server — `data` stays null, the `{#if !data}` branch keeps rendering its skeleton for as long as the page is open, and the only trace is an unhandled rejection in the console. Nothing on screen says anything is wrong, and nothing ever will. Three pages were worse than having no catch: ContainerPage, StackDetailPage and PoolDetailPage caught, handled 404 by navigating away, then `throw e` for everything else — a rethrow inside .catch is a rejected promise nobody awaits, so it produced the same dead skeleton plus a warning. The schema renderer already did this correctly (SchemaPage catches and renders EmptyState with the error as its hint), so the mobile path degraded properly while the desktop pages did not. Pages now match it. The three overview widgets get one line instead: they render into a fixed ~260px slot on the home page where an EmptyState does not fit. StoragePage reports its two fetches separately, because smartctl can be unavailable while the mount table is perfectly fine. Each of these is nine or so lines of the same shape, which wants a shared helper — but the .then bodies do page-specific work (StacksPage prunes its confirmation queue, ContainerPage drops a stale prompt), so a generic loader needs callbacks, and there is no browser test framework here to prove that refactor did not change behaviour. Left as the local pattern SchemaPage already established.The focus effect read `open`, `variant` and the two element bindings, but not `busy` — and both buttons are `disabled={busy}`, so .focus() on them does nothing. It never re-ran when `busy` cleared either. That is reachable in the queued-confirmation flow this branch added: StacksPage renders DependentsConfirm inside `{#key confirmUpdate?.id}` with `busy={confirming}`, so when the answer to one prompt is still in flight and the next queued prompt becomes current, the new dialog is created disabled, focuses nothing, and stays that way. Escape still worked, Enter did nothing, and the Tab trap's swap sent the first Tab to "Update anyway" — the exact invariant ("focus lands on the safe choice for destructive actions") that justified removing the Enter handler in the first place. The focus effect is now separate from the keydown effect and tracks `busy`, so it fires as soon as the buttons can take focus; splitting them also keeps the focus-restore cleanup from running on every re-run. The Tab trap no longer assumes focus is on one of the two buttons — when it is on neither it goes to the safe one instead of falling through to the dangerous one.Pull request closed