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//! | `GET /v1/savings` | [`Savings`] |
35//! | `POST /v1/notify/test?`[`NotifyTestQuery`] | [`NotifyTest`]; 404 without `[notify]` targets or with no such one |
36//! | `GET /v1/caches` | [`Caches`] |
37//! | `GET /v1/caches/history?`[`CacheHistoryQuery`] | [`CacheHistory`] |
38//! | `GET /v1/events?`[`EventQuery`] | `text/event-stream` of [`EventEnvelope`] |
39//! | `GET /v1/records` | [`Records`] |
40//! | `DELETE /v1/records/{job}` | [`RemovedRecords`]; 409 while a shell is open in it |
41//! | `DELETE /v1/records?`[`RemoveRecordsQuery`] | [`RemovedRecords`]; records with an open shell stay |
42//! | `POST /v1/records/{job}/shell?`[`ShellQuery`] | `Upgrade:` [`SHELL_PROTOCOL`], 101, then the shell's terminal; 409 when the budget is full |
43//!
44//! Errors carry an [`ErrorResponse`].
45
46use serde::{Deserialize, Serialize};
47
48mod admission;
49mod budget;
50mod control;
51mod events;
52mod insights;
53mod jobs;
54mod records;
55mod results;
56mod savings;
57mod sizing;
58mod slots;
59mod stats;
60mod storage;
61mod tone;
62mod units;
63
64pub use admission::{Admission, AdmissionPolicy, Basis, CpuAdmission, JobPeak, cores, cpus};
65pub use budget::{
66    BudgetParts, ClosedKind, ClosedPart, FreePart, HeldKind, HeldPart, Reading, SlotPart,
67};
68pub use control::{
69    Busy, Check, CheckStatus, ConfigCheck, ConfigFile, Doctor, PauseQuery, Paused, ReplaceQuery,
70    RunningJob,
71};
72pub use events::{Event, EventEnvelope, EventQuery, RunnerFailure};
73pub use insights::{
74    AutoNote, Delivery, Digest, INSIGHT_DAYS, INSIGHT_DAYS_MAX, Insight, InsightKind, Insights,
75    InsightsQuery, LongWait, Notification, NotifyEvent, NotifyTest, NotifyTestQuery, Priority,
76    RepositoryWaits, RunLink, Severity, WaitSpread, WeekWaits,
77};
78pub use jobs::{
79    Activity, ActivityQuery, Bucket, BuildCacheCounts, BuildCacheStats, CacheHistory,
80    CacheHistoryQuery, CacheKind, CacheSize, CacheVolume, Caches, ChartLine, ChartLineKind,
81    ChartLines, Holder, HostMemory, HostSample, HostSampleQuery, HostSamples, Job, JobMetrics,
82    JobPage, JobQuery, JobRunner, JobState, JobSummary, JobTitle, Limit, LimitSource, Limits,
83    LogQuery, LogSummary, Milestone, Outcome, OutcomeKind, Phase, PhaseKind, Pressure,
84    RunComparison, Sample, SampleSeries, SampleUsage, Started, Stats, StatsQuery, StatsRow, Step,
85    Timeline, job_title,
86};
87pub use records::{
88    Record, RecordLimits, RecordNote, RecordRemoval, Records, RemoveRecordsQuery, RemovedRecords,
89    SHELL_PROTOCOL, ShellQuery,
90};
91pub use results::{force_pause_consequence, reseed_requested, reseeded};
92pub use savings::{
93    Period, PricedRun, PricedRunner, Pricing, PricingHistory, SELF_HOSTED_RATE, SavedWindow,
94    Savings, Sku, Usd, Visibility, billed_minutes, reads_since,
95};
96pub use sizing::{
97    Advice, AutoPlan, GRANULE, JobSizing, Need, PAGE_CACHE_ROOM, Peaks, ProfilePlan, ProfileSizing,
98    Resize, RevertCause, SAMPLE_INTERVAL, Sizing, SizingChange, SizingHold, SizingQuery,
99    SizingRefused, SizingRevert, Spread, Starts, need, period, sampled_note,
100};
101pub use slots::{Health, SlotState};
102pub use stats::StatsSummary;
103pub use storage::{
104    BuildCacheActivity, CacheLimit, RECORDS_NEAR_FULL, RecordState, records_near_full,
105};
106pub use tone::{Tone, Toned};
107#[cfg(feature = "host")]
108pub(crate) use units::days_from_civil;
109pub use units::{ago, cores_used, cpu_time, duration, in_cores};
110
111/// Who sent a request, as the transport authenticated it. Handlers get it from one place, so a new transport only
112/// has to produce one.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "kebab-case", tag = "kind")]
115pub enum Principal {
116    /// The user the daemon runs as, on the local Unix socket.
117    LocalOwner {
118        /// The Unix user ID the socket's peer runs as.
119        uid: u32,
120    },
121}
122
123/// `GET /v1/status`: what the daemon serves and runs right now.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct Status {
126    /// The daemon's version, from its `Cargo.toml`.
127    pub version: String,
128    /// Set once the daemon stops polling to shut down.
129    pub shutting_down: bool,
130    /// The memory budget and what runners use of it.
131    pub budget: Budget,
132    /// Every configured profile with its slots.
133    pub profiles: Vec<ProfileStatus>,
134    /// Every configured repository.
135    pub repositories: Vec<RepositoryStatus>,
136    /// Queued jobs the repositories' runners can take and none holds yet, at the last poll, oldest first. No event
137    /// carries the list: `job.queued` announces a job, `job.started` and `job.finished` its leaving the queue.
138    /// A repository whose last listing failed, as while GitHub rate-limits runnerd, or that is no longer polled
139    /// because runnerd is shutting down, shows no jobs here, while the history keeps its jobs queued.
140    #[serde(default)]
141    pub queued: Vec<QueuedJob>,
142}
143
144/// The memory budget runners and the mirror share, in bytes.
145#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
146pub struct Budget {
147    /// The systemd slice runner containers run in.
148    pub slice: String,
149    /// The slice's `MemoryMax`; `None` when it is unlimited.
150    pub memory_max: Option<u64>,
151    /// Memory the running containers reserve: the runners' caps, or under measured admission the larger of each
152    /// runner's predicted peak and its last sampled memory, and the caps of the seed containers, shells and the
153    /// mirror.
154    pub memory_in_use: u64,
155    /// What a new runner must fit in beside them.
156    #[serde(default)]
157    pub admission: AdmissionPolicy,
158    /// Under measured admission, whether the slice is at the high-water mark, so no runner starts.
159    #[serde(default)]
160    pub held: bool,
161    /// The mirror's part of `memory_in_use`: its cap while it is enabled or still running, else 0.
162    #[serde(default)]
163    pub mirror_memory: u64,
164    /// The seed containers' part of `memory_in_use`, one per profile that reseeds, by profile name.
165    #[serde(default)]
166    pub seed_memory: Vec<SeedMemory>,
167    /// The shells' part of `memory_in_use`, one per shell open in a record, by job.
168    #[serde(default)]
169    pub shell_memory: Vec<ShellMemory>,
170    /// What the host's memory leaves runners beside its other programs, from a recent reading of it; absent
171    /// without one, and from a daemon before it read the host.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub host: Option<HostBudget>,
174    /// The CPU budget, under `[host] cpu_overcommit`. Absent without one, and from a daemon before CPU admission.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub cpu: Option<CpuBudget>,
177    /// The CPUs the slice may use, in millicores: its `AllowedCPUs` or the host's, less under a lower `CPUQuota`,
178    /// whether or not a CPU budget admits runners. The runners' CPU limits are quotas that may add up past it.
179    /// Absent when it could not be read, and from a daemon before it said.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub slice_cpus: Option<u64>,
182}
183
184/// What the host's memory leaves the runners (ADR 0055). New runners fit in the lower of `MemoryMax` and
185/// `limit`; running ones are never stopped for it.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct HostBudget {
188    /// The reading it comes from.
189    pub memory: HostMemory,
190    /// What the slice may hold now: what its runners hold without page cache plus the host's `MemAvailable`, less
191    /// a margin kept free for the host's page cache.
192    pub limit: u64,
193    /// What that leaves new runners beside what the running ones reserve, in bytes.
194    pub spare: u64,
195    /// Whether the host's tasks stall for memory (`full` pressure), so no runner starts until they stop.
196    pub stalled: bool,
197}
198
199/// Hundredths of a percent as a percentage: `1.5%`, `12%`.
200pub fn percent(hundredths: u32) -> String {
201    let whole = hundredths / 100;
202    match hundredths % 100 / 10 {
203        0 => format!("{whole}%"),
204        tenth => format!("{whole}.{tenth}%"),
205    }
206}
207
208/// The CPU budget runners share, in millicores.
209#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
210pub struct CpuBudget {
211    /// The CPUs the slice may use: its `AllowedCPUs` or the host's, less under a lower `CPUQuota`.
212    pub cpus: u64,
213    /// Those times `cpu_overcommit`: what the runners' reservations must fit.
214    pub budget: u64,
215    /// What the runners reserve: each its predicted average CPU under measured admission, else its `cpus`.
216    pub in_use: u64,
217    /// Whether a queued job waits for CPU that memory and a slot have room for.
218    pub held: bool,
219}
220
221impl CpuBudget {
222    /// The budget's line as clients show it beside memory, such as `CPU 4.5 of 32 CPUs reserved` or, while a job
223    /// waits for it, `CPU 30 of 32 CPUs reserved; a job waits for CPU`.
224    pub fn line(&self) -> String {
225        let mut line = format!(
226            "CPU {} of {} CPUs reserved",
227            cores(self.in_use),
228            cores(self.budget)
229        );
230        if self.held {
231            line.push_str("; a job waits for CPU");
232        }
233        line
234    }
235}
236
237impl Budget {
238    /// How runners are admitted, as clients add it to the budget's line: nothing under caps, else `by predicted
239    /// peaks`, and why nothing starts while the slice is at the high-water mark.
240    pub fn admission_note(&self) -> Option<&'static str> {
241        match (self.admission, self.held) {
242            (AdmissionPolicy::Caps, _) => None,
243            (AdmissionPolicy::Measured, false) => Some("by predicted peaks"),
244            (AdmissionPolicy::Measured, true) => Some(
245                "by predicted peaks; the slice is past 90% of its MemoryMax, so no runner starts",
246            ),
247        }
248    }
249}
250
251/// A shell container's memory cap: its record's profile's limit when the shell opened.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct ShellMemory {
254    /// The job whose record the shell runs.
255    pub job: u64,
256    /// Its cap in bytes.
257    pub memory: u64,
258}
259
260/// A seed container's memory cap: its profile's limit when the reseed started, or, for a requested reseed whose
261/// profile drained, the room it holds until it starts.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263pub struct SeedMemory {
264    /// The profile it reseeds.
265    pub profile: String,
266    /// Its cap in bytes.
267    pub memory: u64,
268}
269
270/// A runner profile and what runs in its slots.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272pub struct ProfileStatus {
273    /// The name under `[profiles]` in `runnerd.toml`.
274    pub name: String,
275    /// The image its runners run, such as `runnerd-runner:base`.
276    pub image: String,
277    /// CPUs per slot.
278    pub cpus: u32,
279    /// Bytes per slot.
280    pub memory: u64,
281    /// Set while slot 1's layers become the profile's snapshot.
282    pub reseeding: bool,
283    /// Index 0 is slot 1.
284    pub slots: Vec<SlotStatus>,
285}
286
287/// One slot of a profile: room for one job.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct SlotStatus {
290    /// 1-based.
291    pub slot: usize,
292    /// `None` when the slot is free.
293    pub runner: Option<RunnerStatus>,
294}
295
296/// A runner registered for one job, in its container.
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct RunnerStatus {
299    /// `owner/name` the runner is registered on.
300    pub repository: String,
301    /// The runner's name, also its container's.
302    pub name: String,
303    /// The runner's ID on GitHub.
304    pub runner_id: u64,
305    /// The Docker container ID.
306    pub container: String,
307    /// Whether it runs a job, as GitHub or its log reports it: its log names the job before GitHub lists it busy.
308    pub busy: bool,
309    /// Seconds since the Unix epoch.
310    pub started_at: u64,
311    /// What it takes of the budget: its cap under caps; under measured admission the larger of its predicted peak
312    /// and its last sampled memory. 0 from a daemon before measured admission.
313    #[serde(default)]
314    pub reserved: u64,
315    /// A warm runner: idle, and no queued job of its repository waits for it; it takes the next one GitHub hands
316    /// out. False from a daemon before warm runners.
317    #[serde(default)]
318    pub warm: bool,
319}
320
321/// A configured repository and its queue.
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323pub struct RepositoryStatus {
324    /// `owner/name` on GitHub.
325    pub name: String,
326    /// Its first pool's profile, which clients before pools show.
327    #[serde(default)]
328    pub profile: String,
329    /// Its first pool's labels, which clients before pools show.
330    #[serde(default)]
331    pub labels: Vec<String>,
332    /// Its pools in config order, one per `[[repositories]]` entry: a job runs in the one whose labels match it
333    /// (ADR 0058). Empty from a daemon before pools; `listed_pools` reads either.
334    #[serde(default)]
335    pub pools: Vec<PoolStatus>,
336    /// Paused repositories get no new runners; their running jobs finish.
337    pub paused: bool,
338    /// Its jobs in `Status::queued`: queued ones none of its runners took yet, those no pool serves too.
339    pub queued_jobs: usize,
340}
341
342/// One of a repository's pools: its runners in one profile.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct PoolStatus {
345    /// The profile its jobs run in.
346    pub profile: String,
347    /// The labels its runners register with.
348    pub labels: Vec<String>,
349    /// Its jobs in `Status::queued`: those its labels match.
350    pub queued_jobs: usize,
351}
352
353/// A queued job runners of this daemon can take, as GitHub listed it at the last poll.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct QueuedJob {
356    /// GitHub's job ID.
357    pub id: u64,
358    /// The repository whose runners can take it, `owner/name`.
359    pub repository: String,
360    /// The profile of its repository's pool whose labels match it; `None` when no pool's do, and from a daemon
361    /// before pools.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub profile: Option<String>,
364    /// The workflow run it belongs to.
365    pub run_id: u64,
366    /// 1 for the first attempt of the run, higher for re-runs.
367    pub run_attempt: u32,
368    /// The workflow's name.
369    pub workflow: Option<String>,
370    /// The job's name in the workflow.
371    pub name: String,
372    /// The branch the run is for.
373    pub branch: Option<String>,
374    /// The job's page on GitHub.
375    pub url: Option<String>,
376    /// When GitHub queued it, in seconds since the Unix epoch; 0 if GitHub did not say.
377    pub queued_at: u64,
378    /// Why no runner has it yet, as the last plan saw it. `None` before the daemon has planned with it, while it
379    /// shuts down, and from a daemon before it said.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub waiting: Option<Waiting>,
382}
383
384/// The slot of an idle runner of `job`'s repository, in its pool's profile when known, such as `jvm 2`.
385fn starting_for(job: &QueuedJob, status: &Status) -> Option<String> {
386    status
387        .profiles
388        .iter()
389        .filter(|profile| job.profile.as_ref().is_none_or(|own| *own == profile.name))
390        .find_map(|profile| {
391            profile
392                .slots
393                .iter()
394                .find(|slot| {
395                    slot.runner
396                        .as_ref()
397                        .is_some_and(|runner| runner.repository == job.repository && !runner.busy)
398                })
399                .map(|slot| format!("{}\u{a0}{}", profile.name, slot.slot))
400        })
401}
402
403impl QueuedJob {
404    /// Where it runs, as every client names it: its repository, and its pool's profile when the repository has
405    /// several pools in `status`, such as `halyard/ledger · light`.
406    pub fn place(&self, status: &Status) -> String {
407        let pools = status
408            .repositories
409            .iter()
410            .find(|repository| repository.name == self.repository)
411            .map_or(0, |repository| repository.pools.len());
412        match self.profile.as_deref().filter(|_| pools > 1) {
413            Some(profile) => format!("{}\u{a0}· {profile}", self.repository),
414            None => self.repository.clone(),
415        }
416    }
417}
418
419impl RepositoryStatus {
420    /// Its pools, or from a daemon before pools its one profile and labels.
421    pub fn listed_pools(&self) -> Vec<PoolStatus> {
422        if !self.pools.is_empty() {
423            return self.pools.clone();
424        }
425        vec![PoolStatus {
426            profile: self.profile.clone(),
427            labels: self.labels.clone(),
428            queued_jobs: self.queued_jobs,
429        }]
430    }
431
432    /// Its pools' profiles, as every client lists them on one line: `jvm` or `jvm + light`.
433    pub fn profiles(&self) -> String {
434        self.listed_pools()
435            .iter()
436            .map(|pool| pool.profile.as_str())
437            .collect::<Vec<_>>()
438            .join(" + ")
439    }
440
441    /// The profile of its pool whose labels match a job asking for `labels`, as the daemon picks it.
442    pub fn pool_for(&self, labels: &[String]) -> Option<String> {
443        self.listed_pools()
444            .into_iter()
445            .find(|pool| crate::config::labels_match(labels, &pool.labels))
446            .map(|pool| pool.profile)
447    }
448}
449
450/// What failed, that holds a repository's runners back for a while.
451#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(rename_all = "kebab-case")]
453pub enum HoldCause {
454    /// Its runners ended without taking a job.
455    #[default]
456    NoJob,
457    /// Its runners ended without a job after waiting for one, twice in a row.
458    IdleExits,
459    /// GitHub did not register its runner.
460    Register,
461    /// Docker did not start its runner's container.
462    Start,
463}
464
465/// Why a queued job has no runner of its own yet. Jobs are served oldest first; each takes the first of these that
466/// holds for it.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(rename_all = "kebab-case", tag = "kind")]
469pub enum Waiting {
470    /// Its repository is paused.
471    Paused,
472    /// Runners of its repository failed, so none starts before `until` (seconds since the Unix epoch).
473    Backoff {
474        /// When runners may start again.
475        until: u64,
476        /// What failed; `NoJob` from a daemon before it said.
477        #[serde(default)]
478        cause: HoldCause,
479    },
480    /// A runner started for it and waits for GitHub to hand it the job: a runner takes any job its labels match,
481    /// so not necessarily this one.
482    RunnerStarting,
483    /// Slot 1 of its profile is becoming the profile's snapshot, so none of the profile's slots starts.
484    Reseeding {
485        /// The profile.
486        profile: String,
487    },
488    /// Its profile's image is being pulled, so none of the profile's slots starts.
489    PullingImage {
490        /// The profile.
491        profile: String,
492    },
493    /// Every slot of its profile holds a runner.
494    NoSlot {
495        /// The profile.
496        profile: String,
497    },
498    /// What its runner would reserve does not fit the memory budget left. Under measured admission, `free` is 0
499    /// while the slice is at the high-water mark (`Budget::held`).
500    Memory {
501        /// What its runner would reserve, in bytes.
502        needs: u64,
503        /// What the budget has left, in bytes.
504        free: u64,
505    },
506    /// What its runner would reserve does not fit what the host's memory has to spare beside its other programs
507    /// (`HostBudget::spare`), though it fits the slice's budget. `free` is 0 while the host's tasks stall for memory
508    /// (`HostBudget::stalled`).
509    HostMemory {
510        /// What its runner would reserve, in bytes.
511        needs: u64,
512        /// What the host has to spare, in bytes.
513        free: u64,
514    },
515    /// What its runner would reserve does not fit the CPU budget left, in millicores.
516    Cpu {
517        /// What its runner would reserve.
518        needs: u64,
519        /// What the CPU budget has left.
520        free: u64,
521    },
522    /// An older job has waited long enough for CPU that no younger one takes CPU before it.
523    BehindCpu {
524        /// The older job's ID.
525        job: u64,
526    },
527    /// An older job is about to have a runner start for it, which takes the slot, memory or CPU this one would
528    /// otherwise start with: what holds it is that start, not what runs now.
529    BehindStart {
530        /// The older job's ID.
531        job: u64,
532    },
533    /// No pool of its repository has every label it asks for, so no runner of this daemon can take it (ADR 0058).
534    NoPool {
535        /// The labels it asks for.
536        labels: Vec<String>,
537    },
538    /// A reason from a newer daemon than this client knows, so a reason added later does not keep the client from
539    /// reading the status.
540    #[serde(other)]
541    Unknown,
542}
543
544/// What a [`Waiting`] reason waits on, which clients draw it by.
545#[derive(Debug, Clone, Copy, PartialEq, Eq)]
546pub enum WaitKind {
547    /// Something the owner can act on: a pause, runners that failed to take a job, or labels no pool serves.
548    Act,
549    /// Something that clears by itself shortly: a runner starting, a reseed, an image pull, an older job's start.
550    Soon,
551    /// Room: a slot, memory or CPU that a running job holds.
552    Full,
553    /// A reason this client does not know (`Waiting::Unknown`), drawn neutral.
554    Other,
555}
556
557impl Waiting {
558    /// What it waits on.
559    pub fn kind(&self) -> WaitKind {
560        match self {
561            Waiting::Paused | Waiting::Backoff { .. } | Waiting::NoPool { .. } => WaitKind::Act,
562            Waiting::Unknown => WaitKind::Other,
563            Waiting::RunnerStarting
564            | Waiting::Reseeding { .. }
565            | Waiting::PullingImage { .. }
566            | Waiting::BehindStart { .. } => WaitKind::Soon,
567            Waiting::NoSlot { .. }
568            | Waiting::Memory { .. }
569            | Waiting::HostMemory { .. }
570            | Waiting::Cpu { .. }
571            | Waiting::BehindCpu { .. } => WaitKind::Full,
572        }
573    }
574
575    /// Why `job` waits, in the owner's words with the numbers that hold it, as every client says it: such as
576    /// `All 2 node slots are busy` or `Needs 9G of memory; 2.5G of the budget is free`. `status` is the one `job`
577    /// is queued in, which names the slot of a starting runner and the older job one waits behind.
578    pub fn reason(&self, job: &QueuedJob, status: &Status, now: u64) -> String {
579        let size = |bytes: u64| crate::config::ByteSize(bytes).to_string();
580        let older_name = |id: u64| {
581            status
582                .queued
583                .iter()
584                .find(|candidate| candidate.id == id)
585                .map_or_else(|| "an older job".to_owned(), |older| older.name.clone())
586        };
587        match self {
588            Waiting::Paused => format!("{} is paused", job.repository),
589            Waiting::Unknown => "Waits for a reason this client does not know yet".to_owned(),
590            Waiting::NoPool { labels } if labels.is_empty() => {
591                "It asks for no labels, so no profile serves it".to_owned()
592            }
593            Waiting::NoPool { labels } => format!(
594                "No profile serves the {} {}",
595                if labels.len() == 1 { "label" } else { "labels" },
596                labels.join(", ")
597            ),
598            Waiting::Backoff { until, cause } => format!(
599                "{}; retrying in {}",
600                match cause {
601                    HoldCause::NoJob => "Runners failed to take a job",
602                    HoldCause::IdleExits => "Runners ended without a job after waiting for one",
603                    HoldCause::Register => "Runners cannot register with GitHub",
604                    HoldCause::Start => "Runners cannot start",
605                },
606                duration(until.saturating_sub(now))
607            ),
608            Waiting::RunnerStarting => match starting_for(job, status) {
609                Some(slot) => format!("A runner is starting for it in {slot}"),
610                None => "A runner is starting for it".to_owned(),
611            },
612            Waiting::Reseeding { profile } => format!("{profile} is rebuilding its snapshot"),
613            Waiting::PullingImage { profile } => format!("Pulling {profile}'s image"),
614            Waiting::NoSlot { profile } => {
615                let slots = status
616                    .profiles
617                    .iter()
618                    .find(|candidate| &candidate.name == profile)
619                    .map_or(0, |profile| profile.slots.len());
620                if slots == 1 {
621                    format!("The {profile} slot is busy")
622                } else {
623                    format!("All {slots} {profile} slots are busy")
624                }
625            }
626            Waiting::Memory { .. } if status.budget.held => {
627                "The slice is past 90% of its MemoryMax, so nothing starts".to_owned()
628            }
629            Waiting::Memory { needs, free } => format!(
630                "Needs {} of memory; {} of the budget is free",
631                size(*needs),
632                size(*free)
633            ),
634            Waiting::HostMemory { needs, free } => {
635                let host = status.budget.host.as_ref();
636                let reason = if host.is_some_and(|host| host.stalled) {
637                    "The host is short of memory: its programs stall waiting for it, so nothing starts"
638                        .to_owned()
639                } else {
640                    format!(
641                        "The host is short of memory: needs {}; the host has {} to spare",
642                        size(*needs),
643                        size(*free)
644                    )
645                };
646                match host {
647                    Some(host) => format!("{reason}; {}", host.memory.outside_line()),
648                    None => reason,
649                }
650            }
651            Waiting::Cpu { needs, free } => {
652                format!(
653                    "Needs {}; {} are free",
654                    cpus(*needs),
655                    admission::free_cores(*free)
656                )
657            }
658            Waiting::BehindCpu { job: older } => {
659                format!("Behind {}, which waits for CPU", older_name(*older))
660            }
661            Waiting::BehindStart { job: older } => {
662                format!(
663                    "Next after {}, whose runner starts first",
664                    older_name(*older)
665                )
666            }
667        }
668    }
669}
670
671/// `GET /v1/dashboard`: the dashboard's `[web]` settings, as the daemon read them when it started.
672#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
673pub struct Dashboard {
674    /// `[web] open`: the one of `[web] origins` that `runnerctl web` opens the dashboard on; the loopback address
675    /// when none.
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    pub open: Option<String>,
678}
679
680/// `POST /v1/dashboard/pairings`: which of `[web] origins` to make a link for.
681#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
682pub struct PairingQuery {
683    /// One of `[web] origins`; each of them when none.
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub origin: Option<String>,
686}
687
688/// `POST /v1/dashboard/pairings`: fresh one-time links that sign a device in to the dashboard.
689#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
690pub struct Pairings {
691    /// One per origin asked for.
692    pub links: Vec<Pairing>,
693}
694
695/// A link that signs one device in to the dashboard on `origin`, and nowhere else, within `lifetime`.
696#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
697pub struct Pairing {
698    /// One of `[web] origins`.
699    pub origin: String,
700    /// The link with its one-time code: a credential, which `Debug` leaves out.
701    pub url: String,
702    /// Seconds it works for from when it was made.
703    pub lifetime: u64,
704}
705
706impl std::fmt::Debug for Pairing {
707    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
708        formatter
709            .debug_struct("Pairing")
710            .field("origin", &self.origin)
711            .field("lifetime", &self.lifetime)
712            .finish_non_exhaustive()
713    }
714}
715
716/// `POST /v1/shutdown`.
717#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
718pub struct ShutdownRequest {
719    /// Kill running jobs and delete their runners instead of waiting for them.
720    #[serde(default)]
721    pub force: bool,
722}
723
724/// The body of every error response.
725#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
726pub struct ErrorResponse {
727    /// What went wrong, for people to read.
728    pub message: String,
729}