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