Kerbside VDI tokens phase 9: full cross-repo end-to-end lane¶
This is phase 9 of a cross-repository master plan whose plan of record
lives in the Shaken Fist repository
(shakenfist/docs/plans/PLAN-kerbside-vdi-tokens.md). Phases 0-8 are done and
merged across four repos:
| Repo | What merged | Release |
|---|---|---|
| visual-digest-rust | crates.io publishing + tooling | 0.1.1 |
| ryll | visual-digest repin, PyPI publish | 0.1.7 |
| client-python | seamless Kerbside VDI launch (phase 4) | 0.8.3 |
| shakenfist (server) | mint path (phases 1-2, 6-SF, 7-8) | merge only |
| kerbside | exchange endpoint (phases 5, 6-kerbside, 8) | merge only |
Phase 9 is the post-merge phase: it runs the full flow against a real Shaken Fist and closes the one gap the earlier phases could not.
Status: Done. The lane is green as of 2026-07-31 (dispatch
30608314535): all steps pass, including the proxied SPICE session and the
full adversarial matrix. See "First run" below for the one bug the first
dispatch caught and the fix that cleared it.
Situation (grounded)¶
What is already proven, and where the hole is:
- Offline exchange is exhaustively unit-tested.
tests/unit/test_sf_token.py(nine adversarial cases: valid, expired, wrong audience, forged signature, unknown kid with/without refetch, refetch throttled, missing claim, malformed) andtests/unit/test_api.py::SfTokenApiTestCase(replay, unscraped console, valid exchange, 404-does-not-burn-jti) coververify_sf_tokenand the/sf-console.vvendpoint against locally-generated keypairs. - The proxy relay of a kerbside-issued
.vvis proven end to end by thedirect-qemulane (direct-qemu-functional.yml), which boots a real Uncalibrated Sextant guest under qemu, fronts it with a real kerbside + Rust proxy, and drives a scenario throughryll --headlessover its control socket. - The SF mint path is functionally tested by
shakenfist/deploy/shakenfist_ci/cluster_ci_tests/test_vdi_tokens.py(phase 7a): a namespaced client mints a/vdiconsoleproxytoken whose signature verifies against/admin/vditokenpubkey. That test skips today because no cluster in the SF functional matrix provisionsKERBSIDE_URL.
The hole phase 9 fills: nothing yet proves the joined flow — SF mints,
kerbside verifies offline against keys SF actually published, exchanges the
JWT for a consoletoken + .vv, and proxies the session — because that needs a
genuine type: shakenfist source. Phase 7 established (and grounded in the
code) that this cannot be faked in the cloud-free direct-qemu lane:
sf_token.verify_sf_tokenonly truststype: shakenfistsources (_shakenfist_source_names()); atype: staticsource's token is never verifiable.- The exchange asserts
console['source'] == claims['source'], so the console must live under the verifying shakenfist source's name. - The daemon maintenance loop (
_parse_sources, every 60s) reaps any console a live scrape did not yield, so a hand-seeded console does not survive. KERBSIDE_URLis read into SFconfigat process start (load_cluster_config()→SFConfig()), so it must be provisioned at deploy time, not flipped on at test runtime.
Therefore a real single-node Shaken Fist is required — exactly what this phase stands up.
CI reality (grounded in shakenfist/actions)¶
Kerbside's existing functional lanes deploy other clouds (oVirt, OpenStack via kolla) on an under-cloud VM and drive them over SSH from the workflow. Two facts shape phase 9:
- Kerbside CI can already create under-cloud SF instances. The existing
ansible/kerbside-single-node.ymlprovisions guests with theshakenfist.shakenfistcollection's nativesf_*modules, so the runner has working outer-SF API access. - A real single-node SF at develop HEAD is a solved problem via
build-smoke-cluster.shakenfist/actions/setup-test-environment+build-smoke-cluster(inputtopology: localhost) provision an under-cloud VM and deploy SF onto it from locally-built develop-HEAD wheels (sf_build_local_wheels=true). It outputsprimary(egress IP),namespace, andinventory, and leaves/etc/sf/sfrc,/srv/shakenfist/venv/bin/sf-client, and the SF API onhttp://localhost:13000on the primary, with a knownsystem_key(ci-system-key-do-not-use-in-production-ci-system-keyby default).
The gap phase 9 must build: there is no existing glue that stands up an SF
cluster AND points a kerbside proxy at it. In this repo the SF-cluster world
(build-smoke-cluster) and the kerbside-proxy world (kerbside-patches / kolla
containers, driven by deploy-kolla-ansible) never meet. Phase 9 writes that
glue.
Mission¶
Add a new kerbside CI lane that:
- deploys a single-node Shaken Fist at develop HEAD (reusing
build-smoke-cluster); - provisions
KERBSIDE_URL+ a signing key in the SF cluster and restartssf-apiso the mint endpoint activates; - creates a SPICE-console instance in a test namespace;
- stands up a kerbside whose
sources.yamlholds atype: shakenfistsource pointing at that cluster, so it scrapes the console and caches the signing keys; - drives the real flow: mint (via the client) → offline verify →
exchange for
.vv→ proxied SPICE session, asserting a session audit row and clean teardown; and - exercises the adversarial matrix against the live app: replayed token, expired token, wrong audience, unknown kid, cross-namespace mint attempt.
Architecture decision (confirmed with maintainer)¶
Four decisions were taken before implementation:
- Assertion depth: full sextant-screen. The lane boots the Uncalibrated
Sextant assertion-oracle guest inside the nested SF instance (import the
image, boot it with a SPICE console) and drives its on-screen visual digest
through
ryll's control socket, exactly as thedirect-qemulane does. This is the strongest assertion — it proves not just the exchange but a fully working proxied session end to end. - Kerbside co-located on the SF primary. Kerbside runs as plain processes on
the primary node (as
direct-qemu'sstart-kerbside.shdoes), reaching the SF API onhttp://localhost:13000and reading the cluster CA from/etc/sf/locally, so the shakenfist source's CA-cert equality check and cluster scrape work without exposing SF's API externally. The primary already has MariaDB (frombuild-smoke-cluster); phase 9 creates akerbsidedb/user there.ryllruns on the primary too, driving kerbside's proxy overlocalhost, so the whole session is self-contained on the primary. - New
shakenfist/actionscomposite action. The SF-provision + kerbside-deploy glue lands as adeploy-kerbside-on-shakenfistcomposite action inshakenfist/actions(mirroringdeploy-kolla-ansible), reusable beyond this lane. This makes phase 9 a two-PR change: the actions PR lands first, then the kerbside PR referencesshakenfist/actions/deploy-kerbside-on-shakenfist@main. - Dispatch/scheduled gating first. The lane runs via
workflow_dispatch(and optionally a nightlyschedule), not as apull_requestcheck, until it is shown stable — the deploy is heavy and we have seen merge-queue SSH-timeout flakes elsewhere. It is promoted to a PR gate once green and reliable.
Alternatives considered and rejected¶
- Kerbside on the runner, talking to SF over the network — rejected:
single-node SF binds its API on
localhost:13000(not externally exposed, no external TLS), and the shakenfist source needs cluster-local API access forget_cluster_cacert/get_nodes/get_instances. Exposing that is brittle work for no gain. getsfa self-contained SF on the runner — rejected:getsfis the legacy path inshakenfist/actions(it survives only in the "released"/"upgrade" topologies);build-smoke-clusteris the maintained path and already builds+installs SF at develop HEAD.- Handshake-only assertion — rejected in favour of the full sextant-screen assertion above (decision 1).
- Glue as kerbside-repo scripts — rejected in favour of the reusable
composite action (decision 3); the session-driving pieces (image import, mint /
exchange / ryll driving, adversarial checks) still live in
kerbside/tools/, but the deploy glue is inshakenfist/actions.
Execution¶
Split across two repos:
shakenfist/actions (PR lands first)¶
A new composite action deploy-kerbside-on-shakenfist/action.yml that, given a
running single-node SF cluster (the build-smoke-cluster outputs) and a kerbside
checkout + proxy wheel staged on the runner, does the primary-side work over SSH:
- Provision the integration in SF:
sf-ctl set-config KERBSIDE_URL https://<kerbside-public-fqdn>;sf-ctl set-config KERBSIDE_TOKEN_DURATION 300;sf-ctl ensure-kerbside-signing-key; restartsf-apiso the process-cachedKERBSIDE_URLtakes effect.<kerbside-public-fqdn>is how the tokenaudand the returned URL base are formed; it must equal kerbside'sSF_CONSOLE_TOKEN_AUDIENCE. Since kerbside is co-located, this can be a localhost-resolvable name / the primary's own address. - Deploy kerbside on the primary: copy the PR's kerbside checkout + proxy
wheel to the primary; create a
kerbsideMariaDB db/user in the primary's existing MariaDB; install kerbside + the proxy wheel into a venv;pip install ryll==0.1.7(released bin wheel — this lane tests the SF↔kerbside integration, not ryll HEAD); writesources.yamlwith onetype: shakenfistsource (url: http://localhost:13000,username: system,password: <system_key>,ca_cert: <cluster CA from /etc/sf/>); exportKERBSIDE_SF_CONSOLE_TOKEN_AUDIENCE; start kerbside (adaptstart-kerbside.shto run on the primary); wait for the first scrape to yield the console.
Inputs: base_user, primary, system_key, kerbside_public_fqdn,
token_duration. It mirrors deploy-kolla-ansible's shape (SSH into the node,
run a sequence of steps, fail fast).
kerbside (PR references the merged action)¶
One new workflow, .github/workflows/sf-e2e-functional.yml, triggered by
workflow_dispatch (and optionally a nightly schedule) — not
pull_request yet (decision 4). A single job on [self-hosted, vm, debian-12,
l], timeout-minutes: 120. Session-driving logic lives in
kerbside/tools/sf-e2e/* (no >5-line inline CI scripts, per project
convention):
- Stand up SF.
shakenfist/actions/setup-test-environmentthenbuild-smoke-clusterwithtopology: localhost. Captureprimary,namespace,system_key. - Build the PR's proxy wheel on the runner (Rust toolchain +
tools/build-proxy-wheel.sh, exactly as the openstack lane does) and stage it for the composite action. - Deploy kerbside against SF via
shakenfist/actions/deploy-kerbside-on-shakenfist@main. - Import + boot the assertion-oracle guest (
import-sextant.sh): uploadtests/fixtures/uncalibrated-sextant.qcow2to SF as a shared artifact, then create a UEFI, SPICE-console instance from it in a fresh test namespace and awaitcreated. Wait for kerbside's scrape to pick the console up. - Happy path (
drive-happy-path.sh+ Python driver on the primary):client.get_vdi_console_proxy_file(<instance>)performs mint + exchange in one call and returns the.vv; hand it toryll --headless --file <.vv> --control-socket ...against kerbside's proxy on localhost; reusewait-for-banner.sh+smoke-client.py/run-scenario.shto assert the Sextant boot banner and drive the on-screen visual digest (full direct-qemu depth). Assert a session audit row exists and that teardown removes it. - Adversarial matrix (
drive-adversarial.sh), each against the live kerbside/sf-console.vv?token=<jwt>: - replay — exchange the same freshly-minted token twice; second returns
401
token already used; - expired — mint with a short duration, exchange after expiry → 401;
- wrong audience — a token whose
audis not this kerbside → 401; - unknown kid — a token signed by an untrusted key / unknown
kid→ 401 (and confirm the refetch debounce holds under repeat); - cross-namespace mint — a second namespace's client minting for the first namespace's instance → SF returns 404/403 (ownership enforced at the mint, not the exchange).
- Artifacts + teardown: gather kerbside logs, gunicorn logs, ryll
stdout/stderr, the
.vv, SFsf-api/sf-consolelogs, and the sources.yaml; upload onalways(). The under-cloud reaper collects the SF instance.
First run (2026-07-31, dispatch 30582413364)¶
The first dispatch (retention=30) failed at the happy path's first gate,
but every heavy unknown upstream of it passed:
build-smoke-cluster, the co-located kerbside deploy (MariaDB, ryll from source, proxy wheel, source key caching), and — notably — the nested UEFI + SPICE boot of the Uncalibrated Sextant instance all went green. Open question 1 is effectively answered: the guest reachedcreatedon the CI hypervisor without a handshake-level fallback.drive-happy-path.pythen timed out after 180s waiting for the console to appear in kerbside. The daemon log showed the source repeatedly finding the instance (correctvdi: spice, statecreated) but discarding it:Ignoring instance whose node is not in the node map.
Root cause — a real bug in kerbside/sources/shakenfist.py, not the lane.
The source keyed its node map by node['name'] (the fqdn) but looked it up by
inst['node'], which Shaken Fist reports as a node UUID (from the
instance's placement). Name never equals UUID, so every SPICE console was
dropped. It stayed latent because the unit-test fixture set node='n1' and
name='n1', making them accidentally equal. Fixed on branch
sf-source-node-uuid-fix: key the map by node['uuid'], emit hypervisor as
the node fqdn (a connectable host, as the direct-.vv host= needs), and
synthesize host_subject from the fqdn. Test fixtures now model SF's real
contract (distinct uuid vs fqdn) and fail without the fix. The lane's
retain-on-dispatch step also gained always() so a failed run still lingers
for debugging (it was skipped on this failure, tearing the env down early).
Second run (dispatch 30608314535, after the fix merged): green. Every
step passed — the console scraped, the happy path drove mint → offline-verify →
exchange → proxied SPICE session → digest assertion → terminate, and all five
adversarial cases were rejected as expected. The retain step (now always())
ran, and the downstream gates that had never executed before — the proxied
SPICE session and the on-screen digest assertion — both passed. Phase 9 is
complete.
Residual open questions¶
- Booting Uncalibrated Sextant inside nested SF. (Largely resolved by the
first run — the guest booted UEFI + SPICE and reached
created.) The guest is UEFI and its assertion oracle needs a working SPICE console on the SF instance. If the on-screen digest step later proves flaky on the CI hypervisors, fall back to a handshake-level assertion for that step (still proving mint → verify → exchange → proxy) and track the on-screen assertion as follow-up. kerbside_public_fqdn/ audience value. With kerbside co-located and driven over localhost, confirm the exact string used for both SF'sKERBSIDE_URLand kerbside'sSF_CONSOLE_TOKEN_AUDIENCE(they must match byte-for-byte). A stable localhost-resolvable name is simplest.- Runner budget. Confirm the
lclass + 120-minute timeout hold once the full deploy+session is timed; adjust if the deploy dominates.
Success criteria¶
- A single new CI lane deploys a real single-node Shaken Fist at develop HEAD,
provisions
KERBSIDE_URL+ a signing key, stands up a kerbside with atype: shakenfistsource, and drives an SF-minted token through offline verification, exchange, and a proxied SPICE session — green. - The adversarial cases (replay, expired, wrong audience, unknown kid, cross-namespace mint) are each asserted against the live app.
- Phase 7a's
test_vdi_tokens.pymint-path test activates (stops skipping) on this provisioned cluster. - No token string or private key material is logged, in scripts or artifacts.
README.md,AGENTS.md,ARCHITECTURE.md, anddocs/note the new lane and the SF console-source end-to-end coverage.- The master plan's phase 9 row and this repo's
docs/plans/index.mdare updated to Done when the lane is green.
Future work¶
- Promote the primary-side kerbside install to a reusable
shakenfist/actions/deploy-kerbside-on-shakenfistcomposite action if a second consumer appears (Alternative C). - Add the sextant-screen assertion (Open question 1) if the handshake-level assertion lands first.
- Fold key rotation (
sf-ctl rotate-kerbside-signing-key) into the lane to prove the two-key window and kerbside's unknown-kid refetch against a real rotation.