Development¶
Developer-facing notes for working on Kerbside itself. See AGENTS.md for the conventions and common-task recipes, and testing.md for running the test suite, the test harnesses, and the CI lanes.
Database migrations¶
Kerbside uses Alembic for database schema migrations. The migration
files are located in the kerbside/migrations/versions/ directory.
They live inside the package so that they ship in the wheel and
kerbside db upgrade can run them from an install with no
repository checkout present.
Creating a new migration¶
This will create a new migration file in
kerbside/migrations/versions/. Edit the generated file to add your
schema changes in the upgrade() and downgrade() functions.
Example:
def upgrade() -> None:
op.add_column('table_name', sa.Column('column_name', sa.Type()))
def downgrade() -> None:
op.drop_column('table_name', 'column_name')
Applying migrations¶
To apply all pending migrations:
To rollback one migration:
Note: Alembic automatically uses the database URL from the kerbside configuration, so ensure your kerbside config is properly set up before running migrations.
Applying migrations to a deployment¶
The bare alembic commands above need a repository checkout, because
they read alembic.ini from the repository root. A deployment installed
from a wheel has neither, and uses the CLI instead:
kerbside db upgrade # to head
kerbside db upgrade --revision <revision> # to a specific revision
kerbside db downgrade --revision <revision>
These resolve the migration scripts from inside the installed package,
so they work from any working directory. Both take the database URL from
SQL_URL, exactly as the alembic commands do, so the same environment
or /etc/kerbside/kerbside.ini configuration applies -- see
configuration.md.
downgrade requires --revision and has no default. A downgrade with
an implied target is too easy to run against the wrong database.
Note that a percent sign in SQL_URL -- which is what an operator gets
from percent-encoding @, /, # or ! in a database password -- is
escaped for Alembic automatically. No special handling is needed in the
configuration.
Diagrams in the documentation¶
Diagrams in docs/ are mermaid fenced
blocks, which GitHub renders natively -- not ASCII art. Prefer a
vertical flow (flowchart TD, stateDiagram-v2, erDiagram) so a
diagram stays readable on a narrow page; sequenceDiagram is the
right choice for a message exchange between two peers.
Two kinds of fenced block in docs/spice/ are deliberately not
diagrams and stay as plain text: the Offset Size Type Field byte
tables that document a wire structure field by field, and the
byte-layout boxes in
spice/protocol-overview.md. Mermaid's
packet diagram is the only construct that fits the latter, and
GitHub's mermaid version does not reliably support it yet, so the
ASCII survives until that changes.
The database entity relationship diagram exists twice: in
schema.md and, for standalone viewing, in
docs/schema.html. Keep the two in sync -- the add-database-migration
skill says to update both.
No CI lane validates the diagrams -- a syntax error renders as an inline error box on GitHub rather than failing a check. To check a diagram before pushing, run mermaid-cli over the markdown file that contains it; mmdc renders each fenced mermaid block and exits non-zero on a syntax error, which is the actual check:
Review tracking¶
Kerbside receives periodic whole-file human review in addition to the
usual review of changes at pull request time; the current state is in
REVIEWS.md.
Which files count is set by .vscode/review-scope.toml: Python, Rust,
shell, and Markdown, less the plan archive in docs/plans/ and the
generated protobuf stubs.
The state (REVIEWS.md, .vscode/*.weaudit*) is maintained with
tools/review-tracking.sh, a wrapper around the shared helper in the
shakenfist/development
repository. In a clone it is run by hand, not from git hooks: prune
after a pull to discard reviews of files that have since changed,
stamp before committing new review marks, regen to rebuild
REVIEWS.md, next to pick an unreviewed file, and status to
report effective coverage at HEAD. On develop itself the
prune-reviews workflow runs prune automatically after every push,
committing the result back as shakenfist-bot, and the daily
consistency audit in shakenfist/development files an issue when five
or more in-scope files need review.
Signing review marks¶
A review mark is an attestation, so the commit that introduces one must be signed -- that signature is what binds the reviewer to the exact content reviewed. Signing is configured per clone and is easy to forget in a fresh one; check it before stamping, because an unsigned review commit records a mark that nothing vouches for:
git config gpg.format x509
git config gpg.x509.program gitsign
git config commit.gpgsign true
git config tag.gpgsign true
Verify with git log --format='%h %G? %s'; review commits should
report U (signed, with gitsign's Fulcio chain not in a local trust
store) rather than N (unsigned). gitsign needs an interactive
Sigstore login on first use, so run gitsign-credential-cache & to
authenticate once per session instead of once per commit.
The bot's prune commits are deliberately unsigned: pruning only
ever removes marks, so it cannot manufacture an attestation. Only
the commits that add marks need signatures.
Vendored web assets¶
Bootstrap CSS¶
Kerbside uses bootstrap CSS for styling. This was constructed by
downloading Bootstrap 5.3 and jQuery 3.7.0 and then installing to
kerbside/api/static/js.
Axios¶
Kerbside's web administration API uses Axios for HTTP requests.
Version 1.6.5 is cached at kerbside/api/static/js.
sfui¶
kerbside/api/static/sfui is a vendored copy of
shakenfist/sfui, the Shaken
Fist design system: design tokens (tokens.css), the shared page
stylesheet (sf.css), a theme boot script, the brand logo,
Lit-based web components such as sf-tabs, and the vendored Lit
and morphdom libraries those pages need. It is copied in verbatim
by sfui's own tools/vendor.sh, which also stamps the copy with
its source commit in .sfui-commit.
The design system's own README.md is vendored along with it, so
the full contract -- the token rules, what sf.css provides and
how its cascade layer works, and the component contract -- is
readable at kerbside/api/static/sfui/README.md without leaving
the repository.
Never edit anything under kerbside/api/static/sfui/ in place:
change the canonical sfui checkout instead and re-vendor, or the
next sync will silently discard the local change. To re-vendor from
a clean, up-to-date sfui checkout:
To check whether the vendored copy has drifted from canonical sfui without copying anything:
Both commands are run from the sfui checkout, not from kerbside.
Re-vendor from sfui's develop branch once the change you need has
merged there, not from the branch you made it on. The sfui-vendor
consistency audit compares .sfui-commit against canonical
develop and reports a copy that is behind it, so a stamp naming a
feature branch commit -- or an ancestor of a merge commit -- is
flagged even when every vendored file is byte for byte correct.
Page polling¶
Every page rendered with refresh=True polls instead of reloading.
base-sfui.html wraps the page body in <main id="kb-content"> and
includes templates/includes/poll.html, which every 30 seconds
fetches the current URL, parses that element out of the response and
morphs it onto the live one with the vendored morphdom. The old
base.html used a <meta http-equiv="refresh">; the morph cycle
exists so that scroll position, selection, focus, an open <details>
disclosure and a half-confirmed terminate all survive a tick instead
of being reset by a reload.
Two consequences bind anything added to a polled page:
- Never attach a listener to rendered content. Morphing keeps
live nodes alive across a tick, so a per-node listener added after
one accumulates a duplicate on every tick. Delegate from
#kb-contentor above it, which is what every listener on those pages does. - A failed poll reports staleness rather than reloading. An
expired token answers these routes with a JSON 401, so a reload
would trade a readable page for an error body. The refresh stamp
becomes
stale since <time>and the next tick retries.
Previewing templates¶
sfui has no CI of its own, and nothing in kerbside's tox lanes lints templates or CSS -- flake8 and the unit tests cover Python, and the HTML smoke tests deliberately assert on fixture data, never on markup. The only safety net for a converted page's chrome is a human looking at rendered pixels, in both palettes, without having to stand up a deployed kerbside first.
tools/preview-templates.py renders a converted page through
kerbside.api's own Flask test client -- so routing, context and
Jinja rendering are exactly what a real request would produce -- and
writes it next to a symlink of the real static tree, because the
templates reference their assets as root-relative absolute paths
(/static/sfui/...). Only pages that have actually been converted
onto base-sfui.html are supported; today that is all five: login,
which needs neither authentication nor the database; consoles,
whose fixtures render two consoles -- one with sessions and active
tokens, one with neither -- so a single screenshot shows both
terminate states (the two-step disclosure and the dim zero badge);
sessions, sources and audit, each rendered with fixtures that
similarly cover more than one branch (a plain and an errored,
no-CA source; several distinguishable audit events).
The script imports kerbside.api, so it needs an interpreter with
kerbside's dependencies installed. The tox environment already has
them, which makes .tox/py3/bin/python the interpreter to reach for
after any tox -epy3 run; a virtualenv with pip install -e . works
just as well. A bare system python3 will not.
tox -epy3 # only if .tox/py3 does not exist yet
.tox/py3/bin/python tools/preview-templates.py login /tmp/preview
(cd /tmp/preview && python3 -m http.server 8099) &
chromium --headless --disable-gpu --no-sandbox \
--hide-scrollbars --window-size=1280,1000 \
--virtual-time-budget=4000 \
--screenshot=/tmp/login-dark.png \
http://localhost:8099/login.html
chromium --headless --disable-gpu --no-sandbox \
--hide-scrollbars --window-size=1280,1000 \
--virtual-time-budget=4000 \
--blink-settings=preferredColorScheme=2 \
--screenshot=/tmp/login-light.png \
http://localhost:8099/login.html
Three details are easy to get wrong:
- Pick a port nothing else is using. 8099 is only an example, and a stale server left running from an earlier preview will happily serve 404s from a directory that no longer exists.
- Headless Chromium reports
prefers-color-scheme: darkby default, so the plain run above exercises the dark palette;--blink-settings=preferredColorScheme=2is the only value that gives the light one. - Serve the directory over HTTP, not
file://-- the theme toggle is an ES module, and modules do not load from thefile://scheme.
Then actually look at both PNGs.
A previewed polling page carries the same poll script a live page does, so it will fetch its own static URL every 30 seconds; against the statically served preview that returns identical content, which the poll's own unchanged-content short-circuit skips, so it is harmless by construction and needs no special handling.
This only covers what renders. The interactive paths -- submitting
a form, a wrong password, the theme toggle, logout -- still need a
browser against a running kerbside, or a hand-check of the relevant
fetch calls.
One of those fetch calls is worth flagging in advance: a terminate
click (templates/includes/two-step-terminate.html) fires a JSON
POST carrying an X-CSRF-TOKEN header, read from the non-HttpOnly
csrf_access_token cookie, because flask-jwt-extended requires the
double-submit header on cookie-authenticated POSTs. The JWT cookie
is also set SameSite=Lax. If a live terminate against a running
kerbside returns an unexpected 401, check for that header before
suspecting the JWT itself -- a missing or stale cookie is the usual
cause. Authorization: Bearer callers, such as the CI drivers under
tools/, never hit this check; flask-jwt-extended only applies CSRF
protection to cookie-borne tokens.
Building the Rust proxy¶
The Rust SPICE proxy lives in rust/kerbside-proxy/ (its own crate;
.gitignored target/). Builds are wrapped in Docker via the crate's
Makefile, which mounts the repo root so the crate can reach
kerbside/rpc/kerbside.proto:
make -C rust/kerbside-proxy build # cargo build in the kerbside-proxy-dev image
make -C rust/kerbside-proxy test # cargo test
make -C rust/kerbside-proxy lint # cargo fmt --check + clippy -D warnings
build.rs generates the tonic gRPC client from the same
kerbside/rpc/kerbside.proto the Python side uses (vendored protoc, no
system protobuf needed). The generator is tonic-prost-build and the
generated stubs name types from tonic and tonic-prost, so those
three crates (plus prost) must be bumped together — a runtime
crate that moves without its code generator emits stubs that will not
compile. The tonic-prost-rust group in renovate.json keeps Renovate
proposing them as one PR.
The crate depends on the ryll shakenfist-spice-protocol crate as a
git dependency pinned to a specific rev in Cargo.toml; bump the rev
(and commit the updated Cargo.lock) when picking up ryll changes. CI
runs fmt/clippy/test/build via .github/workflows/rust.yml; end-to-end
verification against qemu is direct-qemu-harness.md.
Packaging and release¶
How the wheel is built and how it reaches PATH in a deployment is
described in
How the binary gets there: packaging.
Three things about it only matter while developing:
- A dev checkout carries a dev-inclusive
kerbside-proxyFLOOR (kerbside-proxy>=X.Y.Z.dev0), not an exact pin: naming a.devversion opts pip into pre-release resolution, so a git install resolves the newest proxy wheel on PyPI — a tagged release or a rolling dev wheel published bydev-proxy-wheel.ymlwhen the binary's inputs change.tools/stamp-proxy-version.sh <version>REPLACES the floor with the exactkerbside-proxy==<version>pin at release time (anchored on the# KERBSIDE_PROXY_PINmarker), and also writes a static version into the crate's pyproject — which is howtools/build-proxy-wheel.shknows not to dev-stamp a release-stamped tree. A unit test pins the floor's dev-inclusive property.find_proxy_bin()still prefersKERBSIDE_PROXY_BINand the cargo build tree for local work, and the daemon verifies the binary's gRPC contract hash at launch either way. rust.ymlverifies wheel stamping on pull requests as a packaging guard (tools/verify-wheel-stamping.sh: an unstamped tree must produce a dev-versioned wheel, a release-stamped tree an exactly-versioned one), so a change that breaks the maturin build or the stamping surfaces before the release tag rather than during it.release.ymlruns the cross-compiled matrix build and publishes both packages, proxy first, from a singlev*tag;dev-proxy-wheel.ymlpublishes dev wheels of the proxy (only), unattended. SeeRELEASE-SETUP.mdfor the trusted publishers and the dev-release trust posture.
Validating the firewall against a real client¶
To exercise the L0+L1 firewall without risking a broken session, run
the warn-only capture in
direct-qemu-harness.md: it brings the mock
gRPC server up delivering a WARN_ONLY FirewallPolicy, drives a real
SPICE client (remote-viewer / virt-viewer / ryll headless) through the
proxy, then asserts kerbside_proxy_firewall_verdicts_total is
entirely zero (a clean capture) via verify-rust-proxy.sh
assert-firewall.
Any non-zero observed verdict on legitimate traffic means the
compiled allowlist or a size cap needs widening — never the verdict
weakening. The same harness has a deny-token / deny-all mode for
exercising the PermissionDenied denial path end to end.
To validate live session termination without a full API + daemon +
MariaDB stack, use the termination check in the same harness: the mock
gRPC server's ProxyControl stream emits a one-shot TerminateSession
a configurable number of seconds after the first authorization
(MOCK_GRPC_TERMINATE_AFTER), standing in for the API/DB leg so the
harness exercises the proxy-side cancellation path live.
Dependency pinning¶
Indirect (transitive) dependencies are pinned in pyproject.toml
between the # START_OF_INDIRECT_DEPS and # END_OF_INDIRECT_DEPS
marker comments. tools/pin-indirect-dependencies.sh regenerates that
block wholesale, nightly, via pin-indirect-dependencies.yml. Never
hand-edit between the markers — the next run deletes whatever it
finds there. New direct dependencies go above the start marker,
preferring an exact version.
Four things about the script are not obvious from the block it produces:
- Both markers are load-bearing. The script hard-fails unless each
appears exactly once, and unless START comes before END. Transposed
markers are not a syntax error to the
sedranges or theawkstate machine —/START/,/END/would then match to end of file and the rewrite would silently discard the tail ofpyproject.toml. - The reconcile never moves a version by itself. Existing pins are demoted to pip constraints and the direct dependencies are re-resolved under them, so the job only adds pins nothing had yet and reaps pins nothing requires any more. Renovate stays the only thing that raises a version. If the resolve fails with the pins applied as constraints, a direct dependency now needs something above its current pin.
- Only
[project] dependenciesare resolved. Thetestextra's transitive dependencies are therefore neither pinned nor reaped. - A local dry run rewrites
pyproject.tomlin place. Discard it withgit checkout -- pyproject.toml. The sort collation is pinned toLC_ALL=Cso a workstation run does not produce a diff made entirely of reordering noise.
Packages that must never be pinned carry a # never-pin: <name>
comment. The canonical case is pydantic-core, which each pydantic
release exact-pins itself, and which broke every CI install when
Renovate moved the two out of lockstep (PR #198).
Development configuration¶
Configuration is loaded from environment variables (KERBSIDE_*), then
an INI file at /etc/kerbside/kerbside.ini, then the field defaults in
kerbside/config.py. That path is hardcoded as INI_PATH in
kerbside/config.py and there is no setting that relocates it.
Everything lives in one [kerbside] section, and each key is
upper-cased and KERBSIDE_-prefixed before it reaches the settings
model, so a key is only applied when the corresponding environment
variable is not already set.
etc/kerbside.conf.example documents every setting and its default,
and a unit test fails if the two fall out of step. The full reference
is configuration.md; the three settings that matter
most when developing are:
SQL_URL— database connection stringLOG_OUTPUT_PATH— set tostdoutfor console loggingLOG_VERBOSE— enable debug logging
Debugging¶
Active sessions:
The daemon supervises the Rust proxy as a single child process (one
tokio task per connection, not a worker process per connection), so
ps aux | grep kerbside shows the daemon and its one proxy child. The
proxy's tracing output is inherited by the daemon's stderr;
per-channel activity is visible on the Prometheus /metrics endpoint.
Common traps:
- Token expiry. Console tokens have configurable expiry; the maintenance loop must be running to reap expired ones.
- TLS certificate paths. The proxy requires valid certificates —
check
PROXY_HOST_CERT_PATHandPROXY_HOST_CERT_KEY_PATH. - Database connections. SQLAlchemy sessions should be closed
properly; use context managers or an explicit
session.close().