runnerd_core/api.rs
1//! The daemon's JSON API, version 1: the request and response types every client deserializes, and the
2//! principal a transport authenticates. Plain data only: no I/O, no Docker or GitHub types, no secrets but a
3//! [`Pairing`]'s one-time link, which its `Debug` leaves out.
4//!
5//! Routes, all under the authenticated principal:
6//!
7//! | Route | Body |
8//! |---|---|
9//! | `GET /v1/status` | [`Status`] |
10//! | `POST /v1/repositories/{owner}/{name}/pause?`[`PauseQuery`] | [`Paused`]; 409 [`Busy`] while it runs a job |
11//! | `POST /v1/repositories/{owner}/{name}/resume` | none, 204 |
12//! | `POST /v1/repositories/pause?`[`PauseQuery`] | [`Paused`]; 409 [`Busy`] while any runs a job, pausing none |
13//! | `POST /v1/repositories/resume` | none, 204: every repository resumed |
14//! | `POST /v1/profiles/{name}/reseed` | none, 202; 409 while it reseeds; `cache.reseeded` when done |
15//! | `POST /v1/caches/{name}/clean` | none, 204; 409 [`Busy`] while a runner uses it |
16//! | `GET /v1/config` | [`ConfigFile`] |
17//! | `POST /v1/config/check` | TOML in, [`ConfigCheck`] |
18//! | `PUT /v1/config` | TOML in, [`ConfigCheck`]; 422 [`ConfigCheck`] when invalid |
19//! | `GET /v1/doctor` | [`Doctor`] |
20//! | `POST /v1/shutdown` | [`ShutdownRequest`] in, 202 |
21//! | `GET /v1/dashboard` | [`Dashboard`] |
22//! | `POST /v1/dashboard/sign-out` | none, 204 |
23//! | `POST /v1/dashboard/pairings?`[`PairingQuery`] | [`Pairings`]; 409 when the dashboard is off or has no `[web] origins` |
24//! | `GET /v1/jobs?`[`JobQuery`] | [`JobPage`] |
25//! | `GET /v1/jobs/{id}` | [`Job`] |
26//! | `GET /v1/jobs/{id}/samples` | [`SampleSeries`] |
27//! | `GET /v1/jobs/{id}/logs?`[`LogQuery`] | `text/plain`, one line per log line |
28//! | `GET /v1/host/samples?`[`HostSampleQuery`] | [`HostSamples`] |
29//! | `GET /v1/stats?`[`StatsQuery`] | [`Stats`] |
30//! | `GET /v1/activity?`[`ActivityQuery`] | [`Activity`] |
31//! | `GET /v1/sizing?`[`SizingQuery`] | [`Sizing`] |
32//! | `GET /v1/insights?`[`InsightsQuery`] | [`Insights`] |
33//! | `GET /v1/digest` | [`Digest`] |
34//! | `POST /v1/notify/test?`[`NotifyTestQuery`] | [`NotifyTest`]; 404 without `[notify]` targets or with no such one |
35//! | `GET /v1/caches` | [`Caches`] |
36//! | `GET /v1/caches/history?`[`CacheHistoryQuery`] | [`CacheHistory`] |
37//! | `GET /v1/events?`[`EventQuery`] | `text/event-stream` of [`EventEnvelope`] |
38//! | `GET /v1/records` | [`Records`] |
39//! | `DELETE /v1/records/{job}` | [`RemovedRecords`]; 409 while a shell is open in it |
40//! | `DELETE /v1/records?`[`RemoveRecordsQuery`] | [`RemovedRecords`]; records with an open shell stay |
41//! | `POST /v1/records/{job}/shell?`[`ShellQuery`] | `Upgrade:` [`SHELL_PROTOCOL`], 101, then the shell's terminal; 409 when the budget is full |
42//!
43//! Errors carry an [`ErrorResponse`].
44
45use serde::{Deserialize, Serialize};
46
47mod admission;
48mod control;
49mod events;
50mod insights;
51mod jobs;
52mod records;
53mod sizing;
54
55pub use admission::{Admission, AdmissionPolicy, Basis, CpuAdmission, JobPeak, cores, cpus};
56pub use control::{
57 Busy, Check, CheckStatus, ConfigCheck, ConfigFile, Doctor, PauseQuery, Paused, ReplaceQuery,
58 RunningJob,
59};
60pub use events::{Event, EventEnvelope, EventQuery, RunnerFailure};
61pub use insights::{
62 AutoNote, Delivery, Digest, INSIGHT_DAYS, INSIGHT_DAYS_MAX, Insight, InsightKind, Insights,
63 InsightsQuery, LongWait, Notification, NotifyEvent, NotifyTest, NotifyTestQuery, Priority,
64 RepositoryWaits, RunLink, Severity, WaitSpread, WeekWaits,
65};
66pub use jobs::{
67 Activity, ActivityQuery, Bucket, CacheHistory, CacheHistoryQuery, CacheKind, CacheSize,
68 CacheVolume, Caches, HostSample, HostSampleQuery, HostSamples, Job, JobMetrics, JobPage,
69 JobQuery, JobRunner, JobState, JobSummary, LogQuery, LogSummary, Sample, SampleSeries, Started,
70 Stats, StatsQuery, StatsRow, Step, Timeline,
71};
72pub use records::{
73 Record, RecordLimits, RecordNote, RecordRemoval, Records, RemoveRecordsQuery, RemovedRecords,
74 SHELL_PROTOCOL, ShellQuery,
75};
76pub use sizing::{
77 Advice, AutoPlan, JobSizing, Peaks, ProfileSizing, Resize, Sizing, SizingChange, SizingHold,
78 SizingQuery, Spread, Starts, period,
79};
80
81/// Who sent a request, as the transport authenticated it. Handlers get it from one place, so a new transport only
82/// has to produce one.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "kebab-case", tag = "kind")]
85pub enum Principal {
86 /// The user the daemon runs as, on the local Unix socket.
87 LocalOwner {
88 /// The Unix user ID the socket's peer runs as.
89 uid: u32,
90 },
91}
92
93/// `GET /v1/status`: what the daemon serves and runs right now.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct Status {
96 /// The daemon's version, from its `Cargo.toml`.
97 pub version: String,
98 /// Set once the daemon stops polling to shut down.
99 pub shutting_down: bool,
100 /// The memory budget and what runners use of it.
101 pub budget: Budget,
102 /// Every configured profile with its slots.
103 pub profiles: Vec<ProfileStatus>,
104 /// Every configured repository.
105 pub repositories: Vec<RepositoryStatus>,
106 /// Queued jobs the repositories' runners can take and none holds yet, at the last poll, oldest first. No event
107 /// carries the list: `job.queued` announces a job, `job.started` and `job.finished` its leaving the queue.
108 /// A repository whose last listing failed, as while GitHub rate-limits runnerd, or that is no longer polled
109 /// because runnerd is shutting down, shows no jobs here, while the history keeps its jobs queued.
110 #[serde(default)]
111 pub queued: Vec<QueuedJob>,
112}
113
114/// The memory budget runners and the mirror share, in bytes.
115#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
116pub struct Budget {
117 /// The systemd slice runner containers run in.
118 pub slice: String,
119 /// The slice's `MemoryMax`; `None` when it is unlimited.
120 pub memory_max: Option<u64>,
121 /// Memory the running containers reserve: the runners' caps, or under measured admission the larger of each
122 /// runner's predicted peak and its last sampled memory, and the caps of the seed containers, shells and the
123 /// mirror.
124 pub memory_in_use: u64,
125 /// What a new runner must fit in beside them.
126 #[serde(default)]
127 pub admission: AdmissionPolicy,
128 /// Under measured admission, whether the slice is at the high-water mark, so no runner starts.
129 #[serde(default)]
130 pub held: bool,
131 /// The mirror's part of `memory_in_use`: its cap while it is enabled or still running, else 0.
132 #[serde(default)]
133 pub mirror_memory: u64,
134 /// The seed containers' part of `memory_in_use`, one per profile that reseeds, by profile name.
135 #[serde(default)]
136 pub seed_memory: Vec<SeedMemory>,
137 /// The shells' part of `memory_in_use`, one per shell open in a record, by job.
138 #[serde(default)]
139 pub shell_memory: Vec<ShellMemory>,
140 /// The CPU budget, under `[host] cpu_overcommit`. Absent without one, and from a daemon before CPU admission.
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub cpu: Option<CpuBudget>,
143 /// The CPUs the slice may use, in millicores: its `AllowedCPUs` or the host's, less under a lower `CPUQuota`,
144 /// whether or not a CPU budget admits runners. The runners' CPU limits are quotas that may add up past it.
145 /// Absent when it could not be read, and from a daemon before it said.
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub slice_cpus: Option<u64>,
148}
149
150/// The CPU budget runners share, in millicores.
151#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
152pub struct CpuBudget {
153 /// The CPUs the slice may use: its `AllowedCPUs` or the host's, less under a lower `CPUQuota`.
154 pub cpus: u64,
155 /// Those times `cpu_overcommit`: what the runners' reservations must fit.
156 pub budget: u64,
157 /// What the runners reserve: each its predicted average CPU under measured admission, else its `cpus`.
158 pub in_use: u64,
159 /// Whether a queued job waits for CPU that memory and a slot have room for.
160 pub held: bool,
161}
162
163impl CpuBudget {
164 /// The budget's line as clients show it beside memory, such as `CPU 4.5 of 32 CPUs reserved` or, while a job
165 /// waits for it, `CPU 30 of 32 CPUs reserved; a job waits for CPU`.
166 pub fn line(&self) -> String {
167 let mut line = format!(
168 "CPU {} of {} CPUs reserved",
169 cores(self.in_use),
170 cores(self.budget)
171 );
172 if self.held {
173 line.push_str("; a job waits for CPU");
174 }
175 line
176 }
177}
178
179impl Budget {
180 /// How runners are admitted, as clients add it to the budget's line: nothing under caps, else `by predicted
181 /// peaks`, and why nothing starts while the slice is at the high-water mark.
182 pub fn admission_note(&self) -> Option<&'static str> {
183 match (self.admission, self.held) {
184 (AdmissionPolicy::Caps, _) => None,
185 (AdmissionPolicy::Measured, false) => Some("by predicted peaks"),
186 (AdmissionPolicy::Measured, true) => Some(
187 "by predicted peaks; the slice is past 90% of its MemoryMax, so no runner starts",
188 ),
189 }
190 }
191}
192
193/// A shell container's memory cap: its record's profile's limit when the shell opened.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct ShellMemory {
196 /// The job whose record the shell runs.
197 pub job: u64,
198 /// Its cap in bytes.
199 pub memory: u64,
200}
201
202/// A running seed container's memory cap: its profile's limit when the reseed started.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct SeedMemory {
205 /// The profile it reseeds.
206 pub profile: String,
207 /// Its cap in bytes.
208 pub memory: u64,
209}
210
211/// A runner profile and what runs in its slots.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct ProfileStatus {
214 /// The name under `[profiles]` in `runnerd.toml`.
215 pub name: String,
216 /// The image its runners run, such as `runnerd-runner:base`.
217 pub image: String,
218 /// CPUs per slot.
219 pub cpus: u32,
220 /// Bytes per slot.
221 pub memory: u64,
222 /// Set while slot 1's layers become the profile's snapshot.
223 pub reseeding: bool,
224 /// Index 0 is slot 1.
225 pub slots: Vec<SlotStatus>,
226}
227
228/// One slot of a profile: room for one job.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct SlotStatus {
231 /// 1-based.
232 pub slot: usize,
233 /// `None` when the slot is free.
234 pub runner: Option<RunnerStatus>,
235}
236
237/// A runner registered for one job, in its container.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct RunnerStatus {
240 /// `owner/name` the runner is registered on.
241 pub repository: String,
242 /// The runner's name, also its container's.
243 pub name: String,
244 /// The runner's ID on GitHub.
245 pub runner_id: u64,
246 /// The Docker container ID.
247 pub container: String,
248 /// Whether GitHub reports it running a job.
249 pub busy: bool,
250 /// Seconds since the Unix epoch.
251 pub started_at: u64,
252 /// What it takes of the budget: its cap under caps; under measured admission the larger of its predicted peak
253 /// and its last sampled memory. 0 from a daemon before measured admission.
254 #[serde(default)]
255 pub reserved: u64,
256 /// A warm runner: idle, and no queued job of its repository waits for it; it takes the next one GitHub hands
257 /// out. False from a daemon before warm runners.
258 #[serde(default)]
259 pub warm: bool,
260}
261
262/// A configured repository and its queue.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct RepositoryStatus {
265 /// `owner/name` on GitHub.
266 pub name: String,
267 /// The profile its jobs run in.
268 pub profile: String,
269 /// The labels its runners register with.
270 pub labels: Vec<String>,
271 /// Paused repositories get no new runners; their running jobs finish.
272 pub paused: bool,
273 /// Queued jobs whose labels these runners match, at the last poll.
274 pub queued_jobs: usize,
275}
276
277/// A queued job runners of this daemon can take, as GitHub listed it at the last poll.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct QueuedJob {
280 /// GitHub's job ID.
281 pub id: u64,
282 /// The repository whose runners can take it, `owner/name`.
283 pub repository: String,
284 /// The workflow run it belongs to.
285 pub run_id: u64,
286 /// 1 for the first attempt of the run, higher for re-runs.
287 pub run_attempt: u32,
288 /// The workflow's name.
289 pub workflow: Option<String>,
290 /// The job's name in the workflow.
291 pub name: String,
292 /// The branch the run is for.
293 pub branch: Option<String>,
294 /// The job's page on GitHub.
295 pub url: Option<String>,
296 /// When GitHub queued it, in seconds since the Unix epoch; 0 if GitHub did not say.
297 pub queued_at: u64,
298 /// Why no runner has it yet, as the last plan saw it. `None` before the daemon has planned with it, while it
299 /// shuts down, and from a daemon before it said.
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub waiting: Option<Waiting>,
302}
303
304/// Why a queued job has no runner of its own yet. Jobs are served oldest first; each takes the first of these that
305/// holds for it.
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(rename_all = "kebab-case", tag = "kind")]
308pub enum Waiting {
309 /// Its repository is paused.
310 Paused,
311 /// Runners of its repository ended without taking a job, so none starts before `until` (seconds since the Unix
312 /// epoch).
313 Backoff {
314 /// When runners may start again.
315 until: u64,
316 },
317 /// A runner started for it and waits for GitHub to hand it the job: a runner takes any job its labels match,
318 /// so not necessarily this one.
319 RunnerStarting,
320 /// Slot 1 of its profile is becoming the profile's snapshot, so none of the profile's slots starts.
321 Reseeding {
322 /// The profile.
323 profile: String,
324 },
325 /// Its profile's image is being pulled, so none of the profile's slots starts.
326 PullingImage {
327 /// The profile.
328 profile: String,
329 },
330 /// Every slot of its profile holds a runner.
331 NoSlot {
332 /// The profile.
333 profile: String,
334 },
335 /// What its runner would reserve does not fit the memory budget left. Under measured admission, `free` is 0
336 /// while the slice is at the high-water mark (`Budget::held`).
337 Memory {
338 /// What its runner would reserve, in bytes.
339 needs: u64,
340 /// What the budget has left, in bytes.
341 free: u64,
342 },
343 /// What its runner would reserve does not fit the CPU budget left, in millicores.
344 Cpu {
345 /// What its runner would reserve.
346 needs: u64,
347 /// What the CPU budget has left.
348 free: u64,
349 },
350 /// An older job has waited long enough for CPU that no younger one takes CPU before it.
351 BehindCpu {
352 /// The older job's ID.
353 job: u64,
354 },
355 /// An older job is about to have a runner start for it, which takes the slot, memory or CPU this one would
356 /// otherwise start with: what holds it is that start, not what runs now.
357 BehindStart {
358 /// The older job's ID.
359 job: u64,
360 },
361}
362
363/// What a [`Waiting`] reason waits on, which clients draw it by.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum WaitKind {
366 /// Something the owner can act on: a pause, or runners that failed to take a job.
367 Act,
368 /// Something that clears by itself shortly: a runner starting, a reseed, an image pull, an older job's start.
369 Soon,
370 /// Room: a slot, memory or CPU that a running job holds.
371 Full,
372}
373
374impl Waiting {
375 /// What it waits on.
376 pub fn kind(&self) -> WaitKind {
377 match self {
378 Waiting::Paused | Waiting::Backoff { .. } => WaitKind::Act,
379 Waiting::RunnerStarting
380 | Waiting::Reseeding { .. }
381 | Waiting::PullingImage { .. }
382 | Waiting::BehindStart { .. } => WaitKind::Soon,
383 Waiting::NoSlot { .. }
384 | Waiting::Memory { .. }
385 | Waiting::Cpu { .. }
386 | Waiting::BehindCpu { .. } => WaitKind::Full,
387 }
388 }
389
390 /// Why `job` waits, in the owner's words with the numbers that hold it, as every client says it: such as
391 /// `All 2 node slots are busy` or `Needs 9G of memory; 2.5G of the budget is free`. `status` is the one `job`
392 /// is queued in, which names the slot of a starting runner and the older job one waits behind.
393 pub fn reason(&self, job: &QueuedJob, status: &Status, now: u64) -> String {
394 let size = |bytes: u64| crate::config::ByteSize(bytes).to_string();
395 let older_name = |id: u64| {
396 status
397 .queued
398 .iter()
399 .find(|candidate| candidate.id == id)
400 .map_or_else(|| "an older job".to_owned(), |older| older.name.clone())
401 };
402 match self {
403 Waiting::Paused => format!("{} is paused", job.repository),
404 Waiting::Backoff { until } => format!(
405 "Runners failed to take a job; retrying in {}",
406 admission::wait(until.saturating_sub(now))
407 ),
408 Waiting::RunnerStarting => {
409 let slot = status.profiles.iter().find_map(|profile| {
410 profile
411 .slots
412 .iter()
413 .find(|slot| {
414 slot.runner.as_ref().is_some_and(|runner| {
415 runner.repository == job.repository && !runner.busy
416 })
417 })
418 .map(|slot| format!("{}\u{a0}{}", profile.name, slot.slot))
419 });
420 match slot {
421 Some(slot) => format!("A runner is starting for it in {slot}"),
422 None => "A runner is starting for it".to_owned(),
423 }
424 }
425 Waiting::Reseeding { profile } => format!("{profile} is rebuilding its snapshot"),
426 Waiting::PullingImage { profile } => format!("Pulling {profile}'s image"),
427 Waiting::NoSlot { profile } => {
428 let slots = status
429 .profiles
430 .iter()
431 .find(|candidate| &candidate.name == profile)
432 .map_or(0, |profile| profile.slots.len());
433 if slots == 1 {
434 format!("The {profile} slot is busy")
435 } else {
436 format!("All {slots} {profile} slots are busy")
437 }
438 }
439 Waiting::Memory { .. } if status.budget.held => {
440 "The slice is past 90% of its MemoryMax, so nothing starts".to_owned()
441 }
442 Waiting::Memory { needs, free } => format!(
443 "Needs {} of memory; {} of the budget is free",
444 size(*needs),
445 size(*free)
446 ),
447 Waiting::Cpu { needs, free } => {
448 format!("Needs {}; {} are free", cpus(*needs), cores(*free))
449 }
450 Waiting::BehindCpu { job: older } => {
451 format!("Behind {}, which waits for CPU", older_name(*older))
452 }
453 Waiting::BehindStart { job: older } => {
454 format!(
455 "Next after {}, whose runner starts first",
456 older_name(*older)
457 )
458 }
459 }
460 }
461}
462
463/// `GET /v1/dashboard`: the dashboard's `[web]` settings, as the daemon read them when it started.
464#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
465pub struct Dashboard {
466 /// `[web] open`: the one of `[web] origins` that `runnerctl web` opens the dashboard on; the loopback address
467 /// when none.
468 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub open: Option<String>,
470}
471
472/// `POST /v1/dashboard/pairings`: which of `[web] origins` to make a link for.
473#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
474pub struct PairingQuery {
475 /// One of `[web] origins`; each of them when none.
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub origin: Option<String>,
478}
479
480/// `POST /v1/dashboard/pairings`: fresh one-time links that sign a device in to the dashboard.
481#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
482pub struct Pairings {
483 /// One per origin asked for.
484 pub links: Vec<Pairing>,
485}
486
487/// A link that signs one device in to the dashboard on `origin`, and nowhere else, within `lifetime`.
488#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
489pub struct Pairing {
490 /// One of `[web] origins`.
491 pub origin: String,
492 /// The link with its one-time code: a credential, which `Debug` leaves out.
493 pub url: String,
494 /// Seconds it works for from when it was made.
495 pub lifetime: u64,
496}
497
498impl std::fmt::Debug for Pairing {
499 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500 formatter
501 .debug_struct("Pairing")
502 .field("origin", &self.origin)
503 .field("lifetime", &self.lifetime)
504 .finish_non_exhaustive()
505 }
506}
507
508/// `POST /v1/shutdown`.
509#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
510pub struct ShutdownRequest {
511 /// Kill running jobs and delete their runners instead of waiting for them.
512 #[serde(default)]
513 pub force: bool,
514}
515
516/// The body of every error response.
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518pub struct ErrorResponse {
519 /// What went wrong, for people to read.
520 pub message: String,
521}