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}