Phase 2: Static source driver¶
Part of PLAN-test-harness.md. This phase
lives entirely in kerbside. The plan file lives here in
docs/plans/ per the master plan's single-home rule.
Goal¶
Add a new static source driver to kerbside that reads its VM
mapping from a sources.yaml entry — no real hypervisor required,
no control plane to talk to. The driver becomes:
- The backend kerbside fronts in the upcoming direct-qemu CI lane (phase 5).
- A first-class debugging tool any time you want to point kerbside at a hand-rolled qemu without spinning up a Shaken Fist or oVirt deployment first.
Out of scope for phase 2:
- The CI workflow that uses the driver (phase 5).
- The Uncalibrated Sextant qcow2 distribution (phase 5).
- TLS-backed static consoles. The driver's data model will
accommodate
secure_portandhost_subject, but exercising TLS against a static backend is deferred — Sextant currently exposes SPICE over plaintext via QEMU, and a real CI run can fold TLS in later. - Hot-reload of the sources.yaml mapping. Restart kerbside to pick up changes; cleaner watchdog story is future work.
- Schema validation of the static-driver config via pydantic.
Manual key checks at
__init__time are sufficient for v1; formal schemas can come later.
Decisions baked into this plan¶
These were judgment calls made while drafting the phase plan rather than questions to ask the operator. Flagged explicitly so they can be challenged before code lands.
- File location and class name:
kerbside/sources/static.py, classStaticSource(BaseSource). Mirrors the existingovirt.py::oVirtSourceandshakenfist.py::ShakenFistSourcenaming. - Type discriminator:
type: staticinsources.yaml. Dispatched fromkerbside/main.py::_parse_sources()alongside the existingshakenfist/ovirt/openstackbranches. - Console mapping is inline in the sources.yaml entry, not in
a separate file. Smallest implementation, no file plumbing to
worry about; an external mapping file with hot-reload can be
added later if real-world use demands it. Each entry under a
consoles:key in the source dict. - Tickets are persisted to the Console DB via the existing
db.add_console(..., ticket=...)kwarg, which already accepts and stores tickets on first insert. The driver yields console dicts with theticketfield populated; no separate driver lookup at.vv-generation time is required. This is the smallest possible API-side change — fundamentally just one newelifbranch in the proxy virtviewer handler that reads the ticket from the persisted Console row instead of calling a hypervisor-specific ticket-acquisition method. - Required fields per console entry:
uuid,name,hypervisor,hypervisor_ip,insecure_port,ticket. Optional:secure_port,host_subject. This matches the Console DB model's column set (seekerbside/db.py::Console). - Validation at
__init__time: missing required fields setself.errored = Trueand log a clear error. Mirrors how the oVirt and Shaken Fist drivers handle init-time failures. - No background polling, no caching layer.
__call__walks the in-memory list every time and yields. Cost is trivial; the list is bounded by sources.yaml size.
Situation¶
The hypervisor abstraction in kerbside is intentionally thin.
kerbside/sources/base.py::BaseSource defines exactly three
methods, all defaulting to ...:
class BaseSource(ABC):
def __init__(self, **kwargs):
...
def __call__(self):
...
def close(self):
...
The contract subclasses follow (per
kerbside/sources/{ovirt,shakenfist}.py):
__init__(**kwargs)receives every key/value from the sources.yaml entry. It must setself.erroredtoTrueon initialisation failure.__call__()is a generator that yields console dicts. Each dict carriesuuid,source,hypervisor,hypervisor_ip,insecure_port,secure_port,name,host_subject, and optionallyticket.close()cleans up; optional.
Driver dispatch happens in kerbside/main.py::_parse_sources()
via hard-coded if/elif chains on source['type']. To add a new
driver type we add an elif branch and an import.
The Console DB model
(kerbside/db.py::Console, line 162) already has a ticket
column, and db.add_console() (line 204) already accepts a
ticket=None kwarg that gets passed to the Console constructor on
first insert. On subsequent updates add_console() deliberately
does not overwrite the ticket — useful for sources whose
tickets rotate; harmless for the static driver whose tickets
don't.
.vv file generation happens in kerbside/api.py at two
endpoints: /console/direct/<source>/<uuid>/console.vv and
/console/proxy/<source>/<uuid>/console.vv. The proxy path is
the load-bearing one for kerbside-as-a-proxy and the one phase 5
will exercise. The oVirt branch in that handler instantiates an
oVirtSource and calls get_console_for_vm(uuid,
acquire_ticket=True) to pull a fresh ticket at request time. The
Shaken Fist branch reads from the Console DB directly because
Shaken Fist's ticket lifecycle is server-managed. The static
driver is closer to the Shaken Fist shape: the ticket is already
persisted by enumeration time, so the API handler just reads from
the Console DB.
sources.yaml is loaded via the pydantic SOURCES_PATH setting
(kerbside/config.py:170). The whole sources.yaml entry becomes
kwargs to the driver's __init__, with the type key consumed
by the dispatch and the rest forwarded verbatim.
Unit tests for sources live under
kerbside/tests/unit/test_main.py and mock the driver classes;
functional/integration tests under
kerbside/tests/functional/ require live clusters and are not
the right home for a static-driver suite.
Mission and problem statement¶
After phase 2:
kerbside/sources/static.py::StaticSourceimplementsBaseSourceand yields persisted Console rows from an inlineconsoles:list in its sources.yaml entry, complete with pre-known tickets.kerbside/main.py::_parse_sources()dispatchestype: staticto it.kerbside/api.py's proxy virtviewer handler reads the ticket from the Console DB row fortype: staticsources, mirroring the Shaken Fist branch.- Unit tests cover construction (valid + invalid configs),
yielding behaviour, and at least one end-to-end mock through
_parse_sourcesthat proves the dispatch wiring. - Documentation (
AGENTS.md,ARCHITECTURE.md,docs/console-sources.md) describes the new driver and shows a worked sources.yaml example. - An example
etc/example-static-sources.yamllands in the repo so phase 5's CI workflow has something to start from.
Open questions¶
These need answering before or during the phase, but do not block writing this plan:
- Should the
consoles:list accept a single uuid → console dict, or always a list of dicts? Leaning list-of-dicts — even for a single-VM case it's two extra characters, and it scales cleanly. Lock at start of step 2a unless you push back. - What happens if two static-source entries declare the same
uuid? The Console DB primary-keys on uuid, so the second
insert wins (or, if the column is
Unique, fails). Driver-level detection (warn loudly at init) is nicer than DB-level surprise. Decide during step 2a — the cheap thing is "log and use the last definition". - Should
StaticSourceexposeget_console_for_vm()despite not strictly needing it? Defining the method (even as a thin read-from-config) gives symmetry with the oVirt branch and leaves the door open for the API handler to query the driver for tickets without going through the DB. Lean: no, keep the surface minimal; the DB-read path is simpler. Reconsider only if the API handler change in step 2c gets awkward.
Execution¶
Each step is one logical change → one commit on a feature branch
of this kerbside test-harness branch (or off develop if you'd
rather land phase 2 as its own PR — see Sequencing notes below).
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
2a. Implement StaticSource |
medium | sonnet | none | New file kerbside/sources/static.py. Class StaticSource(BaseSource). __init__(**kwargs) takes the source dict; required keys: source, type, consoles (list of dicts). For each entry in consoles validate required fields (uuid, name, hypervisor, hypervisor_ip, insecure_port, ticket); optional fields (secure_port, host_subject) default to None. On any validation failure log a clear error and set self.errored = True. Build self._consoles_by_uuid: dict[str, dict] for fast lookup; warn if duplicate UUIDs appear and use last-wins. __call__() yields each console dict with the source and ticket fields populated. close() is a no-op (inherits the ... stub). Module-level docstring documents the sources.yaml shape with a worked example. |
| 2b. Wire driver dispatch | low | sonnet | none | In kerbside/main.py: add from .sources import static as static_source alongside the existing imports; add elif source['type'] == 'static': lookup = static_source.StaticSource(**source) to the _parse_sources dispatch chain. Verify by running the existing tests/unit/test_main.py suite. |
| 2c. API: proxy virtviewer handler | medium | sonnet | none | In kerbside/api.py, find the proxy virtviewer handler (ConsolesProxyVirtViewer.get()). The existing branches handle source type via direct DB reads (Shaken Fist) and per-source-driver method calls (oVirt). Add a static branch that reads the ticket from the Console DB row (console.ticket, populated at enumeration time by db.add_console) and skips the driver instantiation entirely. Mirror the Shaken Fist branch's shape. The direct virtviewer handler probably needs the same treatment; if the existing code already falls through to a sensible default for static, leave that path alone and document why in the brief. |
| 2d. Unit tests | medium | sonnet | none | New kerbside/tests/unit/test_sources_static.py. Cover: (1) empty consoles: list constructs cleanly, yields nothing. (2) Single valid console entry constructs and yields the expected dict. (3) Multi-entry yields in order. (4) Missing required field sets self.errored = True and logs an error (use unittest.mock.patch on the logger). (5) Duplicate uuids → warning + last-wins. (6) close() is a no-op. Also add a single mock-heavy end-to-end test in kerbside/tests/unit/test_main.py that exercises _parse_sources() with a static source entry, verifying db.add_console is called with ticket=.... Follow the project's stestr + unittest convention. |
| 2e. Documentation | low | sonnet | none | Update AGENTS.md (file inventory under "Key Files to Understand" — add kerbside/sources/static.py), ARCHITECTURE.md (the sources section gains a static-driver paragraph), and docs/console-sources.md (add a section "Static source" with a worked sources.yaml example and a paragraph noting it's intended for CI and one-off debugging, not production). |
| 2f. Example sources.yaml | low | sonnet | none | Land etc/example-static-sources.yaml (or docs/examples/static-sources.yaml, whichever fits this repo's convention better — check etc/ for existing example configs). Show one source entry with two consoles. Document the file's purpose in a top-of-file comment block. This file gets consumed by phase 5's CI workflow. |
Sequencing notes¶
- 2a is the prerequisite for everything else.
- 2b must land before any integration testing.
- 2c needs 2a + 2b. The API edits should be reviewed carefully — this is the spot where the static driver intersects the rest of kerbside.
- 2d can be drafted concurrently with 2a — the test cases pin the contract and might surface API mistakes before they ship.
- 2e and 2f land anytime after the code is in place.
Branch and PR shape: All phase 2 commits land on the
test-harness branch alongside the planning docs and (eventually)
later phases' implementation. One unified branch keeps the work
chronologically reviewable and avoids juggling per-phase branches.
The eventual PR off test-harness will be wider than a single
phase, which is by design.
Agent guidance¶
This phase plan follows the conventions in PLAN-TEMPLATE.md at
the kerbside repo root. The execution model, effort levels,
model-choice guidance, brief-writing standards, and
management-session review checklist all apply unchanged and are
not duplicated here. Phase plans should fill in the per-step
tables described there.
Notes specific to phase 2:
- Verify no DB migration is needed. The Console DB row
already has a
ticketcolumn;add_consolealready accepts the kwarg. The static driver should not require any alembic changes. If a sub-agent finds otherwise, stop and raise — that is a signal the plan is wrong, not a green light to invent schema changes. - The API handler is the integration point. Step 2c is where
most bugs will hide. The sub-agent should read
ConsolesProxyVirtViewer.get()end-to-end before changing it, and write at least one mocking test (in step 2d) that exercises the new branch. Asserting on the rendered virt-viewer file content (the password / ticket field) is the cheap, high-signal test. - Cross-source coexistence. The CI in phase 5 will run with only a static source. But production deployments may have static + oVirt + Shaken Fist all active. Step 2d's tests should confirm a static source doesn't break the dispatch for other source types running in the same daemon.
Back brief¶
Before executing any step of this plan, please back brief the operator as to your understanding of the step and how the work you intend to do aligns with that step's brief.
Administration and logistics¶
Success criteria¶
Phase 2 is done when:
tox -eflake8andtox -epy3are clean on thetest-harnessbranch with phase 2's commits on top.- The new
StaticSourceunit tests pass; existing unit tests still pass; coverage fromtox -ecoverdoes not regress in touched modules. - A manual smoke test confirms the end-to-end flow: a sources.yaml
with one static source pointing at a hand-launched qemu, a
kerbside daemon, a SPICE client (Ryll or virt-viewer) fetching
the
.vvand connecting. The smoke does not need to be automated in this phase — phase 5 handles that. README.md,AGENTS.md,ARCHITECTURE.md, anddocs/console-sources.mddescribe the new driver. The example sources.yaml ships underetc/(or wherever the repo's convention puts examples).- The
test-harnessbranch is pushed with phase 2's commits visible on origin. A PR will be opened later, scoped to whatever set of phases the operator wants to land together; the management session does not open the PR.
Future work¶
Items deliberately deferred from phase 2:
- External static-consoles file with hot-reload. Useful for long-running debug daemons where the operator wants to swap qemu backends without restarting kerbside. Out of scope for v1.
- Pydantic schema for the static config. A proper pydantic
model in
kerbside/config.py(or alongside the driver) would catch typos and unknown fields at startup. Lift if/when adding static-driver fields starts to feel error-prone. - TLS-backed static consoles. Sextant doesn't expose SPICE
over TLS today; QEMU does support
-spice tls-port=.... The driver's data model accommodatessecure_portandhost_subject, so the addition is "wire it up when needed", not "redesign". - Multi-source race detection. If the operator declares the
same uuid in two different static sources, the daemon currently
picks one and moves on. A loud cross-source uniqueness check
could live in
_parse_sourceslater.
Bugs fixed during this work¶
(None yet.)