Skip to main content

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}