OXYGENHUB
# 8f211e20bea5 oxygenflow / crates / flow-core / src / api.rs
11.4 KiB · Rust · last changed A job's finish carries its peak disk and memory 24 days ago source blame history raw
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>,
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>,
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>,
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>>,
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>>,
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,
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>>,
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>,
51
}
52
53
/// A runner's own measurements, sent with every claim (spec §11.1): cheap
54
/// ones taken each time, the speed from a short benchmark run at start and
55
/// again while idle. Whole numbers, so a claim compares exactly.
56
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
57
pub struct Facts {
58
    /// Free disk where jobs' disks are made, in MiB.
59
    #[serde(default, skip_serializing_if = "Option::is_none")]
60
    pub free_disk_mib: Option<u64>,
61
    /// The 1-minute load average, times 100.
62
    #[serde(default, skip_serializing_if = "Option::is_none")]
63
    pub load_x100: Option<u32>,
64
    /// The benchmark's score: 100 on the reference machine (Earth), twice
65
    /// as fast scoring 200.
66
    #[serde(default, skip_serializing_if = "Option::is_none")]
67
    pub speed: Option<u32>,
68
    /// The speed class, 1 (slow) to 5 (fast); each class twice the one
69
    /// below (spec §11.1).
70
    #[serde(default, skip_serializing_if = "Option::is_none")]
71
    pub speed_class: Option<u8>,
72
    /// The kernel release.
73
    #[serde(default, skip_serializing_if = "Option::is_none")]
74
    pub kernel: Option<String>,
75
}
76
77
impl Facts {
78
    /// The class of a speed score: 3 for the reference machine's 100, each
79
    /// class a factor of two.
80
    pub fn class_of(speed: u32) -> u8 {
81
        match speed {
82
            0..=34 => 1,
83
            35..=69 => 2,
84
            70..=139 => 3,
85
            140..=279 => 4,
86
            _ => 5,
87
        }
88
    }
89
}
90
91
/// A runner's description: shown on the hub's Runners page, and what
92
/// scheduling by label and architecture will match.
93
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
94
pub struct RunnerInfo {
95
    /// `x86_64`, `aarch64`, `riscv64`, …
96
    pub arch: String,
97
    pub os: String,
98
    /// The `flow` version.
99
    pub version: String,
100
    /// From flow.toml `[runner] labels`.
101
    #[serde(default)]
102
    pub labels: Vec<String>,
103
    /// Jobs at once, and the memory (MiB) and vCPUs they share.
104
    pub max_jobs: u32,
105
    #[serde(default)]
106
    pub memory_mib: Option<u32>,
107
    #[serde(default)]
108
    pub cpus: Option<u32>,
109
}
110
111
impl RunnerInfo {
112
    /// This machine and build, with the given labels and capacity.
113
    pub fn here(labels: Vec<String>, max_jobs: u32, memory_mib: Option<u32>, cpus: Option<u32>) -> RunnerInfo {
114
        RunnerInfo {
115
            arch: std::env::consts::ARCH.into(),
116
            os: std::env::consts::OS.into(),
117
            version: env!("CARGO_PKG_VERSION").into(),
118
            labels,
119
            max_jobs,
120
            memory_mib,
121
            cpus,
122
        }
123
    }
124
}
125
126
/// `POST /api/v1/flow/runners/register`: exchange a one-time registration
127
/// token from the hub for a runner credential, binding the runner's
128
/// signing identity to it.
129
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
130
pub struct Register {
131
    pub token: String,
132
    pub name: String,
133
    /// The runner's Oxygen identity, hex of its encoding: its key signs
134
    /// every receipt, and the hub accepts no other for this runner.
135
    pub identity: String,
136
    pub info: RunnerInfo,
137
}
138
139
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
140
pub struct Registered {
141
    pub id: i64,
142
    pub name: String,
143
    /// The runner's bearer token from now on; shown once.
144
    pub credential: String,
145
}
146
147
/// A pinned subrepo to put in the workspace: `repo`'s state `heads`, at `path`.
148
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
149
pub struct SubrepoInput {
150
    pub path: String,
151
    /// `owner/name` on the hub.
152
    pub repo: String,
153
    pub clone_url: String,
154
    pub heads: Vec<String>,
155
}
156
157
/// A secret for a job, as the hub supplies it (spec §9): written by the
158
/// runner at `path` in the sandbox, 0400, on an in-memory mount; never in
159
/// the environment, the receipt or an artifact.
160
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
161
pub struct SecretInput {
162
    pub name: String,
163
    pub path: String,
164
    pub value: String,
165
}
166
167
/// A claimed job: everything the runner needs, nothing more.
168
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
169
pub struct Claim {
170
    pub job_id: i64,
171
    pub run_id: i64,
172
    /// `owner/name`.
173
    pub repo: String,
174
    /// Where to fetch the repository from (`oxy` wire protocol).
175
    pub clone_url: String,
176
    /// Read access to this repository only, until the job finishes.
177
    pub token: String,
178
    /// The state to build, as full change hashes.
179
    pub heads: Vec<String>,
180
    /// Hex BLAKE3 of the canonical pipeline.
181
    pub pipeline_hash: String,
182
    pub job: Job,
183
    pub sandbox: Sandbox,
184
    /// The hub's limit for this job.
185
    pub timeout_s: u64,
186
    /// Subrepos the state pins (Oxygen spec §7.8) that the job may read,
187
    /// fetched with the same token and written at their paths.
188
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189
    pub subrepos: Vec<SubrepoInput>,
190
    /// The state's hash on the hub (what checks are keyed by).
191
    #[serde(default, skip_serializing_if = "String::is_empty")]
192
    pub state: String,
193
    /// The bookmark the run is for.
194
    #[serde(default, skip_serializing_if = "String::is_empty")]
195
    pub bookmark: String,
196
    /// The run's number within its repository.
197
    #[serde(default)]
198
    pub run_number: i64,
199
    /// Variables the hub holds for the repository (its site's, its
200
    /// organisation's and its own, inner overriding outer; oxygenhub#102).
201
    /// Set under `FLOW_*` and under the pipeline's own `env`, so they are
202
    /// part of the job's environment and of its receipt. Never secrets:
203
    /// those are mounted, not put in the environment (spec §9).
204
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
205
    pub env: std::collections::BTreeMap<String, String>,
206
    /// The secrets the job declares (`secret` blocks), as the hub holds
207
    /// them for the repository (oxygenhub#107).
208
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209
    pub secrets: Vec<SecretInput>,
210
}
211
212
impl Claim {
213
    /// What a job is told about what it builds (docs/m0.md §4):
214
    /// `FLOW_REPO`, `FLOW_STATE`, `FLOW_HEADS` (full hashes joined with
215
    /// `+`), `FLOW_BOOKMARK`, `FLOW_RUN` and `FLOW_JOB`. Unknown values are
216
    /// left out.
217
    pub fn flow_env(&self) -> Vec<(String, String)> {
218
        let mut v = vec![
219
            ("FLOW_REPO".to_string(), self.repo.clone()),
220
            ("FLOW_HEADS".to_string(), self.heads.join("+")),
221
            ("FLOW_JOB".to_string(), self.job.name.clone()),
222
        ];
223
        if !self.state.is_empty() {
224
            v.push(("FLOW_STATE".into(), self.state.clone()));
225
        }
226
        if !self.bookmark.is_empty() {
227
            v.push(("FLOW_BOOKMARK".into(), self.bookmark.clone()));
228
        }
229
        if self.run_number > 0 {
230
            v.push(("FLOW_RUN".into(), self.run_number.to_string()));
231
        }
232
        for (k, val) in &self.env {
233
            if !v.iter().any(|(n, _)| n == k) {
234
                v.push((k.clone(), val.clone()));
235
            }
236
        }
237
        v
238
    }
239
}
240
241
/// One line of output (docs/m0.md §2).
242
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
243
pub struct Record {
244
    /// Milliseconds since the job started.
245
    pub t: u64,
246
    /// `out`, `err`, `console` (the VM outside the job) or `flow` (the runner).
247
    pub stream: String,
248
    pub text: String,
249
}
250
251
/// `POST /api/v1/flow/jobs/{id}/decline` (spec §11.5): the runner will
252
/// not run a job it was handed, and says why. The hub queues the job
253
/// again and offers it to this runner no more until its profile changes.
254
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
255
pub struct Decline {
256
    pub reason: String,
257
}
258
259
/// `POST /api/v1/flow/jobs/{id}/log`.
260
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
261
pub struct LogBatch {
262
    pub records: Vec<Record>,
263
}
264
265
/// `POST /api/v1/flow/jobs/{id}/finish`.
266
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
267
pub struct Finish {
268
    /// The job's exit code; absent when the runner could not run it.
269
    pub exit_code: Option<i32>,
270
    pub duration_ms: u64,
271
    pub receipt: Option<Receipt>,
272
    /// The receipt bound to the job's state and signed by the runner
273
    /// (Oxygen spec §14.6), hex of the signed receipt's bytes.
274
    #[serde(default, skip_serializing_if = "Option::is_none")]
275
    pub signed_receipt: Option<String>,
276
    /// Why the runner could not run the job (rootfs missing, VM failed).
277
    pub error: Option<String>,
278
    /// The most disk the job's disks and workspace took, in MiB, and the
279
    /// most memory its sandbox held (oxygenflow#41): the hub learns from
280
    /// them what the job's next run needs.
281
    #[serde(default, skip_serializing_if = "Option::is_none")]
282
    pub peak_disk_mib: Option<u64>,
283
    #[serde(default, skip_serializing_if = "Option::is_none")]
284
    pub peak_memory_mib: Option<u64>,
285
}
286
287
/// Longest text of one record the hub keeps; longer lines are cut.
288
pub const MAX_RECORD_TEXT: usize = 16 * 1024;
289
/// Most records the hub keeps per job; later ones are dropped with a note.
290
pub const MAX_RECORDS: usize = 50_000;