02 - Domain model
The Deployment is the primary runtime entity; the VM is an implementation detail of the agent. This document lists the entities the API stores, their identifiers and the deployment lifecycle.
- Identifiers
- Entities
- Deployment lifecycle
- Who moves what
- Storage rules
- Ephemeral state
- Row cache
- Acceptance criteria
Identifiers
Every id is a random UUID (version 4) with no prefix, assigned by the API.
Clients never send an id or a status on create. A reference to another
entity is a column named after it, such as deployment_id or user_id,
holding that entity's UUID.
Entities
- User - a person: name,
role(operator,traineeorviewer, used like a group), comment,state(disableduntil an operator enables it, like an agent),password(a scrypt hash, its parameters stored with it) andpassword_changed(0 until the user sets their own password, then when they did). The role says what every link of that user can do. A user owns a secret. See 06. The name is unique. An operator creates users; such a user isenabledat once and has no password, and a user without a password cannot log in. - Agent - a registered host: name, placement labels,
state(disabledwhile it waits for an operator or once one turns it off,enabledafter approval). An agent owns a secret. Its last heartbeat, reported capacity N and accelerator are ephemeral state. - Secret - one row per owner, an agent or a user, stored with its
owner:
owner_id, the livegeneration(0 until the first credential is issued) and apending_generationduring a refresh. No key is stored; signing keys are derived from a master key, and the credential that names them knows itself whether it is due for refresh or expired. See 06. - Lease - the agent's single live lease: its id, the reported capacity N and accelerator, the last heartbeat and the deadline, ephemeral state renewed by heartbeat. A heartbeat after expiry opens a new lease with a new id.
- Image - a base disk registered once by the operator: name as used in
scenario.yaml, URL, digest (sha256, 64 lowercase hex characters). A name is registered once; registering it again is refused with 409. Images must ship cloud-init and qemu-guest-agent. - Scenario - one registered version of a scenario directory: the slug
from
scenario.yaml,version, the parsedscenario.yamldocument, and the file-store URL and digest of its bundle. File contents are not SQL columns; they stay in the bundle, see 03. A registered version is immutable; a change is a new version. - Deployment - scenario version,
image_id, agent, status, reason, timestamps,vm_id, and the resources and timeouts. The image, resources and timeouts are copied from the scenario at create time. It is created for one trainee and together with that trainee's link. - Link - deployment, user, token hash,
opened_aton first open,revoked_at. The link acts with its user's role; a trainee's link owns one recording. A deployment is created with its trainee link and holds one live trainee link at a time; ending the deployment revokes all its links. See 04. - Recording - deployment, trainee link, URL of the asciicast in the file store, size and duration. It is stored on the first open of the trainee link; size and duration are 0 until it is closed. See 05.
- Check - deployment, the link it came through if any, the user who
requested it, status (
pending,passed,failed,error,timeout), exit code, message, detail, times. A deployment has onependingcheck at a time. The agent's result settles it; without one the API settles it astimeoutonce it is overdue, and aserrorwhen the deployment ends. See 01. - DiskCheck - deployment, status, message, time. At most one per deployment: the verdict on the disk of its stopped VM, for the operator only. See 03.
Deployment lifecycle
PENDING -> STARTING -> BOOTSTRAPPING -> READY -> STOPPING -> COMPLETED
| | | ^
| +-------------+----------------------+ operator stop
v
CANCELLED
any non-terminal state -> FAILED
- Any non-terminal state may move to FAILED, always with a reason.
- The operator stops a STARTING, BOOTSTRAPPING or READY deployment: it moves to STOPPING, and the agent destroys the VM.
- Stopping a PENDING deployment moves it to CANCELLED.
- A READY deployment past
session_minutesmoves to STOPPING with reasonexpired. - BOOTSTRAPPING past
bootstrap_secondsmoves to FAILED with reasonbootstrap timeout. - COMPLETED, FAILED and CANCELLED are terminal; nothing leaves them.
- Links open the terminal and checks are accepted only while the deployment is READY.
Who moves what
| From | To | Actor | Trigger |
|---|---|---|---|
| PENDING | STARTING | agent | assigned by heartbeat |
| PENDING | CANCELLED | operator | stop |
| STARTING | BOOTSTRAPPING | agent | guest agent answers guest-ping |
| STARTING | STOPPING | operator | stop |
| BOOTSTRAPPING | READY | agent | marker ok, seed ejected |
| BOOTSTRAPPING | STOPPING | operator | stop |
| READY | STOPPING | operator | stop |
| READY | STOPPING | api | session TTL expired |
| STOPPING | COMPLETED | agent | VM destroyed |
| non-terminal | FAILED | agent | runtime or bootstrap error |
| non-terminal | FAILED | api | lease expired, timeout |
Every write is compare-and-swap on the current status, and for agent writes
on agent_id as well, under a live lease.
The table lives in jatszoter_api.lifecycle as a
python-statemachine
machine: one event per row, each carrying the condition on its actor.
Storage rules
The API runs on any SQL database SQLModel can reach; no database product is named or special-cased anywhere in the code.
- Models are plain SQLModel; Alembic is used for migrations and nothing else.
- No foreign keys, stored procedures, triggers or server-side defaults. References are indexed id columns, and the API keeps them consistent.
- Enums are stored as strings, documents as JSON columns, times as integer seconds since the epoch in UTC.
- A save that breaks a unique key is refused with 409; the driver's error code picks the domain error, and any other database error is passed on.
- Files never go into SQL: recordings and scenario bundles live in the file store and the row keeps their URL, see 05 and 03.
- Nothing that lives by a TTL is a SQL row; see below.
Ephemeral state
State that expires on its own lives in Valkey with a
TTL, and never in SQL. When JATSZOTER_VALKEY_DSN is unset - in smoke runs
and tests always - the API uses an in-memory
fakeredis instead.
- The agent's lease under
lease:<agent id>: its id, the reported capacity N and accelerator, and the last heartbeat, written again with the lease TTL by every heartbeat. - A pending registration under
registration:<agent id>, and its claim code underclaim:<sha256 of the code>, until an operator approves it and the agent claims it, or their TTL runs out. - The index of pending registrations, the sorted set
registrations: each agent id scored by its registration time. A reader drops the ids whose registration has lapsed, so nothing scans the cache to list them. - A link session under
session:<sha256 of the session token>: the link id, for a day, renewed on every use. A revoked link's sessions are left to expire; they open nothing, since every use loads the link row. - The control holder under
console:<deployment id>:control, beside the console tail and with its TTL; absent meanstrainee. See 04. - A live recording's state under
recording:<recording id>:*: how far the console is recorded, the bytes written, the rest of a split UTF-8 sequence and the limit flag; andrecording:of:<deployment id>, the id of the deployment's live recording. All for a day, renewed by every write and deleted when the recording closes. See 05.
No SQL row names a lease: an expired id would only go stale there. A deployment names its agent, an agent write is accepted only while Valkey holds a lease for that agent, and a lapsed lease fails the agent's running deployments before a new one is granted.
Row cache
The same Valkey, or fakeredis without a DSN, caches the few values the hot path reads on every request; SQL stays the source of truth. Entries are signed JSON, see 06.
- Every model inherits
Cached:_get,_set,_ttland_deleteon a full key.Baseoverrides them to keep entries under<key>:<id>, ten seconds by default; its_getrenews a hit and, given a loader, stores what the loader reads on a miss. A child overrides them again or reaches the plain ones throughsuper(). - Only an owner's secret is cached today, under
secret:<owner id>, forverify. Every refresh and every promoted generation drops it, so a superseded generation is never taken for the live one. Nothing scans the cache.