Two-tier CI phase 1: the oVirt lane deploys and drives kerbside¶
Phase 1 of PLAN-two-tier-ci.md. Read that master plan first: it holds the prompt, the tier split, the oVirt front-door architecture decision, and the agent guidance this phase inherits.
This phase is independent of the master plan's precondition (sf-e2e PR-readiness) and can start immediately. It does not move any job between tiers — that is phase 3. It makes the oVirt lane worth keeping before we decide where to keep it.
Situation (grounded)¶
What the lane does today¶
.github/workflows/functional-tests.yml's ovirt_matrix
job (lines 124-409) builds a complete single-node oVirt 4.5
environment on Rocky 8 — engine plus hypervisor on one SF
instance at 10.0.2.2, FQDN ovirt.local — boots a
SPICE-enabled Debian 12 GNOME guest with the agents
pre-installed, and then runs two checks on the target:
tools/test-ovirt-console.py— talks to the engine API, asserts the VM has a SPICE display, lists its graphics consoles, and opens a raw SPICE link handshake against the hypervisor's plaintext console port.tools/dump-ovirt-host-subject.py— non-gating diagnostic, prints each host'scertificate.subjectand a spice-common grammar verdict.
Neither deploys kerbside. The PR's code is never installed on
anything; kerbside/sources/ovirt.py never runs; the oVirt
ticket branch in ConsolesProxyVirtViewer
(kerbside/api.py:465-473) has never executed in CI; and no
byte of SPICE has ever traversed the Rust proxy from an oVirt
hypervisor. As a gate this lane validates that oVirt still
installs, not that kerbside still works.
Closing this also closes a recorded item from
PLAN-host-subject-phase-02-kerbside-adoption.md's future
work: "Wiring the oVirt lane's console test through the proxy
with secure_port/host_subject (true cross-hypervisor
enforcement proof)".
What the last green run tells us¶
Run 30692971441, job 91351363033 (2026-08-01, ~42 minutes
wall clock, m runner). Facts taken from its log, not
assumed:
- The console the engine reports is
protocol=spice, address=10.0.2.2, port=5900, tls_port=5901. Both ports are populated, and the address is an IP, not a name. - The plaintext port answers the SPICE link handshake with
NEED_SECURED (5). That is exactly the conditionbackend.rs:93looks for before retrying ontarget.secure_port, so the proxy's escalate-to-TLS path is the path this lane will exercise. - The host certificate subject is
O=local,CN=ovirt.local, verdictPARSES. (O=localcomes fromOVESETUP_PKI_ORG=str:localinshakenfist/actions'ovirt-45-rocky-8-answers.conf.j2.) No scrape-time normalisation is needed.
So every value kerbside/sources/ovirt.py:108-117 yields is
present and well-formed in this environment. Nothing about
the environment blocks this phase.
Why kerbside cannot run on the oVirt node¶
pyproject.toml:17 sets requires-python = ">=3.11".
The oVirt target is Rocky 8, whose system Python is 3.6;
getting 3.11, MariaDB, and a mysqlclient build environment
onto it would be substantial work whose only purpose is to
avoid a network hop we actually want to exercise.
The runner does not have that problem, is already Debian 12
with the whole direct-qemu toolchain pattern proven on it,
and — per kerbside-single-node.yml's "Additional tasks for
CI" block — is itself attached to the 10.0.2.0/24 test
network, so it routes to 10.0.2.2 directly.
Mission¶
Extend the oVirt lane so that, after the environment is built, it:
- deploys the PR's kerbside (package + Rust proxy wheel) on the CI runner;
- registers a
type: ovirtsource pointed at the engine and proves discovery populates a console row with the scraped address, ports, andhost_subject; - relays a real SPICE session from
ryll --headlessthrough kerbside's proxy to the oVirt hypervisor, over the backend's TLS leg with certificate-subject pinning; - asserts the session is recorded and that API-driven termination removes it;
- leaves the proxy, gunicorn, and ryll logs in artifacts.
Architecture decision: kerbside runs on the CI runner¶
Kerbside is deployed on the runner, off-box from oVirt,
reaching the engine at https://ovirt.local/ovirt-engine
and the hypervisor at 10.0.2.2:5900/5901.
Consequences worth stating plainly:
- No
shakenfist/actionschange is required. Unlike sf-e2e — which needed thedeploy-kerbside-on-shakenfistcomposite action because its kerbside lives on a remote primary — everything here runs locally on the runner. The master plan's note that phase 1 "touches shakenfist/actions as well as this repo" is superseded: the only case that would drag that repo in is target-side firewall prep (see Risks), and only if the reachability probe shows it is needed. - The lane exercises the realistic front-door topology
(option (a) in the master plan): kerbside on its own host,
no squid,
SpiceProxyDefaultnever set. That makes it a worked example phase 4 can distil into docs. - Ports are confusing in logs and this is not a conflict:
kerbside's client-facing proxy binds
5900/5901on the runner, while the oVirt hypervisor's SPICE ports are5900/5901on10.0.2.2. Different hosts.
Rejected alternatives:
- Co-locate kerbside on the oVirt node (the sf-e2e shape). Blocked by the Python 3.11 floor on Rocky 8.
- A dedicated Debian kerbside instance on the test network. Closest to production and a genuinely better demonstration, but costs another SF instance and another provisioning path for no additional coverage of kerbside itself. Recorded as future work.
The path being proven¶
ryll --headless
-> kerbside proxy 127.0.0.1:5901 (plaintext)
127.0.0.1:5900 (TLS, proxy CA)
-> hypervisor 10.0.2.2:5900 -> NEED_SECURED
-> hypervisor 10.0.2.2:5901 TLS: verified
against the engine CA, subject
pinned to O=local,CN=ovirt.local
-> qemu on the oVirt host, authenticated with a fresh
engine-issued graphics-console ticket
Everything on the right of the first arrow is code that has never run in CI.
Configuration details that would otherwise each cost a CI cycle¶
These are the traps found while reading the source. Put them in the implementing agent's brief verbatim.
- The source
urlhas no/apisuffix.oVirtSource._ensure_connectionappends/apiitself (ovirt.py:68) and the CA check appends/services/pki-resource?...(ovirt.py:46-48). The correct value ishttps://ovirt.local/ovirt-engine. The existing CI steps pass.../ovirt-engine/apito the test scripts, which is right for them and wrong forsources.yaml. ca_certis inline PEM, and it is compared for equality.__init__writes it to a temp file, fetches the engine's own copy from<url>/services/pki-resource?resource=ca-certificate&format=X509-PEM-CA, and marks the source errored unless the two match afterrstrip()(ovirt.py:57-63). Fetch the CA from that exact URL (curl -k) rather thanscp-ing/etc/pki/ovirt-engine/ca.pem, so the bytes match by construction.- The runner must resolve
ovirt.local. The engine's HTTPS certificate isCN=ovirt.localandrequestsverifies it against the CA, so an IP URL fails. Add10.0.2.2 ovirt.localto the runner's/etc/hosts. The backend leg needs no DNS — the engine reports the console address as10.0.2.2. - The oVirt SDK is deliberately not a kerbside
dependency.
pyproject.toml:70and:161keepovirt-engine-sdk-pythoncommented out, andovirt.py:28-35imports it lazily and errors the source if absent. The venv must install it explicitly. It is a C extension: the runner needslibxml2-dev,libcurl4-openssl-dev, andbuild-essential(bindep.txtalready carries the first and the last for dpkg platforms). - oVirt tickets are short-lived (engine default 120
seconds) and are minted fresh on every
.vvrequest (api.py:465-473, which also writes the ticket to the console row viadb.store_console_ticket). The driver must fetch the.vvand launch ryll immediately — never fetch, then wait, then connect. insecure_portis tried first.backend.rs:93only escalates tosecure_portafter aneed_securederror. In this environmentport=5900is populated and does answerneed_secured, so the path works — see Risks for what to do if that ever changes.
Execution¶
New tooling lives in tools/ovirt-e2e/, mirroring the
layout and conventions of tools/sf-e2e/. All of it runs on
the runner.
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 1a | high | opus | worktree | Create tools/ovirt-e2e/gen-sources.py and tools/ovirt-e2e/deploy-kerbside.sh. gen-sources.py mirrors tools/sf-e2e/gen-sources.py in shape and security posture (write 0600 via os.open, never echo the password, print only non-secret facts to stderr): it takes --output, --engine-url (e.g. https://ovirt.local/ovirt-engine), --username, --password, --source-name, fetches the CA from <engine-url>/services/pki-resource?resource=ca-certificate&format=X509-PEM-CA with verification disabled (this is the bootstrap fetch; oVirtSource.__init__ re-fetches it verified), and writes a single-element YAML list with keys source, type: ovirt, url, username, password, ca_cert. deploy-kerbside.sh is modelled on tools/sf-e2e/deploy-kerbside.sh but runs locally (no SSH) and against oVirt instead of SF: install apt prerequisites (mariadb-server, build-essential, pkg-config, libssl-dev, default-libmysqlclient-dev, libxml2-dev, libxslt1-dev, libcurl4-openssl-dev, python3-venv, openssl, curl); reuse tools/direct-qemu/setup-mariadb.sh verbatim for the database; create a venv and pip install <repo> <proxy-wheel-glob> gunicorn ovirt-engine-sdk-python; generate TLS with tools/direct-qemu/generate-tls.sh; run gen-sources.py; start kerbside by reusing tools/direct-qemu/start-kerbside.sh unchanged (it already hardcodes the MariaDB URL, PUBLIC_FQDN=127.0.0.1, the proxy host subject matching generate-tls.sh, and the auth-seed file the driver needs); then poll until db.get_source(name)['errored'] is false, failing after 120s with the daemon log tail — an errored source is the single most likely failure and its cause is always in that log. Finish by writing /tmp/kerbside-ovirt-ci/kerbside.env (venv, workdir, api port, seed file, sources path, source name, engine url) for the driver, exactly as sf-e2e's script writes kerbside.env. Default WORKDIR=/tmp/kerbside-ovirt-ci, overridable by env. Read the traps in "Configuration details" above and encode them; do not re-derive them. |
| 1b | high | opus | worktree | Create tools/ovirt-e2e/drive-console.py, modelled closely on tools/sf-e2e/drive-happy-path.py (read it first — reuse its env-file loader, its _log convention, its DB-polling helper shape, and its rule that no token, ticket, seed, or .vv body is ever printed). Steps: (1) poll db.get_console(source, uuid) until the scrape has discovered the VM, resolving the VM uuid by listing consoles for the source and matching the smoke-test- name prefix the lane's start-test-target.py uses; log the discovered hypervisor_ip, insecure_port, secure_port, and host_subject — this is the discovery assertion and also the diagnostic if the later legs fail; assert secure_port and host_subject are both non-empty and fail loudly if not, since the whole point is the TLS leg. (2) Mint a JWT from the auth seed exactly as tools/direct-qemu/lane-up.sh lines 138-168 do (same payload shape; kerbside's verify_token only checks signature and expiry). (3) GET /console/proxy/<source>/<uuid>/console.vv, write it 0600, and launch ryll --verbose --headless --file <vv> --control-socket <sock> immediately (see trap 5), redirecting stdout/stderr to the workdir; poll for the control socket for 30s with the same on-timeout diagnostic dump lane-up.sh does. (4) Run tools/direct-qemu/smoke-client.py against the socket as a subprocess and require exit 0 — it asserts hello, non-empty surfaces, and a valid PNG screenshot, which is exactly the "real SPICE relayed" assertion we want and is guest-agnostic. Do NOT use wait-for-banner.sh or anything digest-related: this guest is Debian GNOME, not Sextant, so build ryll without digest-decode. (5) Assert a session row exists for the console in kerbside's DB and that an audit event was recorded. (6) Terminate via GET /console/terminate/<source>/<uuid> (check the exact route in kerbside/api.py before writing it) and assert the session disappears. Exit non-zero with a specific message on every failed assertion. |
| 1c | medium | sonnet | none | Wire the lane in .github/workflows/functional-tests.yml's ovirt_matrix job. Change runs-on from m to l (a release build of ryll plus a MariaDB and a proxy build on the same runner). After the existing "Dump oVirt host certificate subjects" step, add, in order: (i) echo '10.0.2.2 ovirt.local' | sudo tee -a /etc/hosts; (ii) a reachability probe from the runner to 10.0.2.2:5900 and :5901 that fails with an explicit "the hypervisor SPICE ports are not reachable from the runner; check firewalld on the oVirt host" message; (iii) the four proxy-wheel-build steps copied verbatim from the openstack_matrix job (lines 496-516: apt prerequisites, dtolnay/rust-toolchain@stable, maturin+ziglang venv, tools/build-proxy-wheel.sh with WHEEL_OUT) — the toolchain they install is also what step (iv) needs; (iv) build ryll from source into /usr/local/bin/ryll exactly as direct-qemu-functional.yml lines 65-72 do but without --features digest-decode; (v) run tools/ovirt-e2e/deploy-kerbside.sh; (vi) run tools/ovirt-e2e/drive-console.py. Add a second actions/upload-artifact (if: always(), if-no-files-found: warn) for /tmp/kerbside-ovirt-ci/ covering the kerbside daemon log, both gunicorn logs, sources.yaml, the ryll stdout/stderr, and the smoke-client log — mirror the direct-qemu lane's artifact step. Leave the existing target-side steps and their artifact bundle untouched: they are cheap and they localise "the environment broke" versus "kerbside broke". Note the workflow already sets no_proxy nowhere for this job — add no_proxy: 127.0.0.1,localhost at job level, copying direct-qemu-functional.yml lines 21-22, or the runner's squid will 503 the loopback API calls. |
| 1d | low | sonnet | none | Write tools/ovirt-e2e/README.md in the style of tools/sf-e2e/README.md: topology (kerbside on the runner, oVirt at 10.0.2.2), the connection path diagram from this plan, the step flow, the env-file contract, and the security note. Add the new directory to AGENTS.md's key-file map alongside the existing tools/ entries. No docs/ changes — the operator-facing oVirt documentation is phase 4's deliverable. |
| 1e | high | opus | none | Bring-up. Driven from the management session, not a standing sub-agent: dispatch functional-tests.yml with target=["ovirt-45-rocky-8"] and a non-zero retention, watch with ci-status, and hand each failure to a sub-agent with the failing log excerpt and the relevant artifact. Expect two to four iterations; each full run is ~50 minutes, so batch fixes rather than shipping one-line changes per cycle. Do not merge until a dispatch run is green twice in a row. |
Commits: one per step (1a, 1b, 1c, 1d), then whatever bring-up fixes 1e produces, each self-contained.
Risks¶
- The hypervisor's SPICE ports may not be reachable from
the runner. The existing handshake check runs on the
target, so it proves nothing about the runner's path.
engine-setupruns withOVESETUP_UPDATE_FIREWALL=yesand host-deploy installs the vdsm firewalld service (which opens 5900-6923/tcp), so this is expected to work — but the probe in step 1c exists so that if it does not, the lane says so in one line instead of failing inside a TLS handshake. If prep is genuinely needed, it belongs inshakenfist/actions'tools/ovirt-prepare-host.sh(hypervisor role), not in an inline SSH step here. insecure_portabsent. If an oVirt configuration ever reportsport=Nonewith onlytls_portset,insecure_portlands as 0 andbackend.rsdials port 0, fails with connection-refused rather thanneed_secured, and never escalates. That would be a real kerbside defect (the backend should go straight to TLS when no plaintext port exists), not a lane defect — fix it in the proxy, do not work around it in CI. Not expected in this environment: the 2026-08-01 run shows both ports set.- Subject-pinning mismatch. Kerbside pins
host.certificate.subjectas reported by the engine. If vdsm presents a qemu certificate whose subject differs from the host certificate's, the TLS leg refuses. That is a genuine finding about oVirt integration and should be recorded and fixed, not suppressed. The proxy log line (pinned host_subject ... does not match) plus the audit event are the evidence; both are in the artifacts step. - Ticket timing. A slow
.vv-to-connect gap expires the engine ticket and the hypervisor refuses the link. Trap 5 addresses it; if it still bites, the fix is in the driver, not a longer engine ticket lifetime — production clients have the same constraint. - Runtime. The lane is ~42 minutes today; ryll's release build and the kerbside deploy should add roughly 10-12, comfortably inside the existing 120-minute timeout.
Success criteria¶
- The oVirt lane installs the PR's kerbside and its proxy
wheel, and the
type: ovirtsource reaches non-errored state — provingovirt.py's CA equality check and engine authentication against a live 4.5 engine. - Discovery populates a console row whose
hypervisor_ip,insecure_port,secure_port, andhost_subjectare logged, withsecure_portandhost_subjectasserted non-empty. ryll --headlesscompletes hello, reports non-empty surfaces, and returns a valid PNG screenshot through kerbside's proxy — i.e. real SPICE traffic from an oVirt hypervisor crossed the Rust proxy, over TLS, with the certificate subject pinned.- A session and its audit events exist in kerbside's DB, and API-driven termination removes the session.
- The kerbside daemon, gunicorn, ryll, and smoke-client logs are uploaded as a distinct artifact bundle.
pre-commit run --all-filespasses;tox -eflake8andtox -epy3pass.- A dispatch run of the lane is green twice consecutively.
Future work (recorded, not in this phase)¶
- A dedicated Debian kerbside instance on the oVirt test network, instead of co-locating kerbside on the CI runner — closer to the documented production topology.
- A prebuilt ryll binary artifact. Three lanes (direct-qemu, sf-e2e, and now oVirt) each build ryll from source on every run; publishing a binary would cut several minutes from each.
- Driving the direct (non-proxied)
.vvendpoint for oVirt as well, which exercisesConsolesDirectVirtViewer's oVirt ticket branch (api.py:391-397). Low value while the proxy path is the supported one. - Asserting guest-agent presence: this guest ships
spice-vdagent, soagent_connectedmay become a meaningful assertion here in a way it never could in the Sextant lanes.
Status¶
Complete. Steps 1a-1d implemented 2026-08-02 on branch
ovirt-lane-kerbside; step 1e (bring-up) finished
2026-08-03 with runs 30776147437 and
30785102365 green back to back, which is the success
criterion this plan set.
The defect phase 1 targeted is fixed. The oVirt lane no longer builds an oVirt environment and then tests only that environment: it deploys kerbside from the PR's own source and proxy wheel, and relays a real SPICE session from the oVirt hypervisor through it, over TLS, with the hypervisor certificate subject pinned.
Bring-up iterations¶
Run 1 — 30763222820, dispatch, failed at "Build ryll from source" (step 29 of 35).
Everything up to and including the new pre-flight steps
passed, which settles three of the plan's assumptions
against a live environment rather than against last week's
log: the host certificate subject is still
O=local,CN=ovirt.local and still parses, the
10.0.2.2 ovirt.local line lands in /etc/hosts, and both
10.0.2.2:5900 and :5901 accept a connection from the
runner. The proxy wheel also built.
ryll then failed to compile: its audiopus_sys dependency
survives --no-default-features, pkg-config finds no
system Opus on the runner, so the build script falls back
to compiling Opus itself and panics with is `cmake` not
installed?. The prerequisite step had been copied from
openstack_matrix, which builds only the wheel and so
needs nothing beyond build-essential and pkg-config.
Fix: install the same prerequisite set
direct-qemu-functional.yml uses, since that workflow has
been building ryll on this runner image successfully.
Adopting the whole list rather than adding cmake alone is
deliberate — a full cycle is ~50 minutes, so discovering
the next missing header one run at a time is the expensive
way to do this. Batched with it: the venv pip install in
deploy-kerbside.sh now retries up to three times, because
ovirt-engine-sdk-python is first-touch for this project's
CI and a caching index mirror can report a spurious "no
matching distribution" on a first fetch.
Nothing downstream of the ryll build has executed yet, so the deploy and drive scripts remain entirely unproven.
Run 2 — 30765419311, failed at "Deploy kerbside on the runner" (step 30 of 35).
The prerequisite fix worked: ryll 0.1.7 compiled in 2m10s and installed, so the direct-qemu package list is the right one for this lane.
deploy-kerbside.sh then got as far as the venv install
before failing. The workflow passed --proxy-wheel in
single quotes, which suppresses ${GITHUB_WORKSPACE} as
well as the glob, so pip received a literal dollar sign and
reported Invalid wheel filename (wrong number of parts):
'*'. The wheel itself was built correctly and was sitting
where it was meant to be. Double quotes are what this call
wants: the glob has to survive the call so the script
expands it at the point of use, but the variable has to
expand at the call site.
Two things this exposed, both fixed alongside it. The script now resolves the glob during argument validation and fails in one line if it does not match exactly one existing file, rather than passing an unexpanded pattern down to pip and surfacing as that much more confusing message. And the new pip retry, which exists for transient index misses, had dutifully retried a completely deterministic failure three times; resolving the wheel up front means a bad path fails immediately and only genuinely transient failures reach the loop.
Verified before re-dispatching, since a wrong guess costs a
full cycle: start-kerbside.sh accepts exactly the five
arguments deploy-kerbside.sh passes, derives its seed
file to the same path the driver reads
($(dirname PID_FILE)/kerbside-auth-seed.txt), and finds
alembic.ini by walking up from the checkout. The glob
validation was exercised locally against both a matching
and an unexpanded pattern.
Still unproven: everything from generate-tls.sh onward —
source generation, the source health poll, and the whole of
drive-console.py.
Run 3 — 30776147437, green. The first time kerbside has ever been deployed and exercised in this lane.
The gate did not pass vacuously; the evidence for each
stage, from the job log and the kerbside-ovirt-*
artifact:
| Stage | Evidence |
|---|---|
| oVirt scrape | discovered smoke-test-5863, insecure_port=5900 secure_port=5901 host_subject=O=local,CN=ovirt.local |
| Proxy launch | kerbside-proxy from the PR's own wheel, subject-pinned |
| TLS escalation | all four channels (main, display, cursor, inputs) logged hypervisor requires TLS; retrying backend connection over the secure port |
| Real session | agent_connected=True, surface 1024x768, screenshot 36446 bytes with PNG magic |
| Audit and teardown | 10 audit rows, session active, terminated via the REST API, termination event recorded |
Subject pinning really was exercised, which is worth
stating because it fails silently in the passing direction.
The scrape supplied a non-empty host_subject, which
build_config maps to Some(...) rather than the None
that would disable verification (backend.rs:200-206), and
the only certificate log line concerns the hostname check
being bypassed. A pinning failure logs TLS: rejecting
certificate: pinned host_subject ... instead, per the test
at backend.rs:306.
One piece of grit, not a failure: every channel ends with
relay ended with error ... peer closed connection without
sending TLS close_notify. That is ryll disconnecting
abruptly rather than a relay fault, but it is logged at
WARN, so a genuine relay error during teardown would blend
straight into it. Worth quietening later; not phase 1's
problem.
This does not finish step 1e on its own. The success
criterion is two consecutive green dispatch runs, and one
green could still be luck given the roughly 120 second
oVirt ticket window drive-console.py races against.
Alongside this, both Retain the environment if requested
steps changed from github.event_name == 'workflow_dispatch'
to !cancelled() && github.event_name == 'workflow_dispatch'.
Without it a failing step skipped the retention step and the
environment was torn down regardless, so the retention
input only ever held open environments belonging to runs
that had already passed — the runs nobody needs to log into.
!cancelled() rather than always() so that a run someone
deliberately cancelled still releases its environment.
openstack_matrix had the byte-identical defect and got the
same fix.
Run 4 — 30785102365, green. Step 1e complete.
The second consecutive green, on a freshly built
environment and a different console
(smoke-test-3881, uuid f6c265fe...), with the same
evidence at every stage as run 3. Two independently built
environments producing the same result is what makes this a
lane rather than a lucky run.
Dispatched with retention=60 to exercise the
!cancelled() fix in anger. It behaved: the job ran 1h43m
against roughly 43 minutes of actual work, the balance
being the retention sleep, and the step reports success
where the old condition would have skipped it on any
failure.
Follow-up, deliberately not done here¶
Every channel teardown logs relay ended with error ...
peer closed connection without sending TLS close_notify at
WARN. It is ryll disconnecting abruptly rather than a relay
fault, but at WARN a genuine relay error during teardown
would be camouflaged by it. That is a proxy logging
question rather than a CI one, so it belongs in its own
change against rust/kerbside-proxy/src/relay.rs.
Deviations from the plan as written, decided during implementation review:
no_proxyfor the job is127.0.0.1,localhost,ovirt.local,10.0.2.2, not just the loopback pair the plan implied by pointing at direct-qemu's value. The oVirt source reaches the engine with pythonrequests, which honourshttp_proxy— the existing ssh/scp steps do not, which is why the current lane has never needed this. Without the engine inno_proxythe runner's squid would 503 every engine call and the source would error.sources.yamlis deliberately NOT uploaded as a CI artifact. It holds the engine admin password, which is whygen-sources.pywrites it 0600; uploading it would contradict that. The daemon log already says which source it loaded and why it errored, which is what anyone debugging actually needs.gen-sources.pyrejects an--engine-urlending in/apioutright rather than only documenting the trap.- The health poll in
deploy-kerbside.shwaits onerroredbeing false, which is only meaningful becausedaemon_run()(main.py:261-263) calls_parse_sources()synchronously before launching the Rust proxy, andstart-kerbside.shhas already blocked on the proxy's listener. Verified against the source; the script carries a comment saying so, because reordering daemon startup would silently invalidate it. A transient first-round failure self-heals:_parse_sources()clears the error state on a later successful scrape (main.py:166) and the maintenance loop runs every 60 seconds, so the 180-second deadline allows three attempts.
Back brief¶
Before executing any step, back brief the operator on the intended approach and on any deviation from this plan discovered while implementing.