OXYGENHUB
◆ v0.5.2 oxygenflow / crates / flow-core / src / api.rs
19.0 KiB · Rust · last changed A job's finish report says where its time went: image, prepare, run, finish (refs #35) 8 days ago source blame history raw
e0918b88 Andy McCall 1
//! The runner API between OxygenHub and `flow runner` (docs/m0.md §5).
2
//! Both sides use these types, so they cannot drift.
3
4
use crate::pipeline::{Job, Sandbox};
5
use crate::receipt::Receipt;
6
use serde::{Deserialize, Serialize};
7
8
/// `POST /api/v1/flow/claim`.
9
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
10
pub struct ClaimRequest {
11
    /// The runner's name, shown on job pages.
12
    pub runner: String,
13
    /// Tiers this runner can run (`microvm`, `syscall`).
14
    pub runtimes: Vec<String>,
bb643c25 Andy McCall ↶ 15
    /// Memory the runner has free for another job, in MiB: the hub hands
16
    /// out only a job whose sandbox fits. Absent: no limit.
17
    #[serde(default, skip_serializing_if = "Option::is_none")]
18
    pub free_memory_mib: Option<u32>,
19
    /// vCPUs the runner has free for another job. Absent: no limit.
20
    #[serde(default, skip_serializing_if = "Option::is_none")]
21
    pub free_cpus: Option<u32>,
f98a8f78 Andy McCall ↶ 22
    /// What the runner is, for the hub's Runners page.
23
    #[serde(default, skip_serializing_if = "Option::is_none")]
24
    pub info: Option<RunnerInfo>,
8c0a4e3a Andy McCall ↶ 25
    /// The rootfs images the runner holds (BLAKE3 hex, as pipelines pin
26
    /// them): the hub offers only jobs whose image is among them
27
    /// (oxygenhub#73). Absent: the runner does not say, and any job may be
28
    /// offered.
29
    #[serde(default, skip_serializing_if = "Option::is_none")]
30
    pub images: Option<Vec<String>>,
1abadd4a Andy McCall ↶ 31
    /// The architecture of each image it holds, by hash, as the image's
32
    /// manifest records it (spec §11.2): the hub offers a job only to a
33
    /// runner whose architecture its image is for.
34
    #[serde(default, skip_serializing_if = "Option::is_none")]
35
    pub image_archs: Option<std::collections::BTreeMap<String, String>>,
4cf0c99a Andy McCall ↶ 36
    /// Whether the runner fetches a job's image from the hub when it lacks
37
    /// it (oxygenflow#39): the hub may then offer it jobs whose image only
38
    /// the hub holds.
39
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
40
    pub fetches_images: bool,
1daca0b6 Andy McCall ↶ 41
    /// The jobs this runner holds now: running, or finished with a result
42
    /// not yet reported. The hub ends any other job it has as running on
43
    /// this runner, which the runner lost by restarting or never received
44
    /// (oxygenhub#71). Absent: the runner does not say.
45
    #[serde(default, skip_serializing_if = "Option::is_none")]
46
    pub holding: Option<Vec<i64>>,
62829d15 Andy McCall ↶ 47
    /// What the runner measured about itself just now (spec §11.1,
48
    /// docs/jetstream.md §6). Absent: an older runner.
49
    #[serde(default, skip_serializing_if = "Option::is_none")]
50
    pub facts: Option<Facts>,
b14aeaae Andy McCall ↶ 51
    /// Where this runner serves image chunks to its peers (docs/jetstream.md
52
    /// §8.1). Absent: it is not in a swarm.
53
    #[serde(default, skip_serializing_if = "Option::is_none")]
54
    pub swarm: Option<SwarmAddr>,
82cf6e91 Andy McCall ↶ 55
    /// The build caches this runner holds a disk of (oxygenflow#51), each
56
    /// [`cache_slot`] of a repository and job, so the hub can send a job
57
    /// where its cache is. Absent: an older runner.
58
    #[serde(default, skip_serializing_if = "Option::is_none")]
59
    pub caches: Option<Vec<String>>,
1e6b9fb1 Andy McCall ↶ 60
    /// When each of [`ClaimRequest::caches`] last kept a disk, Unix ms, so
61
    /// the hub can send a job to the runner whose copy is freshest when
62
    /// several hold one (oxygenflow#54). Absent: an older runner.
63
    #[serde(default, skip_serializing_if = "Option::is_none")]
64
    pub cache_times: Option<std::collections::BTreeMap<String, u64>>,
82cf6e91 Andy McCall ↶ 65
}
66
67
/// A name safe as one path segment: kept readable, with a short hash so
68
/// two names that clean up alike stay apart. The build cache's pool is laid
69
/// out by these (oxygenflow#28).
70
pub fn segment(s: &str) -> String {
71
    let clean: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || "._-".contains(c) { c } else { '_' }).take(48).collect();
72
    format!("{clean}-{}", &blake3::hash(s.as_bytes()).to_hex()[..8])
73
}
74
75
/// Where a repository's job keeps its build caches, as runners report
76
/// them in [`ClaimRequest::caches`] (oxygenflow#51): `repo/job`, each part
77
/// a [`segment`].
78
pub fn cache_slot(repo: &str, job: &str) -> String {
79
    format!("{}/{}", segment(repo), segment(job))
b14aeaae Andy McCall ↶ 80
}
81
ecce3719 Andy McCall ↶ 82
/// A runner's place in the image swarm: the address peers reach it on,
83
/// the site it is in (peers are only ever in the same site), and the
84
/// BLAKE3 of its TLS certificate, which peers check it by.
b14aeaae Andy McCall ↶ 85
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
86
pub struct SwarmAddr {
87
    pub addr: String,
88
    #[serde(default)]
89
    pub site: String,
ecce3719 Andy McCall ↶ 90
    #[serde(default)]
91
    pub cert: String,
b14aeaae Andy McCall ↶ 92
}
93
94
/// `POST /flow/swarm/announce`: a runner starting, continuing or ending a
95
/// fetch of `image`.
96
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97
pub struct Announce {
98
    pub image: String,
99
    /// `fetching`, `done` or `stopped`.
100
    pub state: String,
101
}
102
103
/// The hub's answer to an announce: the peers to ask, and the ticket that
104
/// admits this runner to them.
105
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
106
pub struct Announced {
107
    pub ticket: String,
108
    pub peers: Vec<SwarmPeer>,
109
    /// Announce again after this many seconds while still fetching.
110
    pub interval_s: u64,
32f54b39 Andy McCall ↶ 111
    /// Whether the hub holds the image's chunks and so may be asked for
112
    /// them (jetstream.md §8.1.1): `Some(false)` for an image only runners
113
    /// hold; absent from a hub that predates it, which always seeds.
114
    #[serde(default, skip_serializing_if = "Option::is_none")]
115
    pub seed: Option<bool>,
116
}
117
118
/// `POST /flow/images/held`: the images a runner holds, by hash
119
/// (oxygenflow#94).
120
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
121
pub struct HeldImages {
122
    pub images: Vec<String>,
123
}
124
125
/// The hub's answer to [`HeldImages`]: those it keeps no manifest for, which
126
/// the runner sends with `PUT /flow/images/{hash}/held`.
127
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
128
pub struct WantedManifests {
129
    pub wanted: Vec<String>,
b14aeaae Andy McCall ↶ 130
}
131
132
/// A peer in the swarm for one image.
133
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
134
pub struct SwarmPeer {
135
    pub runner: i64,
136
    pub name: String,
137
    pub addr: String,
ecce3719 Andy McCall ↶ 138
    /// The BLAKE3 of its TLS certificate: a peer that shows another is not
139
    /// this runner.
140
    pub cert: String,
b14aeaae Andy McCall ↶ 141
    /// It holds the whole image (rather than fetching it now).
142
    pub complete: bool,
62829d15 Andy McCall ↶ 143
}
144
145
/// A runner's own measurements, sent with every claim (spec §11.1): cheap
146
/// ones taken each time, the speed from a short benchmark run at start and
147
/// again while idle. Whole numbers, so a claim compares exactly.
148
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
149
pub struct Facts {
150
    /// Free disk where jobs' disks are made, in MiB.
151
    #[serde(default, skip_serializing_if = "Option::is_none")]
152
    pub free_disk_mib: Option<u64>,
153
    /// The 1-minute load average, times 100.
154
    #[serde(default, skip_serializing_if = "Option::is_none")]
155
    pub load_x100: Option<u32>,
156
    /// The benchmark's score: 100 on the reference machine (Earth), twice
157
    /// as fast scoring 200.
158
    #[serde(default, skip_serializing_if = "Option::is_none")]
159
    pub speed: Option<u32>,
160
    /// The speed class, 1 (slow) to 5 (fast); each class twice the one
161
    /// below (spec §11.1).
162
    #[serde(default, skip_serializing_if = "Option::is_none")]
163
    pub speed_class: Option<u8>,
164
    /// The kernel release.
165
    #[serde(default, skip_serializing_if = "Option::is_none")]
166
    pub kernel: Option<String>,
167
}
168
169
impl Facts {
170
    /// The class of a speed score: 3 for the reference machine's 100, each
171
    /// class a factor of two.
172
    pub fn class_of(speed: u32) -> u8 {
173
        match speed {
174
            0..=34 => 1,
175
            35..=69 => 2,
176
            70..=139 => 3,
177
            140..=279 => 4,
178
            _ => 5,
179
        }
180
    }
f98a8f78 Andy McCall ↶ 181
}
182
183
/// A runner's description: shown on the hub's Runners page, and what
184
/// scheduling by label and architecture will match.
185
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
186
pub struct RunnerInfo {
187
    /// `x86_64`, `aarch64`, `riscv64`, …
188
    pub arch: String,
189
    pub os: String,
190
    /// The `flow` version.
191
    pub version: String,
192
    /// From flow.toml `[runner] labels`.
193
    #[serde(default)]
194
    pub labels: Vec<String>,
195
    /// Jobs at once, and the memory (MiB) and vCPUs they share.
196
    pub max_jobs: u32,
197
    #[serde(default)]
198
    pub memory_mib: Option<u32>,
199
    #[serde(default)]
200
    pub cpus: Option<u32>,
201
}
202
203
impl RunnerInfo {
204
    /// This machine and build, with the given labels and capacity.
205
    pub fn here(labels: Vec<String>, max_jobs: u32, memory_mib: Option<u32>, cpus: Option<u32>) -> RunnerInfo {
206
        RunnerInfo {
207
            arch: std::env::consts::ARCH.into(),
208
            os: std::env::consts::OS.into(),
209
            version: env!("CARGO_PKG_VERSION").into(),
210
            labels,
211
            max_jobs,
212
            memory_mib,
213
            cpus,
214
        }
215
    }
216
}
217
218
/// `POST /api/v1/flow/runners/register`: exchange a one-time registration
219
/// token from the hub for a runner credential, binding the runner's
220
/// signing identity to it.
221
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
222
pub struct Register {
223
    pub token: String,
224
    pub name: String,
225
    /// The runner's Oxygen identity, hex of its encoding: its key signs
226
    /// every receipt, and the hub accepts no other for this runner.
227
    pub identity: String,
228
    pub info: RunnerInfo,
229
}
230
231
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
232
pub struct Registered {
233
    pub id: i64,
234
    pub name: String,
235
    /// The runner's bearer token from now on; shown once.
236
    pub credential: String,
e0918b88 Andy McCall 237
}
238
c23d5191 Andy McCall ↶ 239
/// A pinned subrepo to put in the workspace: `repo`'s state `heads`, at `path`.
240
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
241
pub struct SubrepoInput {
242
    pub path: String,
243
    /// `owner/name` on the hub.
244
    pub repo: String,
245
    pub clone_url: String,
246
    pub heads: Vec<String>,
247
}
248
e2c12561 Andy McCall ↶ 249
/// A secret for a job, as the hub supplies it (spec §9): written by the
250
/// runner at `path` in the sandbox, 0400, on an in-memory mount; never in
251
/// the environment, the receipt or an artifact.
252
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
253
pub struct SecretInput {
254
    pub name: String,
255
    pub path: String,
256
    pub value: String,
257
}
258
e0918b88 Andy McCall 259
/// A claimed job: everything the runner needs, nothing more.
260
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
261
pub struct Claim {
262
    pub job_id: i64,
263
    pub run_id: i64,
264
    /// `owner/name`.
265
    pub repo: String,
266
    /// Where to fetch the repository from (`oxy` wire protocol).
267
    pub clone_url: String,
268
    /// Read access to this repository only, until the job finishes.
269
    pub token: String,
270
    /// The state to build, as full change hashes.
271
    pub heads: Vec<String>,
272
    /// Hex BLAKE3 of the canonical pipeline.
273
    pub pipeline_hash: String,
274
    pub job: Job,
275
    pub sandbox: Sandbox,
276
    /// The hub's limit for this job.
277
    pub timeout_s: u64,
c23d5191 Andy McCall ↶ 278
    /// Subrepos the state pins (Oxygen spec §7.8) that the job may read,
279
    /// fetched with the same token and written at their paths.
280
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
281
    pub subrepos: Vec<SubrepoInput>,
c4285535 Andy McCall ↶ 282
    /// The state's hash on the hub (what checks are keyed by).
283
    #[serde(default, skip_serializing_if = "String::is_empty")]
284
    pub state: String,
285
    /// The bookmark the run is for.
286
    #[serde(default, skip_serializing_if = "String::is_empty")]
287
    pub bookmark: String,
288
    /// The run's number within its repository.
289
    #[serde(default)]
290
    pub run_number: i64,
c58efc6a Andy McCall ↶ 291
    /// Variables the hub holds for the repository (its site's, its
292
    /// organisation's and its own, inner overriding outer; oxygenhub#102).
293
    /// Set under `FLOW_*` and under the pipeline's own `env`, so they are
294
    /// part of the job's environment and of its receipt. Never secrets:
295
    /// those are mounted, not put in the environment (spec §9).
296
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
297
    pub env: std::collections::BTreeMap<String, String>,
e2c12561 Andy McCall ↶ 298
    /// The secrets the job declares (`secret` blocks), as the hub holds
299
    /// them for the repository (oxygenhub#107).
300
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
301
    pub secrets: Vec<SecretInput>,
46fe2c81 Andy McCall ↶ 302
    /// The job's inputs (oxygenflow#58), in the job's order, each with the
303
    /// blob the producing job's receipt names: fetched from
304
    /// `/api/v1/flow/jobs/{job_id}/inputs/{index}` and checked against it.
305
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
306
    pub inputs: Vec<ArtifactInput>,
1b160d37 Andy McCall ↶ 307
    /// How long the job is expected to take, from its recent passing runs,
308
    /// when the hub has learned it (#76): a runner leaving with some notice
309
    /// finishes a job whose rest fits it and hands back the others.
310
    #[serde(default, skip_serializing_if = "Option::is_none")]
311
    pub expected_ms: Option<u64>,
f201e1bc Andy McCall ↶ 312
    /// The checkpoint an earlier attempt saved before its runner handed the
313
    /// job back (oxygenflow#77): the blob to fetch from
314
    /// `/api/v1/flow/jobs/{job_id}/checkpoint` and check, then restore
315
    /// before the job runs.
316
    #[serde(default, skip_serializing_if = "Option::is_none")]
317
    pub checkpoint: Option<String>,
46fe2c81 Andy McCall ↶ 318
}
319
320
/// An artifact a job takes (oxygenflow#58), as the hub hands it over.
321
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
322
pub struct ArtifactInput {
323
    pub artifact: String,
324
    /// The producing job.
325
    pub from: String,
326
    /// Where it goes, under `/src`.
327
    pub path: String,
328
    /// The Oxygen blob reference its receipt names, hex.
329
    pub blob: String,
c4285535 Andy McCall ↶ 330
}
331
332
impl Claim {
333
    /// What a job is told about what it builds (docs/m0.md §4):
334
    /// `FLOW_REPO`, `FLOW_STATE`, `FLOW_HEADS` (full hashes joined with
335
    /// `+`), `FLOW_BOOKMARK`, `FLOW_RUN` and `FLOW_JOB`. Unknown values are
336
    /// left out.
337
    pub fn flow_env(&self) -> Vec<(String, String)> {
338
        let mut v = vec![
339
            ("FLOW_REPO".to_string(), self.repo.clone()),
340
            ("FLOW_HEADS".to_string(), self.heads.join("+")),
341
            ("FLOW_JOB".to_string(), self.job.name.clone()),
342
        ];
343
        if !self.state.is_empty() {
344
            v.push(("FLOW_STATE".into(), self.state.clone()));
345
        }
346
        if !self.bookmark.is_empty() {
347
            v.push(("FLOW_BOOKMARK".into(), self.bookmark.clone()));
348
        }
349
        if self.run_number > 0 {
350
            v.push(("FLOW_RUN".into(), self.run_number.to_string()));
351
        }
c58efc6a Andy McCall ↶ 352
        for (k, val) in &self.env {
353
            if !v.iter().any(|(n, _)| n == k) {
354
                v.push((k.clone(), val.clone()));
355
            }
356
        }
c4285535 Andy McCall ↶ 357
        v
358
    }
e0918b88 Andy McCall 359
}
360
361
/// One line of output (docs/m0.md §2).
362
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
363
pub struct Record {
364
    /// Milliseconds since the job started.
365
    pub t: u64,
366
    /// `out`, `err`, `console` (the VM outside the job) or `flow` (the runner).
367
    pub stream: String,
368
    pub text: String,
369
}
370
b11e30f5 Andy McCall ↶ 371
/// `POST /api/v1/flow/jobs/{id}/decline` (spec §11.5): the runner will
372
/// not run a job it was handed, and says why. The hub queues the job
373
/// again and offers it to this runner no more until its profile changes.
374
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
375
pub struct Decline {
376
    pub reason: String,
377
}
378
732b4de4 Andy McCall ↶ 379
/// `GET /api/v1/flow/runner/update` (docs/jetstream.md §8.2, oxygenhub#322):
380
/// the update policy that applies to the asking runner, and the build it
381
/// should run when that is not the one it runs.
382
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
383
pub struct UpdateAnswer {
384
    pub policy: UpdatePolicy,
385
    /// The build to install now; none when updates are off, the runner is
386
    /// up to date, it is outside its window, or no build suits it.
387
    #[serde(default)]
388
    pub offer: Option<UpdateOffer>,
389
    /// Why there is no offer, or what the offer is for, in words.
390
    #[serde(default)]
391
    pub note: String,
392
}
393
394
/// A runner's update policy once every level is applied (nearest wins).
395
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
396
pub struct UpdatePolicy {
397
    /// Off unless some level turned it on.
398
    pub updates: bool,
399
    /// `stable`, `canary`, or a version such as `0.3.4`.
400
    pub channel: String,
401
    /// Hours (UTC) in which it may update, from and to, when limited.
402
    #[serde(default)]
403
    pub window: Option<(u8, u8)>,
404
    /// Fetch and check, say what would happen, do not swap.
405
    #[serde(default)]
406
    pub dry_run: bool,
407
    /// Where each setting came from: `site`, `owner acme`, `group canary`,
408
    /// `runner`, or `default`, in the order updates, channel, window, dry run.
409
    #[serde(default)]
410
    pub from: Vec<String>,
411
}
412
413
/// A build to install: a release asset of the site's runner releases.
414
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
415
pub struct UpdateOffer {
416
    pub version: String,
417
    pub tag: String,
418
    /// The asset's name, `flow-linux-x86_64`.
419
    pub asset: String,
420
    /// Where to download it, relative to the hub.
421
    pub url: String,
422
    /// Its BLAKE3, as the hub stores and attests it: the download must match.
423
    pub blake3: String,
424
    pub size: u64,
425
    /// Older than the runner's build: a pinned channel going back.
426
    #[serde(default)]
427
    pub older: bool,
428
}
429
430
/// `POST /api/v1/flow/runner/update`: what the runner did about its update,
431
/// shown on its page.
432
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
433
pub struct UpdateReport {
434
    pub note: String,
435
}
436
e0918b88 Andy McCall 437
/// `POST /api/v1/flow/jobs/{id}/log`.
438
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
439
pub struct LogBatch {
440
    pub records: Vec<Record>,
441
}
442
443
/// `POST /api/v1/flow/jobs/{id}/finish`.
444
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
445
pub struct Finish {
446
    /// The job's exit code; absent when the runner could not run it.
447
    pub exit_code: Option<i32>,
448
    pub duration_ms: u64,
449
    pub receipt: Option<Receipt>,
47c1d64d Andy McCall ↶ 450
    /// The receipt bound to the job's state and signed by the runner
451
    /// (Oxygen spec §14.6), hex of the signed receipt's bytes.
452
    #[serde(default, skip_serializing_if = "Option::is_none")]
453
    pub signed_receipt: Option<String>,
e0918b88 Andy McCall 454
    /// Why the runner could not run the job (rootfs missing, VM failed).
455
    pub error: Option<String>,
8f211e20 Andy McCall ↶ 456
    /// The most disk the job's disks and workspace took, in MiB, and the
457
    /// most memory its sandbox held (oxygenflow#41): the hub learns from
458
    /// them what the job's next run needs.
459
    #[serde(default, skip_serializing_if = "Option::is_none")]
460
    pub peak_disk_mib: Option<u64>,
461
    #[serde(default, skip_serializing_if = "Option::is_none")]
462
    pub peak_memory_mib: Option<u64>,
1215f05f Andy McCall ↶ 463
    /// Where the job's time went, in milliseconds (oxygenflow#35): `image`
464
    /// (fetching its image, when the runner lacked it), `prepare` (its
465
    /// workspace and disks), `run` (the sandbox, from start to stop) and
466
    /// `finish` (its outputs, caches and receipt).
467
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
468
    pub phases: std::collections::BTreeMap<String, u64>,
e0918b88 Andy McCall 469
}
470
471
/// Longest text of one record the hub keeps; longer lines are cut.
472
pub const MAX_RECORD_TEXT: usize = 16 * 1024;
473
/// Most records the hub keeps per job; later ones are dropped with a note.
474
pub const MAX_RECORDS: usize = 50_000;