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