Minimal FastAPI + Jinja2 UI to control the qilab systemd service, including updating the qilab binary and updating this app itself from the browser.
- Python 3.10+ with
pip(python3-pip). Nopython3-venv, no build tools. - systemd available on the host
- A systemd service for qilab (example:
qilab.service)
Everything about the update mechanism follows from one rule: the systemd unit reaches the app only through a symlink, never through a concrete install path. That is what lets an update build a new copy alongside the live one and switch over atomically, with a rollback that is just "point the symlink back".
/opt/qilab-web-control/
├── current -> releases/0.4.3 # the ONLY path the unit references
├── releases/
│ ├── 0.4.2/site-packages/ # previous release, kept for rollback
│ └── 0.4.3/site-packages/ # live release (app + its dependencies)
└── scripts/
└── self_update.sh # outside releases/: survives any swap
/var/log/qilab-web-control/self_update.log
/etc/systemd/system/qilab-web-control.service
A release is a pip install --target directory that the unit puts on
PYTHONPATH — deliberately not a virtualenv. python3 -m venv requires the
python3-venv package, which the RT images do not carry, so pip is the only
tool involved. --target also sidesteps PEP 668, so nothing needs
--break-system-packages.
What the indirection buys:
- A failed update cannot break the device. The new release is built and
import-checked before
currentmoves. A bad wheel leaves the live release untouched and the service is never even restarted. - Rollback needs no network and no artifacts. The previous release is still on disk; recovery is a symlink flip.
- Updates need no network either. A new release is seeded from the live one, so only the app's own files come off the uploaded wheel.
Two rules follow from it, and both are enforced rather than documented and hoped for:
- Nothing may reference a concrete release path — not
ExecStart, notPYTHONPATH, notWorkingDirectory.self_update.shrefuses to run if the unit does not route throughcurrent. - The app must not also be installed system-wide. A copy in
/usr/lib/python3/dist-packageswould shadow the release directory onimport appand pin the device to one version forever.
Deployment assets — self_update.sh and the systemd unit — ship inside the
wheel under app/deploy/, and installers copy them out of the installed
package. So what lands on a device always matches the version installed, and
there is exactly one definition of each: the unit is
app/deploy/qilab-web-control.service.
The rpi-img-gen layer is maintained in the image-generation repository. What it
has to produce, and a working customize-hooks implementation, are in
docs/image-layer.md.
The same layout by hand, for a device you can reach over SSH. This is also the
path out of an older deployment — see docs/migration.md
first if /opt/qilab-web-control does not already look like the tree above.
VERSION=0.4.3
ROOT=/opt/qilab-web-control
SP="$ROOT/releases/$VERSION/site-packages"
sudo mkdir -p "$SP" "$ROOT/scripts" /var/log/qilab-web-control
curl -L -o /tmp/qilab_web_control.whl "<gitlab-release-wheel-url>"
sudo python3 -m pip install --target "$SP" /tmp/qilab_web_control.whl
# Fail here rather than at boot.
sudo env PYTHONPATH="$SP" python3 -c 'import app.main'
# Deployment assets come from the wheel, so they match this exact version.
sudo install -m 0755 "$SP/app/deploy/self_update.sh" "$ROOT/scripts/self_update.sh"
sudo install -m 0644 "$SP/app/deploy/qilab-web-control.service" \
/etc/systemd/system/qilab-web-control.service
sudo ln -sfn "$ROOT/releases/$VERSION" "$ROOT/current"
sudo systemctl daemon-reload
sudo systemctl enable --now qilab-web-control.service
sudo systemctl status qilab-web-control.service --no-pagerRead the unit before enabling it: it assumes qilab lives at /opt/qilab, and
picks up shared qilab paths from /etc/default/qilab-global-env if that file
exists.
Every subsequent version should go on through the UI — the install above is needed once per device.
The Control tab's "Update qilab_web_control" section lets an admin upload a new release wheel and upgrade this app itself, without SSH access.
Sequence (app/deploy/self_update.sh, deployed to the device as
/opt/qilab-web-control/scripts/self_update.sh):
- Preflight — refuse to write anything unless this install is actually
swappable:
pipis usable,currentis a symlink (not a real directory), and the unit reaches the app throughcurrentvia itsExecStartor its environment (EnvironmentFile=contents included, sincesystemctl showreports only inlineEnvironment=). An install that does not satisfy the layout aborts here with an explanation instead of silently succeeding at nothing; see docs/migration.md. - Build the new release in
releases/<version>/site-packages, leaving the live release untouched. A same-version reinstall builds into<version>-r2rather than deleting the directory it is running from. - Health check —
PYTHONPATH=<new release> python3 -c 'import app.main', exactly how the unit will import it. On failure, abort:currentis never moved and the service is never restarted. - Atomic swap — flip
currentto the new release. - Restart and verify —
systemctl restart, then pollis-active, because a successfulrestartonly means systemd ran the job, not that aType=simpleprocess stayed up. - Rollback on failure — point
currentback at the previous release and restart again. - Housekeeping, only once the new release is verified live: replant
self_update.shfrom the new wheel (so script fixes reach devices), warn if the new wheel's unit file differs from the installed one, and prune releases older than the previous one.
Three implementation details worth knowing:
- The script runs as its own transient unit (
systemd-run --unit=qilab-web-control-selfupdate --collect), not as a background child of the request handler. It restartsqilab-web-control.servicepartway through its own execution, and a plain child would share that service's cgroup and be killed by the same restart before it could verify success or roll back. - Dependency installs are offline-first. The device is not assumed to reach
PyPI, so the new release is seeded by copying the live release's
site-packagesand the wheel is then installed with--no-deps. Because a release is a plain directory rather than a venv, that copy involves no absolute paths and no shebang rewriting. The seeded copy of the app itself (package plus itsdist-info) is deleted before the install, sincepip --targetdoes not uninstall and twodist-infodirectories would make the reported version ambiguous. - A release that changes dependencies needs network, or a new image. The
--no-depsinstall would leave the old dependency version behind; step 3 catches that, and the script then retries a full networkedpip install. If that also fails, the update aborts with the live release intact.
Residual risk: if the restart and the rollback restart both fail (e.g. the host itself is unhealthy), you still need console/SSH access to recover — the same risk already accepted for the qilab binary's own update mechanism.
The app does not check the layout itself, only the script does. So on a
misconfigured install the UI still accepts the upload and the refusal shows up in
self_update.log a moment later, rather than the form declining up front.
python -m venv .venv
. .venv/bin/activate
pip install -U setuptools
pip install -e .sudo QILAB_SERVICE=qilab.service \
QILAB_UI_TITLE="Qilab Control" \
QILAB_LOG_PATH=/var/log/qilab/qilab.log \
QILAB_UPDATE_SCRIPT=/opt/qilab/scripts/update_release.sh \
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000Self-update is unavailable in this mode — there is no current symlink to flip,
so the script's preflight declines and logs why.
- Refresh the vendored schema fallback (
app/config/config.schema.json) from the qilab tag this release targets, and updateapp/config/VENDORED_SCHEMA_VERSIONto match itsx-qilab-schema-version.verify:vendored_schemain CI fails the release if these two are out of sync — this is the fallback used wheneverQILAB_WEB_SCHEMA_PATHisn't set, so keep it deliberately current rather than letting it silently drift from qilab's schema. - Update
CHANGELOG.mdwith a section for the version you are releasing (for example## [0.4.3] - 2026-10-07). - Ensure
setup.pyversionexactly matches the release tag version without thevprefix. - Push the version tag (format
vX.Y.Z) to trigger build + GitLab release publication.
The published wheel is the single deployment artifact: it carries the app, the
unit file, and self_update.sh. Nothing in the image layer needs updating for a
normal release — only the wheel path/version it points at.
One release property is worth calling out in the changelog when it applies: a
release that changes install_requires cannot be delivered over the air to a
device without PyPI access, because the offline seeding path installs the wheel
with --no-deps. Such a release needs network on the device or a new image. Keep
dependency bumps deliberate for that reason.
- Start/stop uses
systemctland requires permission. If this service runs as a non-root user, add a sudoers rule or run it under a privileged service account. - Start/stop/restart actions are available from the UI.
QILAB_LOG_LEVELcan be set from the Control tab (DEBUG,ERROR,OFF); saving it restartsqilab.service.- The UI polls
/var/log/qilab/qilab.logevery 100ms and displays the latest lines. - The
Configtab is a schema-driven structured editor for the qilabnodes.yaml. It renders fields, dropdowns, and add/remove controls from qilab'sconfig.schema.json, validates edits against that schema before saving, then writes canonical qilab-dialect YAML and restartsqilab.service. An invalid edit is rejected without touching the live file.- Config path is resolved the way qilab resolves it: it prefers the qilab unit's
QILAB_CONFIG_PATH/QILAB_CONFIG_DIR(read viasystemctl show), then this process's env, then~/.qilab/config/nodes.yaml.QILAB_CONFIG_FILEis deprecated (qilab never read it) and only used as a last resort. The resolved path and its source are shown in the UI. - Schema source: set
QILAB_WEB_SCHEMA_PATH=/opt/qilab/current/config.schema.jsonto track qilab's schema live — the qilab release tarball now shipsconfig.schema.jsonnext to the binary, soupdate_release.sh's symlink swap keeps this path pointing at the schema for whichever qilab version is actually deployed; otherwise a vendored copy bundled with this package is used. The schema cache is reloaded automatically right after a binary update (see/update); use the "Reload schema" button to force a reload at any other time.
- Config path is resolved the way qilab resolves it: it prefers the qilab unit's
- Binary updates are done by uploading a tarball + version from the UI, which executes
update_release.sh <version> <tarball_path>. - Binary updates are root-only: this web control process must run as root, and it executes the update script directly (no sudo fallback).
- Self-updates (of this app) are done by uploading the release wheel + version from the UI's "Update qilab_web_control" section; see Self-update above. Also root-only, and the result isn't reported synchronously — check
self_update.login the UI (or/var/log/qilab-web-control/self_update.log) instead. - The
Profilingtab renders qilab's profiler CSVs as sortable tables, and theData acquisitiontab exposes the DAQ JSONL for download.- Telemetry paths are resolved the way qilab resolves them, and from the qilab unit's environment rather than this process's, since that is what decides where the simulator actually writes:
QILAB_PROFILER_PATH/QILAB_PROFILER_DIRfor the profiler andQILAB_DAQ_PATH/QILAB_DAQ_DIRfor DAQ, falling back to~/.qilab/logs/. Note qilab's own asymmetry:QILAB_PROFILER_PATHis a base with no extension (qilab appends_durations.csv/_events.csv), whileQILAB_DAQ_PATHis a complete filename. Each resolved path and its source are shown in the UI. EnvironmentFile=is understood.systemctl show -p Environmentreports only inlineEnvironment=directives, so the app also reads the unit'sEnvironmentFile=contents and layers them under the inline values, matching systemd's precedence. Without this, a deployment that keeps qilab's paths in a shared file (as the RT image does, in/etc/default/qilab-global-env) would silently resolve every path — config included — to the wrong place.- Profiler data is written once per run, when qilab shuts down gracefully, and each run truncates the previous file. So while qilab is active the tables show the previous run; the page says so, and always shows the file's modification time. A hard kill leaves no new file at all.
- The profiler base name is compiled into qilab (
PROF_SAVE_DATA("profile")), so the app cannot discover it. If it ever differs, setQILAB_WEB_PROFILER_BASE; when the expected file is missing the page names the bases it did find in that directory. - DAQ is opt-in via
QILAB_DAQ=1on the qilab unit — compared against that exact string, sotrue/yescount as disabled. If the sink resolves under$HOMEwhile the qilab unit setsProtectHome=true, it can never write there; the page flags that specific combination. - Optional tuning:
QILAB_WEB_DAQ_PREVIEW_LINES(default 5),QILAB_WEB_DAQ_COUNT_MAX_BYTES(default 8 MiB — above this the record count is estimated from a sample rather than scanning the whole file, and the UI marks it as an estimate).
- Telemetry paths are resolved the way qilab resolves them, and from the qilab unit's environment rather than this process's, since that is what decides where the simulator actually writes:
This server is intentionally minimal and has no authentication, while
binding 0.0.0.0:8000 as root. Anyone who can reach the port can start and stop
qilab, rewrite its config, read logs and research data, and upload a binary or
wheel that then runs as root. Treat network access to the port as root access to
the device: bind it to a trusted interface or a lab-only network, or put it
behind an authenticating reverse proxy, before exposing it to any untrusted
network.
What the app does defend against:
- Cross-site requests. Non-GET requests whose
Origin/Refererdoes not match theHostheader, or that a browser marksSec-Fetch-Site: cross-site, are refused with 403. So a web page an operator has open cannot drive the UI. A reverse proxy in front of the app must forward the originalHostheader. - Path injection through the version field. Versions are restricted to
dot-separated alphanumerics (
0.4.2,v0.4.2,0.5.0rc1), in the routes and again inself_update.sh. - Hijacked upload directories. An upload directory that is a symlink, owned by another user, or group/world-writable is refused.
It does not defend against DNS rebinding, which an authenticating proxy or a host allow-list in front of the app does.
Copyright (c) 2026, Quantum Internet Alliance, Delft University of Technology, Milen Kolev, Stephanie Wehner.
qilab_web_control is released under the Clear BSD License (SPDX: BSD-3-Clause-Clear). This license grants no patent rights. Every source file carries the matching SPDX header.
Dependencies are not bundled with this package. pip installs them from PyPI, and they remain under their own licenses:
- FastAPI and its dependency Starlette: MIT License / BSD 3-Clause License.
- Jinja2: BSD 3-Clause License.
- Uvicorn: BSD 3-Clause License.
- python-multipart: Apache License 2.0.
- PyYAML: MIT License.
- jsonschema: MIT License.
- pytest and HTTPX (tests only, not shipped): MIT License / BSD 3-Clause License.