| 1 |
# Jetstream |
| 2 |
|
| 3 |
*Design and architecture decision, 2026-10-11 (oxygenflow#38). Builds on |
| 4 |
spec §11, runner capabilities and placement (#36). Crate and CLI: `jet`.* |
| 5 |
|
| 6 |
**Flow** says what to run: a pipeline of hermetic jobs, each in a sandbox, |
| 7 |
with its outputs, reports and signed receipt. **Jet** carries each job to |
| 8 |
where it should run: a fleet of runners on your own machines, on |
| 9 |
Kubernetes or in the cloud, that measure themselves, explain every choice |
| 10 |
they make, and fetch what they need on their own. |
| 11 |
|
| 12 |
## 1. The decision |
| 13 |
|
| 14 |
**Context.** Today OxygenHub holds the job queue and talks to runners |
| 15 |
directly (`/api/v1/flow/claim`, logs, outputs, finish). Runners are |
| 16 |
configured by hand: images are copied onto each machine, labels steer |
| 17 |
jobs, and a job that cannot run anywhere simply waits. Every |
| 18 |
self-hosted CI system has the same shape and the same problems (§2), and |
| 19 |
none of it is specific to Oxygen. |
| 20 |
|
| 21 |
**Decision.** The fleet becomes a service of its own, **Jetstream**: |
| 22 |
|
| 23 |
- Jetstream owns the queue, the runners and their measured facts, |
| 24 |
placement and its explanations, pools (hosts, Kubernetes, cloud |
| 25 |
providers) and images. It knows nothing about repositories. |
| 26 |
- The **forge** owns pipelines, runs, checks, receipts, artifacts, |
| 27 |
reports and the UI. It hands Jetstream jobs and receives their events. |
| 28 |
- **OxygenHub embeds Jetstream** in its own process by default, so nothing |
| 29 |
changes for anyone who never configures it, and can instead point at a |
| 30 |
standalone `jet server` shared by several forges. |
| 31 |
- **Sources are pluggable**: an Oxygen repository is mounted lazily |
| 32 |
through flow-casfs; a Git repository is checked out. |
| 33 |
- **`flow run`** runs a pipeline on your own machine through an |
| 34 |
in-process Jetstream with one local runner: the same code path as CI. |
| 35 |
|
| 36 |
**Consequences.** |
| 37 |
|
| 38 |
- Jetstream can be reused by others, and could later run jobs for forges |
| 39 |
that are not OxygenHub (§12) without a rewrite. |
| 40 |
- One more boundary to keep stable: the forge contract (§4) is versioned. |
| 41 |
- Oxygen's advantage becomes visible: the same fleet runs both kinds of |
| 42 |
source, and an Oxygen job starts without a checkout (§5). |
| 43 |
|
| 44 |
```mermaid |
| 45 |
flowchart LR |
| 46 |
subgraph Forges |
| 47 |
OH["OxygenHub<br/>pipelines · runs · checks<br/>receipts · reports"] |
| 48 |
OTHER["another forge<br/>(an adapter, later)"] |
| 49 |
end |
| 50 |
subgraph JS["Jetstream (jet)"] |
| 51 |
Q["queue"] |
| 52 |
P["placement<br/>+ why"] |
| 53 |
F["runner facts"] |
| 54 |
I["images"] |
| 55 |
D["demand"] |
| 56 |
end |
| 57 |
subgraph Pools |
| 58 |
H["hosts<br/>earth-1 · plex-1 · narvi-1"] |
| 59 |
K["Kubernetes<br/>controller, pod per job"] |
| 60 |
C["cloud<br/>GCP · AWS · Azure"] |
| 61 |
end |
| 62 |
OH -- "jobs" --> Q |
| 63 |
OTHER -. "jobs" .-> Q |
| 64 |
Q -- "events: logs, outputs,<br/>finish, receipt" --> OH |
| 65 |
Q --> P |
| 66 |
F --> P |
| 67 |
H -- "claim + facts" --> P |
| 68 |
K -- "claim + facts" --> P |
| 69 |
C -- "claim + facts" --> P |
| 70 |
I -- "by hash" --> H |
| 71 |
I -- "by hash" --> K |
| 72 |
I -- "by hash" --> C |
| 73 |
D -- "how many, of what" --> K |
| 74 |
D -- "how many, of what" --> C |
| 75 |
``` |
| 76 |
|
| 77 |
## 2. What it fixes |
| 78 |
|
| 79 |
Research into self-hosted runners (GitHub Actions, GitLab, Buildkite, |
| 80 |
Jenkins, Forgejo; 2023–2026) found the same complaints everywhere. Most of |
| 81 |
them are fleet problems, not pipeline problems. |
| 82 |
|
| 83 |
|
| 84 |
|
| 85 |
| Persistent runners leak state; fork PRs and compromised actions reach other jobs' secrets | A fresh microVM per job (or the syscall tier); nothing survives a job | |
| 86 |
| A job waits for hours, then fails, with no reason given | Every waiting job says why, in terms of facts (§7) | |
| 87 |
| Disk fills mid-job; an OOM kill shows up as a "lost runner" | Disk and memory checked on every claim against what the job needed last time; OOM reported as OOM | |
| 88 |
| Images copied to each machine by hand; slow pulls | Images served by hash, fetched by the runner on demand, chunked so a rebuild moves only what changed (§8) | |
| 89 |
| Exact-match labels that sprawl | Facts measured by the runner; labels only steer (§7) | |
| 90 |
| Lost runners and spot reclaims leave jobs hanging | Requeue on a lost runner or a reclaim notice (§11) | |
| 91 |
| Forced runner upgrades with a cut-off date | Runners update themselves between jobs; the contract is versioned | |
| 92 |
| Ephemeral runners start cold, 30–60 s and no caches | Firecracker boots in about a second; images and caches stay warm by hash | |
| 93 |
| Multi-arch builds through QEMU, 5–30× slower; no RISC-V runner at all | Native runners per architecture, RISC-V included (narvi) | |
| 94 |
| No queue, utilisation or cost figures | Queue time, utilisation and cost by job, pool and region, built in (§11) | |
| 95 |
|
| 96 |
## 3. Responsibilities |
| 97 |
|
| 98 |
|
| 99 |
|
| 100 |
| Pipeline from the repository | | ✔ | |
| 101 |
| Runs, jobs, `needs`, the run page | | ✔ | |
| 102 |
| Queue, placement, why a job waits | ✔ | shows it | |
| 103 |
| Runners, their facts and tokens | ✔ | shows them | |
| 104 |
| Pools: hosts, Kubernetes, cloud | ✔ | | |
| 105 |
| Images | ✔ (by hash) | | |
| 106 |
| Secrets and variables | carries them to the job | ✔ (stores them) | |
| 107 |
| Job identity (OIDC) | carries the token | ✔ (issues it) | |
| 108 |
| Logs | streams them | ✔ (keeps them) | |
| 109 |
| Outputs, reports, receipts | carries them | ✔ (keeps, checks, shows) | |
| 110 |
| Checks on a change | | ✔ | |
| 111 |
|
| 112 |
## 4. The contracts |
| 113 |
|
| 114 |
Two contracts, both HTTPS and JSON, versioned from the start. |
| 115 |
|
| 116 |
**Forge ↔ Jetstream.** The forge submits a job and follows it. |
| 117 |
|
| 118 |
- `submit`: the job's spec (sandbox, image hash per architecture, exec, |
| 119 |
outputs, time limit), its **source** (§5), its secrets and OIDC token, |
| 120 |
the forge's run and job ids, and optional steering (`runs-on`, |
| 121 |
`prefer`). |
| 122 |
- `events`: a stream per forge of `placed` (where, and why), `started`, |
| 123 |
`log`, `output` (a file or blob), `finished` (exit code, peak disk and |
| 124 |
memory, duration), `receipt` (signed by the runner), and `waiting` |
| 125 |
(why, when nothing fits). |
| 126 |
- `cancel`, and `queue` (what is waiting, for the forge's pages). |
| 127 |
|
| 128 |
**Runner ↔ Jetstream.** What runners already do with the hub today, moved: |
| 129 |
`claim` (with its facts, §6), `log`, `output`, `finish`, `decline` (with a |
| 130 |
reason). A runner talks only to Jetstream, never to a forge: moving from |
| 131 |
embedded to external Jetstream is a URL change for it. |
| 132 |
|
| 133 |
```mermaid |
| 134 |
sequenceDiagram |
| 135 |
participant Forge as OxygenHub |
| 136 |
participant Jet as Jetstream |
| 137 |
participant R as earth-1 |
| 138 |
Forge->>Jet: submit(job, source, secrets, token) |
| 139 |
Note over Jet: queued: needs x86_64, microvm,<br/>~6 GiB disk (from its last run) |
| 140 |
R->>Jet: claim(facts: idle, 41 GiB free, holds image) |
| 141 |
Jet->>Forge: placed on earth-1 (holds the image, idle, score 100) |
| 142 |
Jet->>R: job |
| 143 |
R->>Jet: log, log, output |
| 144 |
Jet->>Forge: log, log, output |
| 145 |
R->>Jet: finish(exit 0, peak 5.2 GiB disk, 3.1 GiB memory) |
| 146 |
Jet->>Forge: finished + receipt |
| 147 |
``` |
| 148 |
|
| 149 |
## 5. Sources: mounted, or checked out |
| 150 |
|
| 151 |
A job's source says how a runner gets its files. |
| 152 |
|
| 153 |
- **Oxygen** (`oxy://HUB/OWNER/NAME@STATE`): flow-casfs mounts the state |
| 154 |
read-only and reads objects as the job touches them (spec §5.1). Nothing |
| 155 |
is checked out; a job that reads 50 files of a 100,000-file repository |
| 156 |
fetches 50 files. Jobs whose scope a change does not touch keep their |
| 157 |
previous result (spec §6), and receipts name the exact state. |
| 158 |
- **Git** (`git+https://…@COMMIT`): the runner clones or fetches the |
| 159 |
commit into the job's disk before it starts, as every CI system does. |
| 160 |
|
| 161 |
Both run through the same fleet, so the difference is measured, not |
| 162 |
claimed: the job page shows how long the source took. |
| 163 |
|
| 164 |
```mermaid |
| 165 |
flowchart TB |
| 166 |
subgraph O["Oxygen source"] |
| 167 |
O1["job starts"] --> O2["mount state (ms)"] --> O3["read files as used"] |
| 168 |
end |
| 169 |
subgraph G["Git source"] |
| 170 |
G1["clone / fetch commit"] --> G2["check out every file"] --> G3["job starts"] |
| 171 |
end |
| 172 |
``` |
| 173 |
|
| 174 |
## 6. Facts: runners know what they are |
| 175 |
|
| 176 |
A runner measures itself, and sends what it measured **with every claim**. |
| 177 |
Idle runners claim every few seconds, so Jetstream always holds every idle |
| 178 |
runner's facts from moments ago; there is no separate audit channel to go |
| 179 |
stale. |
| 180 |
|
| 181 |
|
| 182 |
|
| 183 |
| Architecture, CPU model, cores | `uname`, `/proc/cpuinfo` | at start | |
| 184 |
| Tiers: microvm (KVM opened, not assumed), syscall (Landlock ABI), wasm | probing | at start, then hourly | |
| 185 |
| Memory free, disk free where jobs run | `/proc/meminfo`, `statvfs` | every claim | |
| 186 |
| Load, jobs running | `/proc/loadavg`, its own state | every claim | |
| 187 |
| Images held | its image store | every claim | |
| 188 |
| Speed score | a fixed few-second benchmark (hashing, compression, a small compile), normalised so earth-1 ≈ 100 | at start, then about every 15 minutes while idle | |
| 189 |
| Where it runs | the pool: host, cluster and node, or cloud, region, instance type, spot | at start | |
| 190 |
|
| 191 |
The score is checked against reality: Jetstream compares each runner's |
| 192 |
claimed speed with how fast its jobs actually ran, and ranks a runner |
| 193 |
whose real timings drift (thermal throttling, noisy neighbours) by what |
| 194 |
it achieved. |
| 195 |
|
| 196 |
## 7. Placement: requirements, ranking, and why |
| 197 |
|
| 198 |
**A job's requirements are mostly derived**, never typed by the user: |
| 199 |
|
| 200 |
- architecture from its image; |
| 201 |
- tier from its sandbox; |
| 202 |
- disk and memory **learned from its own past runs** (peak plus |
| 203 |
headroom); a new job goes to the roomiest runner the first time; |
| 204 |
- an explicit floor when the pipeline gives one. |
| 205 |
|
| 206 |
**Placement** filters, then ranks: |
| 207 |
|
| 208 |
1. **Filter on hard facts:** architecture, tier, enough free disk and |
| 209 |
memory now, steering that requires a pool or label. |
| 210 |
2. **Rank on soft ones:** already holds the image (warm), idle, speed |
| 211 |
score, the shortest estimated finish. |
| 212 |
3. **A short hold for the best fit:** when the runner asking is a poor |
| 213 |
fit and a better one claimed seconds ago, the job is held a few seconds |
| 214 |
for it, then goes to whoever is next. |
| 215 |
4. **The runner has the last word:** it checks again on accept and |
| 216 |
declines with a reason if its disk filled meanwhile. |
| 217 |
|
| 218 |
**Every decision explains itself**, on the job and through the API: |
| 219 |
|
| 220 |
> ran on **earth-1**: holds the image, idle, score 100, estimated 4 min |
| 221 |
|
| 222 |
> **waiting**: needs ~12 GiB disk (its last run used 10.4); plex-1 has 9; |
| 223 |
> earth-1 is busy until about 11:40 |
| 224 |
|
| 225 |
**Labels only steer.** `runs-on "plex"` pins, `prefer "fast"` biases. A |
| 226 |
user who never writes one never needs to. |
| 227 |
|
| 228 |
## 8. Images |
| 229 |
|
| 230 |
- **Content-addressed.** An image is an ext4 root file system named by |
| 231 |
its BLAKE3 hash (`cas://blob/…`), as today. A sandbox may name one per |
| 232 |
architecture. |
| 233 |
- **Served by Jetstream.** A runner that lacks an image fetches it by hash |
| 234 |
and verifies it; nobody copies images between machines. Images are |
| 235 |
chunked, so a rebuilt image moves only its changed chunks. |
| 236 |
- **Built by Flow.** An image is a job's output: a project's |
| 237 |
`.flow/image/` becomes a job, its image is kept and served, and the |
| 238 |
sandbox's pin follows it (a proposal: "image changed: af3c… → 91be…"). |
| 239 |
- **Architecture recorded at build**, so an x86 image never reaches a |
| 240 |
RISC-V runner. |
| 241 |
|
| 242 |
### 8.1 The swarm: runners share images |
| 243 |
|
| 244 |
Runners that are configured for it fetch image chunks from each other as |
| 245 |
well as from the hub (oxygenflow #47, oxygenhub #144). Fifty fresh |
| 246 |
runners asking for the same 2 GiB image then cost the hub about one copy, |
| 247 |
not fifty, and a site behind a thin link pulls each chunk across it once. |
| 248 |
|
| 249 |
- **Trust comes from the hashes.** The manifest comes from the hub, and |
| 250 |
every chunk is checked against its name before it is kept. A peer can |
| 251 |
fail to help; it can never change what a job runs. |
| 252 |
- **The hub is the tracker.** A runner reports its swarm address, its site |
| 253 |
and its certificate's fingerprint with every claim. `POST /api/v1/flow/swarm/announce {image, state}` |
| 254 |
(`fetching`, `done` or `stopped`) returns the peers in the same site that |
| 255 |
hold the image or are fetching it, and a **ticket**. |
| 256 |
- **Tickets admit peers.** A ticket names the runner, its site and an |
| 257 |
expiry (15 minutes), signed with the hub key that `/.well-known/oxyhub-key` |
| 258 |
publishes. A runner shows its ticket once to each peer |
| 259 |
(`POST /swarm/v1/hello`) and gets a session for the ticket's life; no |
| 260 |
other runner and no account can take chunks from a peer. |
| 261 |
- **Encrypted, and pinned by the hub.** Each runner makes its own TLS |
| 262 |
certificate on first use (`swarm/cert.der`, its key readable by the |
| 263 |
runner alone) and keeps it, so its fingerprint (BLAKE3 of the |
| 264 |
certificate) holds across restarts. The tracker hands each peer out with |
| 265 |
that fingerprint, and a fetcher talks only to a peer showing that |
| 266 |
certificate and proving its key in the handshake; no CA is involved. A |
| 267 |
peer at another address, or a stranger at a peer's, gets nothing and |
| 268 |
sees nothing: chunks, tickets and sessions all travel inside TLS. |
| 269 |
- **The peer protocol** is HTTPS on the runner's swarm address: |
| 270 |
- `POST /swarm/v1/hello` with the ticket: a session token. |
| 271 |
- `GET /swarm/v1/images/{image}/have`: `complete`, or two bit maps over |
| 272 |
the manifest's needed list (`Manifest::needed`), in its order: the |
| 273 |
chunks it has, then the chunks it is fetching now. |
| 274 |
- `GET /swarm/v1/chunks/{chunk}`: the chunk's bytes, from the chunk |
| 275 |
cache or read out of a whole image; `503` when the peer is at its |
| 276 |
connection limit, and the fetcher asks it again shortly. |
| 277 |
- **Rarest first.** The fetcher refreshes every peer's maps twice a |
| 278 |
second and asks each peer, four requests at a time, for the chunk the |
| 279 |
fewest peers hold. The hub is a seed that holds everything: it is asked |
| 280 |
only for chunks no peer has or is fetching, or any chunk once the peers |
| 281 |
have made no progress for two seconds. Runners that start together so |
| 282 |
split the hub's chunks between them rather than each taking them all. When every missing chunk is already asked |
| 283 |
for, idle workers ask a second source for it (the endgame; the hub joins |
| 284 |
it only once the peers stall). A peer that |
| 285 |
fails is dropped and its chunks go back to the pool. |
| 286 |
- **Serving while fetching.** A chunk is offered to others as soon as it |
| 287 |
is checked and kept, so runners starting together share the load. |
| 288 |
- **Bounded.** A runner serves at most 16 connections and `upload_mib_s` |
| 289 |
(default 50 MiB/s), so serving never starves its own jobs. |
| 290 |
- **Configured, never assumed.** Without a `[runner.swarm]` section a |
| 291 |
runner neither listens nor asks peers. Peers are not offered across |
| 292 |
sites, so a site is the network its runners can reach each other on. |
| 293 |
|
| 294 |
In flow.toml (or as `[swarm]` in runner.toml, which stands in for the |
| 295 |
`[runner]` section): |
| 296 |
|
| 297 |
```toml |
| 298 |
[runner.swarm] |
| 299 |
listen = "0.0.0.0:7401" # where peers reach this runner |
| 300 |
advertise = "10.0.0.5:7401" # the address peers are given (default: listen) |
| 301 |
site = "home" # peers are only ever in the same site |
| 302 |
upload_mib_s = 50 |
| 303 |
``` |
| 304 |
|
| 305 |
### 8.2 Runners update themselves |
| 306 |
|
| 307 |
Proposed 2026-10-25 from Andy's experience: keeping a large fleet's |
| 308 |
runners up to date by hand is one of the biggest costs of running CI. |
| 309 |
Runners fetch new runner builds the way they fetch images, from each other |
| 310 |
through the swarm, check them as strictly as a release, and swap |
| 311 |
themselves in without losing work, under a policy the organisation sets, |
| 312 |
testing on a few runners before the rest. |
| 313 |
|
| 314 |
**A runner build** is a `flow` binary from an OxygenFlow release, one per |
| 315 |
target (linux-x86_64, linux-arm64, linux-riscv64; Windows and macOS when |
| 316 |
those runners exist), built by Flow from the tagged state with a signed |
| 317 |
receipt, as oxy's and OxygenHub's releases are. The hub serves it as a |
| 318 |
blob with a chunk manifest, so the swarm (§8.1) carries it unchanged: one |
| 319 |
runner of a site fetches each chunk from the hub and the rest from it. |
| 320 |
|
| 321 |
**Off unless chosen, nearest wins.** The policy is set for the site, an |
| 322 |
organisation, a test group and a runner; the nearest setting wins: |
| 323 |
|
| 324 |
- `updates`: `off` (the default) or `on`; |
| 325 |
- `channel`: `stable`, `canary`, or a pinned version (`0.3.4`); |
| 326 |
- `window`: hours in which a runner may update (outside them it waits); |
| 327 |
- `dry_run`: fetch and check, say what would happen, do not swap. |
| 328 |
|
| 329 |
A runner's own `flow.toml` has the last word: `[runner.update] allow = |
| 330 |
false` refuses every update whatever the hub says, so a site can hold a |
| 331 |
machine back while it tests. |
| 332 |
|
| 333 |
**Test groups and waves.** A test group is a named set of runners: chosen |
| 334 |
by name, by label or by pool. A rollout is a release and its waves, in |
| 335 |
order; each wave is a group and its share of it, **a percentage or chosen |
| 336 |
runners**. A percentage is taken by a stable hash of each runner's id, so |
| 337 |
"10% of build-farm" is the same machines every rollout, the ones the |
| 338 |
organisation watches. A wave starts when the one before has been healthy |
| 339 |
for its soak time (say 24 hours) or when an admin promotes it. For |
| 340 |
example: canary (two chosen runners), then build-farm at 10%, then |
| 341 |
build-farm at 100%, then everyone. |
| 342 |
|
| 343 |
**Healthy** means the updated runners came back on the new version within |
| 344 |
ten minutes, claim jobs, and their jobs' infrastructure errors (`lost`, |
| 345 |
`error`, declines) over the soak are no worse than the same runners' on |
| 346 |
the version before, over enough jobs to tell. A wave that is not healthy |
| 347 |
halts the rollout, and its runners roll back. Job failures from the |
| 348 |
pipelines themselves do not count. |
| 349 |
|
| 350 |
**On the runner,** in order: |
| 351 |
|
| 352 |
1. The hub's answer to a claim or heartbeat names an update when the |
| 353 |
policy says so: the version, its blob and manifest, its receipt. |
| 354 |
2. Fetch the blob through the swarm (hub as seed); every chunk checked |
| 355 |
against the manifest from the hub. |
| 356 |
3. Check it as a release: its BLAKE3 matches the release asset the hub |
| 357 |
attests; the receipt is signed by an `owned` build runner (trust |
| 358 |
classes, #79) and names the tagged state; the target is this machine's. |
| 359 |
A peer can only serve bytes; it cannot change what is installed. |
| 360 |
4. Stage it (`bin/flow.new`) and run its self-check (`flow.new tiers`, |
| 361 |
`--version`). |
| 362 |
5. Drain with notice (`leave_within`, §11.5.2 of the spec): finish what |
| 363 |
fits, hand back the rest with its checkpoints. |
| 364 |
6. Swap: `flow` → `flow.prev`, `flow.new` → `flow`, and restart. |
| 365 |
7. Prove it: the first claim reports the new version. A runner that will |
| 366 |
not start, or that the hub does not hear from within ten minutes, goes |
| 367 |
back to `flow.prev` (a start counter in the runner's home: three |
| 368 |
failed starts after an update restore the previous build). |
| 369 |
8. The runner's page shows its version, channel, group, last update and |
| 370 |
why; the audit log records every update and rollback. |
| 371 |
|
| 372 |
**Phones** take runner updates with the app's releases (Android updates an |
| 373 |
app only through its store or an install), so they follow the app. |
| 374 |
|
| 375 |
**Order of work:** (1) the runner side, with a local source (`flow runner |
| 376 |
update --from FILE`): check, stage, drain, swap, prove, roll back (done, |
| 377 |
#85: spec §11.5.3, Linux and Windows); runners asking the hub and taking |
| 378 |
its offer (done); (2) the |
| 379 |
policy on the hub (opt in and out, channels, windows, dry run) and the |
| 380 |
update a runner is offered (done: oxygenhub#322, for the site and for each |
| 381 |
organisation's own runners, `GET /api/v1/flow/runner/update`); (3) test groups (done: chosen or a percentage), waves and health (a |
| 382 |
build that rolls back on one runner is held for every other, oxygenhub#326; |
| 383 |
waves that advance by themselves after a soak are not built yet); (4) the |
| 384 |
swarm as transport; (5) Admin › Runners › Updates (done) and `oh runner |
| 385 |
update`. |
| 386 |
|
| 387 |
## 9. Pools |
| 388 |
|
| 389 |
A **pool** is one place runners come from. All pools report the same |
| 390 |
facts and follow the same placement. |
| 391 |
|
| 392 |
```mermaid |
| 393 |
flowchart TB |
| 394 |
J["Jetstream"] |
| 395 |
subgraph Hosts["Host pool"] |
| 396 |
E["earth-1<br/>x86_64 · microvm"] |
| 397 |
PL["plex-1<br/>x86_64 · microvm"] |
| 398 |
N["narvi-1<br/>riscv64 · syscall"] |
| 399 |
end |
| 400 |
subgraph K8s["Kubernetes pool"] |
| 401 |
CTL["jet controller"] |
| 402 |
KP1["KVM node pool<br/>Firecracker in pod"] |
| 403 |
KP2["Kata / gVisor<br/>RuntimeClass"] |
| 404 |
KP3["ordinary pods<br/>syscall tier"] |
| 405 |
CTL --> KP1 |
| 406 |
CTL --> KP2 |
| 407 |
CTL --> KP3 |
| 408 |
end |
| 409 |
subgraph Cloud["Cloud pool (europe-west2)"] |
| 410 |
PRV["jet provider"] |
| 411 |
VM1["warm VM, nested KVM<br/>several microVMs"] |
| 412 |
VM2["VM per job<br/>syscall tier"] |
| 413 |
PRV --> VM1 |
| 414 |
PRV --> VM2 |
| 415 |
end |
| 416 |
J --> Hosts |
| 417 |
J --> CTL |
| 418 |
J --> PRV |
| 419 |
``` |
| 420 |
|
| 421 |
**Hosts.** Machines you own run `jet runner` (today's `flow runner`). |
| 422 |
|
| 423 |
**Kubernetes.** `jet controller` runs in the cluster, claims for it, and |
| 424 |
creates **one pod per job**, the pattern every major CI system uses on |
| 425 |
Kubernetes. The tier follows what each node pool offers: |
| 426 |
|
| 427 |
|
| 428 |
|
| 429 |
| `/dev/kvm` through a device plugin (a tainted pool) | microvm: Firecracker inside the pod | |
| 430 |
| A Kata or gVisor RuntimeClass (AKS Pod Sandboxing, GKE Sandbox) | syscall, inside the platform's sandbox | |
| 431 |
| Nothing special (GKE Autopilot included) | syscall, in an unprivileged pod | |
| 432 |
|
| 433 |
Dedicated KVM hosts beside a cluster stay first class: that is how |
| 434 |
microVM CI is run in practice. |
| 435 |
|
| 436 |
**Cloud providers.** `jet provider` creates and removes VMs in a cloud |
| 437 |
account, keyless through OIDC (OxygenHub is already an issuer, OxygenHub spec |
| 438 |
§13.5): |
| 439 |
|
| 440 |
- **Packing first:** a warm 8–16 vCPU VM with nested KVM runs several |
| 441 |
microVM jobs, saving the 30–60 s boot, the cold disk and per-VM minimum |
| 442 |
charges. Nested KVM: GCP Intel types and N4D, AWS C7i/C8i and newer |
| 443 |
(since 2026), Azure Dsv5. |
| 444 |
- **One VM per job** with the syscall tier inside is the fallback where |
| 445 |
nested KVM is not offered. |
| 446 |
- **Regions are pinned** per pool and never fall back outside the set: |
| 447 |
for the UK, `europe-west2`, `uksouth`, `eu-west-2`. Every job records |
| 448 |
where it ran. |
| 449 |
- **Spot**, with on-demand fallback; a reclaim notice requeues the job. |
| 450 |
- **Scale-in** only of idle VMs near the end of their billing increment. |
| 451 |
- Build order: GCP, then AWS, then Azure. |
| 452 |
|
| 453 |
The provider interface: `create(n, spec)`, `delete(ids)`, `list()`, |
| 454 |
`health(id)`, `capabilities()`, `quota()`. |
| 455 |
|
| 456 |
## 10. Demand and autoscaling |
| 457 |
|
| 458 |
Jetstream answers **demand by pull**, so nothing needs a way into your |
| 459 |
network: |
| 460 |
|
| 461 |
- `GET /demand?pool=…` (long-poll): waiting jobs grouped by architecture, |
| 462 |
tier and size, and the age of the oldest. Kubernetes pools scale on it |
| 463 |
through a KEDA scaler; cloud providers through their own loop. |
| 464 |
- **One-shot runner tokens** for ephemeral runners and pods: issued for |
| 465 |
one start, never long-lived. |
| 466 |
|
| 467 |
## 11. Reliability and running it |
| 468 |
|
| 469 |
- **Lost runner:** its jobs are requeued after a short grace; a spot |
| 470 |
reclaim notice requeues at once. |
| 471 |
- **OOM** is reported as OOM, with the job's peak, and raises its learned |
| 472 |
memory need. |
| 473 |
- **Updates:** a runner told of a newer version updates itself between |
| 474 |
jobs. No cut-off date; the contract is versioned and old runners keep |
| 475 |
working until they update. |
| 476 |
- **Figures:** queue time (the single most useful CI metric), utilisation |
| 477 |
per runner and pool, and cost per job, pool and region where a provider |
| 478 |
knows its prices. |
| 479 |
|
| 480 |
## 12. Embedded or on its own |
| 481 |
|
| 482 |
```mermaid |
| 483 |
flowchart LR |
| 484 |
subgraph A["Embedded (the default)"] |
| 485 |
OH1["OxygenHub process<br/>+ jet inside"] |
| 486 |
R1["runners"] --> OH1 |
| 487 |
end |
| 488 |
subgraph B["On its own"] |
| 489 |
OH2["OxygenHub"] --> JS2["jet server"] |
| 490 |
OH3["another OxygenHub"] --> JS2 |
| 491 |
R2["runners"] --> JS2 |
| 492 |
end |
| 493 |
``` |
| 494 |
|
| 495 |
- **Embedded:** the same crate in OxygenHub's process, with no |
| 496 |
configuration. This is what OxygenHub does unless told otherwise. |
| 497 |
- **On its own:** `[flow] jetstream = "https://jet.example"` in the hub's |
| 498 |
configuration points it at a standalone `jet server`; several hubs may |
| 499 |
share one fleet. |
| 500 |
- **Other forges, later:** the forge contract (§4) and Git sources (§5) |
| 501 |
are what an adapter needs, such as one that runs GitLab or GitHub jobs |
| 502 |
on Jetstream runners. That would be a contained piece of work, not a |
| 503 |
rewrite; it is not planned yet. |
| 504 |
|
| 505 |
## 13. Running a pipeline on your own machine |
| 506 |
|
| 507 |
```sh |
| 508 |
flow run # every job of .flow/pipeline.kdl, on this directory's files |
| 509 |
flow run test # one job, and what it needs |
| 510 |
flow run --var X=1 --secret TOKEN=… # what the hub would supply |
| 511 |
``` |
| 512 |
|
| 513 |
`flow run` exists today: it runs the pipeline's jobs in the same sandboxes |
| 514 |
CI uses, before anything is pushed. With Jetstream it becomes an |
| 515 |
in-process Jetstream with one local runner, the same placement and the |
| 516 |
same code path as CI, so passing locally means passing in CI. With images |
| 517 |
served by Jetstream (§8), the first local run fetches the pinned image by |
| 518 |
hash instead of needing it imported by hand. |
| 519 |
|
| 520 |
## 14. Security |
| 521 |
|
| 522 |
- **Isolation per job:** a microVM where KVM exists, the syscall tier |
| 523 |
(Landlock and seccomp) elsewhere, a platform sandbox where a cluster |
| 524 |
offers one. No job ever shares a Docker daemon or a host socket. |
| 525 |
- **Secrets** are mounted as files in the job and masked in logs (spec |
| 526 |
§9), never written to a runner's disk outside the job. |
| 527 |
- **Cloud VMs** have no cloud identity of their own, the metadata service |
| 528 |
is blocked from inside job microVMs, and job subnets accept nothing |
| 529 |
inbound. |
| 530 |
- **Runner tokens** are per runner, and one-shot for ephemeral ones. |
| 531 |
|
| 532 |
## 15. Order of work |
| 533 |
|
| 534 |
1. **The boundary, inside the hub:** queue, placement and runners behind |
| 535 |
the Jetstream contract in a `jet` crate, embedded; no behaviour change, |
| 536 |
CI proves it. |
| 537 |
2. **Images served by Jetstream**, fetched by hash; images built by Flow |
| 538 |
jobs. |
| 539 |
3. **Facts on every claim, learned requirements, placement with its |
| 540 |
explanation**, on the job page and the queue. |
| 541 |
4. **narvi-1**, the first runner brought up this way (riscv64, syscall). |
| 542 |
5. **`jet server`**, OxygenHub pointing at it. |
| 543 |
6. **The Kubernetes controller**, with a KEDA scaler on `/demand`. |
| 544 |
7. **Cloud providers:** GCP, then AWS, then Azure. |
| 545 |
|
| 546 |
## 16. Open questions |
| 547 |
|
| 548 |
- Whether artifacts and report files travel through Jetstream or go |
| 549 |
straight from the runner to the forge (today: straight to the hub). |
| 550 |
- Multi-tenancy in a shared `jet server`: pools per organisation, and |
| 551 |
who may submit to which. |
| 552 |
- How long the best-fit hold (§7) should be, and whether it should learn. |
| 553 |
- Receipts: the runner signs; whether Jetstream countersigns where it |
| 554 |
placed the job. |