Phase 3: the docker compose demo stack¶
Master plan: PLAN-demo-install.md
Planned at high effort: container plumbing, TLS bootstrap,
two supervised processes and a SPICE target under one
compose up, with many independent failure modes.
Situation¶
There is no Python-side container image and nothing
published to a registry. rust/kerbside-proxy/Dockerfile is
a build container for the Rust wheel, not a runtime image.
Everything the stack needs is nonetheless already proven somewhere in the tree, and this phase should assemble rather than invent:
| Need | Existing proven source |
|---|---|
| The full env var set | tools/direct-qemu/start-kerbside.sh:56-90 |
| TLS material and matching subject | tools/direct-qemu/generate-tls.sh |
| MariaDB database and user | tools/direct-qemu/setup-mariadb.sh |
Static source sources.yaml |
etc/example-static-sources.yaml |
| Bearer token minting | tools/direct-qemu/lane-up.sh:129-161 |
| Both processes | start-kerbside.sh:140-160 (gunicorn, then kerbside daemon run) |
| Readiness polling | start-kerbside.sh:163-215 |
Two facts established while planning, both load-bearing:
- The
.vvembeds the CA.kerbside/api.py:445-448readsCACERT_PATH, escapes newlines, and emits it asca=in the virt-viewer file (VIRTVIEWER_TEMPLATE,api.py:351-366), alongsidehost-subject=fromPROXY_HOST_SUBJECT. So a stockremote-viewerverifies the demo's self-signed CA from the.vvalone — the evaluator installs no certificates and the TLS leg is genuinely exercised. This is why the demo can be honest rather than insecure-only. kerbside db upgradeexists as of phase 1, so the entrypoint does not need a repository checkout.
Mission¶
docker compose up in demo/, on a machine with nothing
but docker, reaches a SPICE console served by a real SPICE
server, proxied by kerbside, viewable in remote-viewer.
Approach¶
Services¶
Three services, all on the default compose network:
db— upstreammariadbimage,MARIADB_DATABASE/MARIADB_USER/MARIADB_PASSWORDset to demo values, a named volume, and ahealthcheckusingmariadb-admin ping. Do not publish its port.spice-target— the thing being proxied. See below.kerbside— built fromdemo/Dockerfile, depends ondbwithcondition: service_healthyand onspice-target. Publishes API and both SPICE ports.
The SPICE target¶
The demo needs a real SPICE server or it proves nothing. Run
qemu-system-x86_64 with no disk:
qemu-system-x86_64 -m 256 -display none \
-spice port=5910,password=demo-ticket,disable-ticketing=off
With no bootable device SeaBIOS renders a "No bootable device" screen — which is a genuine SPICE display surface, proving the whole path end to end. Deliberately chosen over the alternatives:
- No KVM required. TCG is fine for a BIOS screen, so the
demo does not need
/dev/kvmor privileged containers, and works on a laptop, in a VM, and in CI. If/dev/kvmhappens to be available it is not used, and the plan does not try to detect it — conditional acceleration is complexity for no visible gain at a BIOS prompt. - No guest image download. Uncalibrated Sextant is the project's real test guest and the right choice for CI assertions, but making an evaluator fetch or build a guest to see a demo is exactly the friction this phase removes.
Say plainly in demo/README.md and the docs that the black
screen with BIOS text is the expected result, and that it is
a SPICE session, not a placeholder. An evaluator who thinks
the demo failed is worse than no demo.
The kerbside image¶
demo/Dockerfile, single stage on python:3.13-slim (the
tree requires >=3.11; match functional-tests.yml's
version if it pins one). Needs at build time:
build-essential, pkg-config, default-libmysqlclient-dev
for mysqlclient, and openssl plus curl at runtime.
Consult bindep.txt rather than guessing the list, and
docs/installation.md's bindep section stops being
hypothetical if the Dockerfile is the worked example.
Install kerbside via a build argument so the same Dockerfile serves the evaluator and CI:
Default installs the released package from PyPI, which is
what the docs describe. CI overrides it with /src and a
bind-mounted checkout so the lane tests the PR, not the last
release. The default must be the released package: a
demo that silently tests unreleased code is a demo that
works for the maintainer and fails for everyone else.
The Rust proxy comes in with kerbside's exact pin when
installing from PyPI. When KERBSIDE_SOURCE=/src, that pin
is absent from the committed tree by design (see the
KERBSIDE_PROXY_PIN comment in pyproject.toml), so CI
must also install a locally built proxy wheel —
tools/build-proxy-wheel.sh and
tools/direct-qemu/install-proxy-wheel.sh already do this
and phase 4 wires them in. Document the requirement in the
Dockerfile as a comment; do not try to make the default path
build Rust.
The entrypoint¶
demo/entrypoint.sh, set -euo pipefail, in order:
- Bootstrap TLS if absent. Call
tools/direct-qemu/generate-tls.sh— copied into the image at build time, so there is one TLS bootstrap in the tree and it is the CI-proven one — writing to a named volume so it survivescompose restartbut notcompose down -v. Skip ifproxy-cert.pemalready exists.PROXY_HOST_SUBJECTis then the literal subject that script produces,C=US,O=Kerbside CI,CN=kerbside-ci, set in the compose file rather than derived at runtime. Deriving it from the certificate is tempting and wrong: the string must match what the client checks, and pinning it as a literal is what makes a mismatch fail loudly. Note theKerbside CIorganisation name is inherited from the CI script; renaming it means editing both, and is not worth it. - Generate
AUTH_SECRET_SEEDwithopenssl rand -hex 32into the volume if absent, and read it thereafter. Never a baked-in constant: issue #131 is exactly the failure of shipping a known signing key, and the demo should model the right behaviour. Persisting it is what lets a token minted atupstill work after a restart. - Wait for the database, then
kerbside db upgrade. Compose healthchecks cover most of this; a short retry loop covers the rest, becauseservice_healthyand "accepting the kerbside user's credentials" are not the same event. - Start
gunicorn kerbside.api:appbound to0.0.0.0inside the container on the API port. - Exec
kerbside daemon runas PID 1's foreground child, so a crashed daemon exits the container and compose reports it. Do not background both andwait— that hides the failure that matters most.
Two processes in one container is not the shape a production
deployment should copy. State that in a comment and in the
docs, and say what the real shape is (separate units or
containers, sharing API_SOCKET_PATH, per
docs/configuration.md's note that the proxy must be
co-located with the daemon).
Configuration¶
Set env vars in docker-compose.yml, taking
start-kerbside.sh:56-90 as the reference list, and also
ship demo/kerbside.ini demonstrating the INI form from
phase 2, mounted at /etc/kerbside/kerbside.ini. The demo is
the natural place to show both mechanisms and which wins.
Keep secrets (the seed) out of the INI file and in the
generated volume.
sources.yaml: demo/sources.yaml, derived from
etc/example-static-sources.yaml, one console whose
hypervisor_ip is the spice-target service name,
insecure_port: 5910, ticket: demo-ticket. Backend TLS is
deliberately not used — generate-tls.sh does emit qemu
x509 material, and a second TLS leg would add a failure mode
without adding anything the evaluator can see. Note in the
file that a real deployment sets secure_port and
host_subject, and point at the oVirt use-case page, which
covers backend pinning properly.
Ports¶
Publish to loopback explicitly:
ports:
- '127.0.0.1:13002:13002' # REST API and web UI
- '127.0.0.1:5900:5900' # SPICE, TLS
- '127.0.0.1:5901:5901' # SPICE, redirect to TLS
Master plan decision 3, settled 2026-08-14, requires this:
the demo has no real authentication, so it must not be
reachable from the network by default. Comment why,
immediately above, so the evaluator who changes it knows
what they are accepting, and state it as a limitation in
demo/README.md rather than only in the compose file.
Getting a token and a .vv¶
demo/get-console.sh: mint a bearer token, GET the
console list, then fetch the .vv and write it to a file,
printing the remote-viewer command.
The token comes from kerbside demo token --subject
demo-admin, run inside the kerbside container — master
plan decision 1, settled 2026-08-14 and delivered by phase 1
step 1f. No PyJWT snippet anywhere in demo/; if the
command is missing or refuses, that is a phase 1 defect to
fix there, not to work around here.
The command's own guards do useful work for the demo: it
refuses unless every configured source is static and
refuses if AUTH_SECRET_SEED is still the sentinel. Both
hold for this stack, so a refusal means the entrypoint's
seed generation or demo/sources.yaml is wrong — a genuine
failure worth surfacing rather than routing around. Do not
add a fallback path.
The docs must still be honest that this is a demonstration affordance standing in for real authentication, not the intended user journey, and should cite issues #300 and #301 so a reader can see it is known and tracked rather than accidental.
Execution¶
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 3a | high | opus | worktree | Create demo/Dockerfile and demo/entrypoint.sh per the "The kerbside image" and "The entrypoint" sections above. Read tools/direct-qemu/start-kerbside.sh first and in full — it is the reference for the env var set, the readiness polling, and the gunicorn invocation, and the sequencing traps it documents in comments are real. Copy tools/direct-qemu/generate-tls.sh into the image rather than reimplementing TLS bootstrap. KERBSIDE_SOURCE build arg defaulting to kerbside. Build the image and confirm kerbside, kerbside-proxy, and gunicorn all resolve on PATH inside it, and that find_proxy_bin() succeeds — python -c 'from kerbside.proxy_supervisor import find_proxy_bin; print(find_proxy_bin())', the same pre-check start-kerbside.sh:96-103 runs. Do not proceed to 3b until that passes. |
| 3b | high | opus | worktree | Create demo/docker-compose.yml with the three services from "Services", the env var set, the loopback-only port publishing with its explanatory comment, and named volumes for the database and for the generated TLS/seed material. Add demo/sources.yaml and demo/kerbside.ini. Bring the stack up from a clean state (docker compose down -v first) and iterate until docker compose ps shows all three healthy, the API answers on 127.0.0.1:13002, and both SPICE ports accept a TCP connection. Report the actual docker compose logs kerbside output for a successful start in your result — not a summary of it. |
| 3c | medium | sonnet | worktree | Create demo/get-console.sh per "Getting a token and a .vv". It mints the token with docker compose exec kerbside kerbside demo token --subject demo-admin, lists consoles via the API, fetches the .vv for the single demo console, writes it to ./demo-console.vv, and prints the remote-viewer ./demo-console.vv command. No PyJWT fallback — if the command refuses, surface its message and stop. Verify the .vv contains tls-port=, host-subject=, and a ca= field holding an escaped PEM — those come from kerbside/api.py:445-451 and their absence means the TLS leg will not verify. |
| 3d | high | opus | none | End-to-end verification with a real client. From a clean docker compose down -v, bring the stack up, run get-console.sh, and open the .vv with remote-viewer (install virt-viewer if absent; the operator's host runs a normal X session, so a window will appear). Confirm a SPICE session establishes over the TLS port with CA verification from the .vv, and that the SeaBIOS screen renders. Capture a screenshot into the scratchpad and report its path. Then confirm the negative case: stop spice-target and check kerbside reports a failure rather than hanging. If the TLS leg does not verify, do not fall back to the insecure port and call it done — diagnose it, because a demo that silently proves only the plaintext path is the failure mode this phase most needs to avoid. |
| 3e | medium | sonnet | worktree | Write demo/README.md: what the stack is, the three commands (docker compose up -d, ./get-console.sh, remote-viewer), that the expected result is a BIOS screen and why, that ports are loopback-only and why, that two processes in one container is not a production shape, and docker compose down -v to remove everything including the generated CA and seed. Keep it short and point at docs/installation.md as the narrative version — phase 5 writes that, so cross-link by path and let phase 5 make it true. Do not duplicate the walkthrough here. |
Success criteria¶
docker compose up -dfrom a clean checkout, with only docker installed, brings up all three services healthy.remote-vieweropens the console over TLS with CA verification from the.vv, and a SPICE display renders. Demonstrated with a screenshot, not asserted.KERBSIDE_SOURCE=/srcbuilds against a local checkout (phase 4 depends on this).docker compose down -vleaves nothing behind; a subsequentupregenerates TLS and the seed and still works.- No
/dev/kvm, no privileged containers, no host package installation. - The generated seed is random per deployment, not a constant.
kerbside demo tokenworks in the demo stack, and demonstrably refuses in the real stack when a non-staticsource is added todemo/sources.yaml— verify by adding a dummy oVirt entry, confirming the refusal names it, then reverting. Phase 1 unit-tests the guard; this confirms it fires against a real deployment rather than a fixture.tox -eflake8passes; any shell script added is consistent with the style oftools/direct-qemu/.
Notes for review¶
The likely quiet failure is the demo working over the insecure port while the TLS leg is broken — the console still appears, so it looks fine. Step 3d's TLS requirement is the guard. Check the reviewer-visible evidence for which port the session used before accepting this phase.