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 pub queued: Vec<QueuedJob>,
141}
142
143/// The memory budget runners and the mirror share, in bytes.
144#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
145pub struct Budget {
146 /// The systemd slice runner containers run in.
147 pub slice: String,
148 /// The slice's `MemoryMax`; `None` when it is unlimited.
149 pub memory_max: Option<u64>,
150 /// Memory the running containers reserve: the runners' caps, or under measured admission the larger of each
151 /// runner's predicted peak and its last sampled memory, and the caps of the seed containers, shells and the
152 /// mirror.
153 pub memory_in_use: u64,
154 /// What a new runner must fit in beside them.
155 pub admission: AdmissionPolicy,
156 /// Under measured admission, whether the slice is at the high-water mark, so no runner starts.
157 pub held: bool,
158 /// The mirror's part of `memory_in_use`: its cap while it is enabled or still running, else 0.
159 pub mirror_memory: u64,
160 /// The seed containers' part of `memory_in_use`, one per profile that reseeds, by profile name.
161 pub seed_memory: Vec<SeedMemory>,
162 /// The shells' part of `memory_in_use`, one per shell open in a record, by job.
163 pub shell_memory: Vec<ShellMemory>,
164 /// What the host's memory leaves runners beside its other programs, from a recent reading of it; absent
165 /// without one.
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub host: Option<HostBudget>,
168 /// The CPU budget, under `[host] cpu_overcommit`. Absent without one.
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub cpu: Option<CpuBudget>,
171 /// The CPUs the slice may use, in millicores: its `AllowedCPUs` or the host's, less under a lower `CPUQuota`,
172 /// whether or not a CPU budget admits runners. The runners' CPU limits are quotas that may add up past it.
173 /// Absent when it could not be read.
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub slice_cpus: Option<u64>,
176}
177
178/// What the host's memory leaves the runners (ADR 0055). New runners fit in the lower of `MemoryMax` and
179/// `limit`; running ones are never stopped for it.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct HostBudget {
182 /// The reading it comes from.
183 pub memory: HostMemory,
184 /// What the slice may hold now: what its runners hold without page cache plus the host's `MemAvailable`, less
185 /// a margin kept free for the host's page cache.
186 pub limit: u64,
187 /// What that leaves new runners beside what the running ones reserve, in bytes.
188 pub spare: u64,
189 /// Whether the host's tasks stall for memory (`full` pressure), so no runner starts until they stop.
190 pub stalled: bool,
191}
192
193/// Hundredths of a percent as a percentage: `1.5%`, `12%`.
194pub fn percent(hundredths: u32) -> String {
195 let whole = hundredths / 100;
196 match hundredths % 100 / 10 {
197 0 => format!("{whole}%"),
198 tenth => format!("{whole}.{tenth}%"),
199 }
200}
201
202/// The CPU budget runners share, in millicores.
203#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
204pub struct CpuBudget {
205 /// The CPUs the slice may use: its `AllowedCPUs` or the host's, less under a lower `CPUQuota`.
206 pub cpus: u64,
207 /// Those times `cpu_overcommit`: what the runners' reservations must fit.
208 pub budget: u64,
209 /// What the runners reserve: each its predicted average CPU under measured admission, else its `cpus`.
210 pub in_use: u64,
211 /// Whether a queued job waits for CPU that memory and a slot have room for.
212 pub held: bool,
213}
214
215impl CpuBudget {
216 /// The budget's line as clients show it beside memory, such as `CPU 4.5 of 32 CPUs reserved` or, while a job
217 /// waits for it, `CPU 30 of 32 CPUs reserved; a job waits for CPU`.
218 pub fn line(&self) -> String {
219 let mut line = format!(
220 "CPU {} of {} CPUs reserved",
221 cores(self.in_use),
222 cores(self.budget)
223 );
224 if self.held {
225 line.push_str("; a job waits for CPU");
226 }
227 line
228 }
229}
230
231impl Budget {
232 /// How runners are admitted, as clients add it to the budget's line: nothing under caps, else `by predicted
233 /// peaks`, and why nothing starts while the slice is at the high-water mark.
234 pub fn admission_note(&self) -> Option<&'static str> {
235 match (self.admission, self.held) {
236 (AdmissionPolicy::Caps, _) => None,
237 (AdmissionPolicy::Measured, false) => Some("by predicted peaks"),
238 (AdmissionPolicy::Measured, true) => Some(
239 "by predicted peaks; the slice is past 90% of its MemoryMax, so no runner starts",
240 ),
241 }
242 }
243}
244
245/// A shell container's memory cap: its record's profile's limit when the shell opened.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct ShellMemory {
248 /// The job whose record the shell runs.
249 pub job: u64,
250 /// Its cap in bytes.
251 pub memory: u64,
252}
253
254/// A seed container's memory cap: its profile's limit when the reseed started, or, for a requested reseed whose
255/// profile drained, the room it holds until it starts.
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257pub struct SeedMemory {
258 /// The profile it reseeds.
259 pub profile: String,
260 /// Its cap in bytes.
261 pub memory: u64,
262}
263
264/// A runner profile and what runs in its slots.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct ProfileStatus {
267 /// The name under `[profiles]` in `runnerd.toml`.
268 pub name: String,
269 /// The image its runners run, such as `runnerd-runner:base`.
270 pub image: String,
271 /// CPUs per slot.
272 pub cpus: u32,
273 /// Bytes per slot.
274 pub memory: u64,
275 /// Set while slot 1's layers become the profile's snapshot.
276 pub reseeding: bool,
277 /// Index 0 is slot 1.
278 pub slots: Vec<SlotStatus>,
279}
280
281/// One slot of a profile: room for one job.
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct SlotStatus {
284 /// 1-based.
285 pub slot: usize,
286 /// `None` when the slot is free.
287 pub runner: Option<RunnerStatus>,
288}
289
290/// A runner registered for one job, in its container.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub struct RunnerStatus {
293 /// `owner/name` the runner is registered on.
294 pub repository: String,
295 /// The runner's name, also its container's.
296 pub name: String,
297 /// The runner's ID on GitHub.
298 pub runner_id: u64,
299 /// The Docker container ID.
300 pub container: String,
301 /// Whether it runs a job, as GitHub or its log reports it: its log names the job before GitHub lists it busy.
302 pub busy: bool,
303 /// Seconds since the Unix epoch.
304 pub started_at: u64,
305 /// What it takes of the budget: its cap under caps; under measured admission the larger of its predicted peak
306 /// and its last sampled memory.
307 pub reserved: u64,
308 /// A warm runner: idle, and no queued job of its repository waits for it; it takes the next one GitHub hands
309 /// out.
310 pub warm: bool,
311}
312
313/// A configured repository and its queue.
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315pub struct RepositoryStatus {
316 /// `owner/name` on GitHub.
317 pub name: String,
318 /// Its first pool's profile, which 0.0.19's TUI and dashboard colour it by. Read `pools`; it goes when the wire
319 /// check moves past 0.0.19 (ADR 0073).
320 pub profile: String,
321 /// Its first pool's labels, for 0.0.19's clients as `profile` is.
322 pub labels: Vec<String>,
323 /// Its pools in config order, one per `[[repositories]]` entry: a job runs in the one whose labels match it
324 /// (ADR 0058).
325 pub pools: Vec<PoolStatus>,
326 /// Paused repositories get no new runners; their running jobs finish.
327 pub paused: bool,
328 /// Its jobs in `Status::queued`: queued ones none of its runners took yet, those no pool serves too.
329 pub queued_jobs: usize,
330}
331
332/// One of a repository's pools: its runners in one profile.
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub struct PoolStatus {
335 /// The profile its jobs run in.
336 pub profile: String,
337 /// The labels its runners register with.
338 pub labels: Vec<String>,
339 /// Its jobs in `Status::queued`: those its labels match.
340 pub queued_jobs: usize,
341}
342
343/// A queued job runners of this daemon can take, as GitHub listed it at the last poll.
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct QueuedJob {
346 /// GitHub's job ID.
347 pub id: u64,
348 /// The repository whose runners can take it, `owner/name`.
349 pub repository: String,
350 /// The profile of its repository's pool whose labels match it; `None` when no pool's do.
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub profile: Option<String>,
353 /// The workflow run it belongs to.
354 pub run_id: u64,
355 /// 1 for the first attempt of the run, higher for re-runs.
356 pub run_attempt: u32,
357 /// The workflow's name.
358 pub workflow: Option<String>,
359 /// The job's name in the workflow.
360 pub name: String,
361 /// The branch the run is for.
362 pub branch: Option<String>,
363 /// The job's page on GitHub.
364 pub url: Option<String>,
365 /// When GitHub queued it, in seconds since the Unix epoch; 0 if GitHub did not say.
366 pub queued_at: u64,
367 /// Why no runner has it yet, as the last plan saw it. `None` before the daemon has planned with it, and while
368 /// it shuts down.
369 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub waiting: Option<Waiting>,
371}
372
373/// The slot of an idle runner of `job`'s repository, in its pool's profile when known, such as `jvm 2`.
374fn starting_for(job: &QueuedJob, status: &Status) -> Option<String> {
375 status
376 .profiles
377 .iter()
378 .filter(|profile| job.profile.as_ref().is_none_or(|own| *own == profile.name))
379 .find_map(|profile| {
380 profile
381 .slots
382 .iter()
383 .find(|slot| {
384 slot.runner
385 .as_ref()
386 .is_some_and(|runner| runner.repository == job.repository && !runner.busy)
387 })
388 .map(|slot| format!("{}\u{a0}{}", profile.name, slot.slot))
389 })
390}
391
392impl QueuedJob {
393 /// Where it runs, as every client names it: its repository, and its pool's profile when the repository has
394 /// several pools in `status`, such as `halyard/ledger · light`.
395 pub fn place(&self, status: &Status) -> String {
396 let pools = status
397 .repositories
398 .iter()
399 .find(|repository| repository.name == self.repository)
400 .map_or(0, |repository| repository.pools.len());
401 match self.profile.as_deref().filter(|_| pools > 1) {
402 Some(profile) => format!("{}\u{a0}· {profile}", self.repository),
403 None => self.repository.clone(),
404 }
405 }
406}
407
408impl RepositoryStatus {
409 /// The profile of its first pool, which every client colours it by.
410 pub fn first_profile(&self) -> &str {
411 self.pools.first().map_or("", |pool| pool.profile.as_str())
412 }
413
414 /// Its pools' profiles, as every client lists them on one line: `jvm` or `jvm + light`.
415 pub fn profiles(&self) -> String {
416 self.pools
417 .iter()
418 .map(|pool| pool.profile.as_str())
419 .collect::<Vec<_>>()
420 .join(" + ")
421 }
422
423 /// The profile of its pool whose labels match a job asking for `labels`, as the daemon picks it.
424 pub fn pool_for(&self, labels: &[String]) -> Option<String> {
425 self.pools
426 .iter()
427 .find(|pool| crate::config::labels_match(labels, &pool.labels))
428 .map(|pool| pool.profile.clone())
429 }
430}
431
432/// What failed, that holds a repository's runners back for a while.
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
434#[serde(rename_all = "kebab-case")]
435pub enum HoldCause {
436 /// Its runners ended without taking a job.
437 NoJob,
438 /// Its runners ended without a job after waiting for one, twice in a row.
439 IdleExits,
440 /// GitHub did not register its runner.
441 Register,
442 /// Docker did not start its runner's container.
443 Start,
444}
445
446/// Why a queued job has no runner of its own yet. Jobs are served oldest first; each takes the first of these that
447/// holds for it.
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
449#[serde(rename_all = "kebab-case", tag = "kind")]
450pub enum Waiting {
451 /// Its repository is paused.
452 Paused,
453 /// Runners of its repository failed, so none starts before `until` (seconds since the Unix epoch).
454 Backoff {
455 /// When runners may start again.
456 until: u64,
457 /// What failed.
458 cause: HoldCause,
459 },
460 /// A runner started for it and waits for GitHub to hand it the job: a runner takes any job its labels match,
461 /// so not necessarily this one.
462 RunnerStarting,
463 /// Slot 1 of its profile is becoming the profile's snapshot, so none of the profile's slots starts.
464 Reseeding {
465 /// The profile.
466 profile: String,
467 },
468 /// Its profile's image is being pulled, so none of the profile's slots starts.
469 PullingImage {
470 /// The profile.
471 profile: String,
472 },
473 /// Every slot of its profile holds a runner.
474 NoSlot {
475 /// The profile.
476 profile: String,
477 },
478 /// What its runner would reserve does not fit the memory budget left. Under measured admission, `free` is 0
479 /// while the slice is at the high-water mark (`Budget::held`).
480 Memory {
481 /// What its runner would reserve, in bytes.
482 needs: u64,
483 /// What the budget has left, in bytes.
484 free: u64,
485 },
486 /// What its runner would reserve does not fit what the host's memory has to spare beside its other programs
487 /// (`HostBudget::spare`), though it fits the slice's budget. `free` is 0 while the host's tasks stall for memory
488 /// (`HostBudget::stalled`).
489 HostMemory {
490 /// What its runner would reserve, in bytes.
491 needs: u64,
492 /// What the host has to spare, in bytes.
493 free: u64,
494 },
495 /// What its runner would reserve does not fit the CPU budget left, in millicores.
496 Cpu {
497 /// What its runner would reserve.
498 needs: u64,
499 /// What the CPU budget has left.
500 free: u64,
501 },
502 /// An older job has waited long enough for CPU that no younger one takes CPU before it.
503 BehindCpu {
504 /// The older job's ID.
505 job: u64,
506 },
507 /// An older job is about to have a runner start for it, which takes the slot, memory or CPU this one would
508 /// otherwise start with: what holds it is that start, not what runs now.
509 BehindStart {
510 /// The older job's ID.
511 job: u64,
512 },
513 /// No pool of its repository has every label it asks for, so no runner of this daemon can take it (ADR 0058).
514 NoPool {
515 /// The labels it asks for.
516 labels: Vec<String>,
517 },
518 /// A reason from a newer daemon than this client knows, so a reason added later does not keep the client from
519 /// reading the status.
520 #[serde(other)]
521 Unknown,
522}
523
524/// What a [`Waiting`] reason waits on, which clients draw it by.
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526pub enum WaitKind {
527 /// Something the owner can act on: a pause, runners that failed to take a job, or labels no pool serves.
528 Act,
529 /// Something that clears by itself shortly: a runner starting, a reseed, an image pull, an older job's start.
530 Soon,
531 /// Room: a slot, memory or CPU that a running job holds.
532 Full,
533 /// A reason this client does not know (`Waiting::Unknown`), drawn neutral.
534 Other,
535}
536
537impl Waiting {
538 /// What it waits on.
539 pub fn kind(&self) -> WaitKind {
540 match self {
541 Waiting::Paused | Waiting::Backoff { .. } | Waiting::NoPool { .. } => WaitKind::Act,
542 Waiting::Unknown => WaitKind::Other,
543 Waiting::RunnerStarting
544 | Waiting::Reseeding { .. }
545 | Waiting::PullingImage { .. }
546 | Waiting::BehindStart { .. } => WaitKind::Soon,
547 Waiting::NoSlot { .. }
548 | Waiting::Memory { .. }
549 | Waiting::HostMemory { .. }
550 | Waiting::Cpu { .. }
551 | Waiting::BehindCpu { .. } => WaitKind::Full,
552 }
553 }
554
555 /// Why `job` waits, in the owner's words with the numbers that hold it, as every client says it: such as
556 /// `All 2 node slots are busy` or `Needs 9G of memory; 2.5G of the budget is free`. `status` is the one `job`
557 /// is queued in, which names the slot of a starting runner and the older job one waits behind.
558 pub fn reason(&self, job: &QueuedJob, status: &Status, now: u64) -> String {
559 let size = |bytes: u64| crate::config::ByteSize(bytes).to_string();
560 let older_name = |id: u64| {
561 status
562 .queued
563 .iter()
564 .find(|candidate| candidate.id == id)
565 .map_or_else(|| "an older job".to_owned(), |older| older.name.clone())
566 };
567 match self {
568 Waiting::Paused => format!("{} is paused", job.repository),
569 Waiting::Unknown => "Waits for a reason this client does not know yet".to_owned(),
570 Waiting::NoPool { labels } if labels.is_empty() => {
571 "It asks for no labels, so no profile serves it".to_owned()
572 }
573 Waiting::NoPool { labels } => format!(
574 "No profile serves the {} {}",
575 if labels.len() == 1 { "label" } else { "labels" },
576 labels.join(", ")
577 ),
578 Waiting::Backoff { until, cause } => format!(
579 "{}; retrying in {}",
580 match cause {
581 HoldCause::NoJob => "Runners failed to take a job",
582 HoldCause::IdleExits => "Runners ended without a job after waiting for one",
583 HoldCause::Register => "Runners cannot register with GitHub",
584 HoldCause::Start => "Runners cannot start",
585 },
586 duration(until.saturating_sub(now))
587 ),
588 Waiting::RunnerStarting => match starting_for(job, status) {
589 Some(slot) => format!("A runner is starting for it in {slot}"),
590 None => "A runner is starting for it".to_owned(),
591 },
592 Waiting::Reseeding { profile } => format!("{profile} is rebuilding its snapshot"),
593 Waiting::PullingImage { profile } => format!("Pulling {profile}'s image"),
594 Waiting::NoSlot { profile } => {
595 let slots = status
596 .profiles
597 .iter()
598 .find(|candidate| &candidate.name == profile)
599 .map_or(0, |profile| profile.slots.len());
600 if slots == 1 {
601 format!("The {profile} slot is busy")
602 } else {
603 format!("All {slots} {profile} slots are busy")
604 }
605 }
606 Waiting::Memory { .. } if status.budget.held => {
607 "The slice is past 90% of its MemoryMax, so nothing starts".to_owned()
608 }
609 Waiting::Memory { needs, free } => format!(
610 "Needs {} of memory; {} of the budget is free",
611 size(*needs),
612 size(*free)
613 ),
614 Waiting::HostMemory { needs, free } => {
615 let host = status.budget.host.as_ref();
616 let reason = if host.is_some_and(|host| host.stalled) {
617 "The host is short of memory: its programs stall waiting for it, so nothing starts"
618 .to_owned()
619 } else {
620 format!(
621 "The host is short of memory: needs {}; the host has {} to spare",
622 size(*needs),
623 size(*free)
624 )
625 };
626 match host {
627 Some(host) => format!("{reason}; {}", host.memory.outside_line()),
628 None => reason,
629 }
630 }
631 Waiting::Cpu { needs, free } => {
632 format!(
633 "Needs {}; {} are free",
634 cpus(*needs),
635 admission::free_cores(*free)
636 )
637 }
638 Waiting::BehindCpu { job: older } => {
639 format!("Behind {}, which waits for CPU", older_name(*older))
640 }
641 Waiting::BehindStart { job: older } => {
642 format!(
643 "Next after {}, whose runner starts first",
644 older_name(*older)
645 )
646 }
647 }
648 }
649}
650
651/// `GET /v1/dashboard`: the dashboard's `[web]` settings, as the daemon read them when it started.
652#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
653pub struct Dashboard {
654 /// `[web] open`: the one of `[web] origins` that `runnerctl web` opens the dashboard on; the loopback address
655 /// when none.
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub open: Option<String>,
658}
659
660/// `POST /v1/dashboard/pairings`: which of `[web] origins` to make a link for.
661#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
662pub struct PairingQuery {
663 /// One of `[web] origins`; each of them when none.
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub origin: Option<String>,
666}
667
668/// `POST /v1/dashboard/pairings`: fresh one-time links that sign a device in to the dashboard.
669#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
670pub struct Pairings {
671 /// One per origin asked for.
672 pub links: Vec<Pairing>,
673}
674
675/// A link that signs one device in to the dashboard on `origin`, and nowhere else, within `lifetime`.
676#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
677pub struct Pairing {
678 /// One of `[web] origins`.
679 pub origin: String,
680 /// The link with its one-time code: a credential, which `Debug` leaves out.
681 pub url: String,
682 /// Seconds it works for from when it was made.
683 pub lifetime: u64,
684}
685
686impl std::fmt::Debug for Pairing {
687 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
688 formatter
689 .debug_struct("Pairing")
690 .field("origin", &self.origin)
691 .field("lifetime", &self.lifetime)
692 .finish_non_exhaustive()
693 }
694}
695
696/// `POST /v1/shutdown`.
697#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
698pub struct ShutdownRequest {
699 /// Kill running jobs and delete their runners instead of waiting for them.
700 #[serde(default)]
701 pub force: bool,
702}
703
704/// The body of every error response.
705#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
706pub struct ErrorResponse {
707 /// What went wrong, for people to read.
708 pub message: String,
709}