OXYGENHUB
# 82cf6e91d398 oxygenflow / crates / flow-core / src / api.rs
14.0 KiB · Rust · last changed feat(cache): unchanged sources keep their times; claims list the caches held (closes #52, refs #51) 22 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>>,
60
}
61
62
/// A name safe as one path segment: kept readable, with a short hash so
63
/// two names that clean up alike stay apart. The build cache's pool is laid
64
/// out by these (oxygenflow#28).
65
pub fn segment(s: &str) -> String {
66
    let clean: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || "._-".contains(c) { c } else { '_' }).take(48).collect();
67
    format!("{clean}-{}", &blake3::hash(s.as_bytes()).to_hex()[..8])
68
}
69
70
/// Where a repository's job keeps its build caches, as runners report
71
/// them in [`ClaimRequest::caches`] (oxygenflow#51): `repo/job`, each part
72
/// a [`segment`].
73
pub fn cache_slot(repo: &str, job: &str) -> String {
74
    format!("{}/{}", segment(repo), segment(job))
b14aeaae Andy McCall ↶ 75
}
76
ecce3719 Andy McCall ↶ 77
/// A runner's place in the image swarm: the address peers reach it on,
78
/// the site it is in (peers are only ever in the same site), and the
79
/// BLAKE3 of its TLS certificate, which peers check it by.
b14aeaae Andy McCall ↶ 80
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
81
pub struct SwarmAddr {
82
    pub addr: String,
83
    #[serde(default)]
84
    pub site: String,
ecce3719 Andy McCall ↶ 85
    #[serde(default)]
86
    pub cert: String,
b14aeaae Andy McCall ↶ 87
}
88
89
/// `POST /flow/swarm/announce`: a runner starting, continuing or ending a
90
/// fetch of `image`.
91
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92
pub struct Announce {
93
    pub image: String,
94
    /// `fetching`, `done` or `stopped`.
95
    pub state: String,
96
}
97
98
/// The hub's answer to an announce: the peers to ask, and the ticket that
99
/// admits this runner to them.
100
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
101
pub struct Announced {
102
    pub ticket: String,
103
    pub peers: Vec<SwarmPeer>,
104
    /// Announce again after this many seconds while still fetching.
105
    pub interval_s: u64,
106
}
107
108
/// A peer in the swarm for one image.
109
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
110
pub struct SwarmPeer {
111
    pub runner: i64,
112
    pub name: String,
113
    pub addr: String,
ecce3719 Andy McCall ↶ 114
    /// The BLAKE3 of its TLS certificate: a peer that shows another is not
115
    /// this runner.
116
    pub cert: String,
b14aeaae Andy McCall ↶ 117
    /// It holds the whole image (rather than fetching it now).
118
    pub complete: bool,
62829d15 Andy McCall ↶ 119
}
120
121
/// A runner's own measurements, sent with every claim (spec §11.1): cheap
122
/// ones taken each time, the speed from a short benchmark run at start and
123
/// again while idle. Whole numbers, so a claim compares exactly.
124
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
125
pub struct Facts {
126
    /// Free disk where jobs' disks are made, in MiB.
127
    #[serde(default, skip_serializing_if = "Option::is_none")]
128
    pub free_disk_mib: Option<u64>,
129
    /// The 1-minute load average, times 100.
130
    #[serde(default, skip_serializing_if = "Option::is_none")]
131
    pub load_x100: Option<u32>,
132
    /// The benchmark's score: 100 on the reference machine (Earth), twice
133
    /// as fast scoring 200.
134
    #[serde(default, skip_serializing_if = "Option::is_none")]
135
    pub speed: Option<u32>,
136
    /// The speed class, 1 (slow) to 5 (fast); each class twice the one
137
    /// below (spec §11.1).
138
    #[serde(default, skip_serializing_if = "Option::is_none")]
139
    pub speed_class: Option<u8>,
140
    /// The kernel release.
141
    #[serde(default, skip_serializing_if = "Option::is_none")]
142
    pub kernel: Option<String>,
143
}
144
145
impl Facts {
146
    /// The class of a speed score: 3 for the reference machine's 100, each
147
    /// class a factor of two.
148
    pub fn class_of(speed: u32) -> u8 {
149
        match speed {
150
            0..=34 => 1,
151
            35..=69 => 2,
152
            70..=139 => 3,
153
            140..=279 => 4,
154
            _ => 5,
155
        }
156
    }
f98a8f78 Andy McCall ↶ 157
}
158
159
/// A runner's description: shown on the hub's Runners page, and what
160
/// scheduling by label and architecture will match.
161
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
162
pub struct RunnerInfo {
163
    /// `x86_64`, `aarch64`, `riscv64`, …
164
    pub arch: String,
165
    pub os: String,
166
    /// The `flow` version.
167
    pub version: String,
168
    /// From flow.toml `[runner] labels`.
169
    #[serde(default)]
170
    pub labels: Vec<String>,
171
    /// Jobs at once, and the memory (MiB) and vCPUs they share.
172
    pub max_jobs: u32,
173
    #[serde(default)]
174
    pub memory_mib: Option<u32>,
175
    #[serde(default)]
176
    pub cpus: Option<u32>,
177
}
178
179
impl RunnerInfo {
180
    /// This machine and build, with the given labels and capacity.
181
    pub fn here(labels: Vec<String>, max_jobs: u32, memory_mib: Option<u32>, cpus: Option<u32>) -> RunnerInfo {
182
        RunnerInfo {
183
            arch: std::env::consts::ARCH.into(),
184
            os: std::env::consts::OS.into(),
185
            version: env!("CARGO_PKG_VERSION").into(),
186
            labels,
187
            max_jobs,
188
            memory_mib,
189
            cpus,
190
        }
191
    }
192
}
193
194
/// `POST /api/v1/flow/runners/register`: exchange a one-time registration
195
/// token from the hub for a runner credential, binding the runner's
196
/// signing identity to it.
197
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
198
pub struct Register {
199
    pub token: String,
200
    pub name: String,
201
    /// The runner's Oxygen identity, hex of its encoding: its key signs
202
    /// every receipt, and the hub accepts no other for this runner.
203
    pub identity: String,
204
    pub info: RunnerInfo,
205
}
206
207
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
208
pub struct Registered {
209
    pub id: i64,
210
    pub name: String,
211
    /// The runner's bearer token from now on; shown once.
212
    pub credential: String,
e0918b88 Andy McCall 213
}
214
c23d5191 Andy McCall ↶ 215
/// A pinned subrepo to put in the workspace: `repo`'s state `heads`, at `path`.
216
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
217
pub struct SubrepoInput {
218
    pub path: String,
219
    /// `owner/name` on the hub.
220
    pub repo: String,
221
    pub clone_url: String,
222
    pub heads: Vec<String>,
223
}
224
e2c12561 Andy McCall ↶ 225
/// A secret for a job, as the hub supplies it (spec §9): written by the
226
/// runner at `path` in the sandbox, 0400, on an in-memory mount; never in
227
/// the environment, the receipt or an artifact.
228
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
229
pub struct SecretInput {
230
    pub name: String,
231
    pub path: String,
232
    pub value: String,
233
}
234
e0918b88 Andy McCall 235
/// A claimed job: everything the runner needs, nothing more.
236
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
237
pub struct Claim {
238
    pub job_id: i64,
239
    pub run_id: i64,
240
    /// `owner/name`.
241
    pub repo: String,
242
    /// Where to fetch the repository from (`oxy` wire protocol).
243
    pub clone_url: String,
244
    /// Read access to this repository only, until the job finishes.
245
    pub token: String,
246
    /// The state to build, as full change hashes.
247
    pub heads: Vec<String>,
248
    /// Hex BLAKE3 of the canonical pipeline.
249
    pub pipeline_hash: String,
250
    pub job: Job,
251
    pub sandbox: Sandbox,
252
    /// The hub's limit for this job.
253
    pub timeout_s: u64,
c23d5191 Andy McCall ↶ 254
    /// Subrepos the state pins (Oxygen spec §7.8) that the job may read,
255
    /// fetched with the same token and written at their paths.
256
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
257
    pub subrepos: Vec<SubrepoInput>,
c4285535 Andy McCall ↶ 258
    /// The state's hash on the hub (what checks are keyed by).
259
    #[serde(default, skip_serializing_if = "String::is_empty")]
260
    pub state: String,
261
    /// The bookmark the run is for.
262
    #[serde(default, skip_serializing_if = "String::is_empty")]
263
    pub bookmark: String,
264
    /// The run's number within its repository.
265
    #[serde(default)]
266
    pub run_number: i64,
c58efc6a Andy McCall ↶ 267
    /// Variables the hub holds for the repository (its site's, its
268
    /// organisation's and its own, inner overriding outer; oxygenhub#102).
269
    /// Set under `FLOW_*` and under the pipeline's own `env`, so they are
270
    /// part of the job's environment and of its receipt. Never secrets:
271
    /// those are mounted, not put in the environment (spec §9).
272
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
273
    pub env: std::collections::BTreeMap<String, String>,
e2c12561 Andy McCall ↶ 274
    /// The secrets the job declares (`secret` blocks), as the hub holds
275
    /// them for the repository (oxygenhub#107).
276
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
277
    pub secrets: Vec<SecretInput>,
c4285535 Andy McCall ↶ 278
}
279
280
impl Claim {
281
    /// What a job is told about what it builds (docs/m0.md §4):
282
    /// `FLOW_REPO`, `FLOW_STATE`, `FLOW_HEADS` (full hashes joined with
283
    /// `+`), `FLOW_BOOKMARK`, `FLOW_RUN` and `FLOW_JOB`. Unknown values are
284
    /// left out.
285
    pub fn flow_env(&self) -> Vec<(String, String)> {
286
        let mut v = vec![
287
            ("FLOW_REPO".to_string(), self.repo.clone()),
288
            ("FLOW_HEADS".to_string(), self.heads.join("+")),
289
            ("FLOW_JOB".to_string(), self.job.name.clone()),
290
        ];
291
        if !self.state.is_empty() {
292
            v.push(("FLOW_STATE".into(), self.state.clone()));
293
        }
294
        if !self.bookmark.is_empty() {
295
            v.push(("FLOW_BOOKMARK".into(), self.bookmark.clone()));
296
        }
297
        if self.run_number > 0 {
298
            v.push(("FLOW_RUN".into(), self.run_number.to_string()));
299
        }
c58efc6a Andy McCall ↶ 300
        for (k, val) in &self.env {
301
            if !v.iter().any(|(n, _)| n == k) {
302
                v.push((k.clone(), val.clone()));
303
            }
304
        }
c4285535 Andy McCall ↶ 305
        v
306
    }
e0918b88 Andy McCall 307
}
308
309
/// One line of output (docs/m0.md §2).
310
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
311
pub struct Record {
312
    /// Milliseconds since the job started.
313
    pub t: u64,
314
    /// `out`, `err`, `console` (the VM outside the job) or `flow` (the runner).
315
    pub stream: String,
316
    pub text: String,
317
}
318
b11e30f5 Andy McCall ↶ 319
/// `POST /api/v1/flow/jobs/{id}/decline` (spec §11.5): the runner will
320
/// not run a job it was handed, and says why. The hub queues the job
321
/// again and offers it to this runner no more until its profile changes.
322
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
323
pub struct Decline {
324
    pub reason: String,
325
}
326
e0918b88 Andy McCall 327
/// `POST /api/v1/flow/jobs/{id}/log`.
328
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
329
pub struct LogBatch {
330
    pub records: Vec<Record>,
331
}
332
333
/// `POST /api/v1/flow/jobs/{id}/finish`.
334
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
335
pub struct Finish {
336
    /// The job's exit code; absent when the runner could not run it.
337
    pub exit_code: Option<i32>,
338
    pub duration_ms: u64,
339
    pub receipt: Option<Receipt>,
47c1d64d Andy McCall ↶ 340
    /// The receipt bound to the job's state and signed by the runner
341
    /// (Oxygen spec §14.6), hex of the signed receipt's bytes.
342
    #[serde(default, skip_serializing_if = "Option::is_none")]
343
    pub signed_receipt: Option<String>,
e0918b88 Andy McCall 344
    /// Why the runner could not run the job (rootfs missing, VM failed).
345
    pub error: Option<String>,
8f211e20 Andy McCall ↶ 346
    /// The most disk the job's disks and workspace took, in MiB, and the
347
    /// most memory its sandbox held (oxygenflow#41): the hub learns from
348
    /// them what the job's next run needs.
349
    #[serde(default, skip_serializing_if = "Option::is_none")]
350
    pub peak_disk_mib: Option<u64>,
351
    #[serde(default, skip_serializing_if = "Option::is_none")]
352
    pub peak_memory_mib: Option<u64>,
e0918b88 Andy McCall 353
}
354
355
/// Longest text of one record the hub keeps; longer lines are cut.
356
pub const MAX_RECORD_TEXT: usize = 16 * 1024;
357
/// Most records the hub keeps per job; later ones are dropped with a note.
358
pub const MAX_RECORDS: usize = 50_000;