Skip to content
693 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Fast pages, required checks on the branch, self-hosted runners, honest incidents1//! Self-hosted runners: a workspace's or a repository's own machines, which
2//! run its workflow jobs (and, when it says so, its agents' work) instead
3//! of g1t's sandboxes. Time on them costs nothing.
4//!
5//! The actions service keeps them, beside the jobs they run. A machine runs
6//! `g1t-runner` (`crates/runner`), which registers once with a short-lived
7//! registration token an admin made, gets a credential of its own, and then
8//! only ever calls out: it polls `api.g1t.sh` for work, runs what it is
9//! given with the same harness g1t's sandboxes use, and reports back. No
10//! port is opened on the machine.
11//!
12//! - A **registration token** (`g1trt_…`) is made by an owner of the
13//! workspace (or an admin of the repository) and lasts an hour. It can
14//! register any number of runners until then, and nothing else.
15//! - A **runner credential** (`g1tr_…`) belongs to one runner. It is stored
16//! only as a hash, rotates every day (the poll that rotates it hands the
17//! new one over), and is revoked when the runner is removed. It can poll
18//! for work, report on what that runner was given, and remove the runner;
19//! nothing else. Any other endpoint refuses it.
20//! - A job is given to a runner when every label in its `runs-on` is one
21//! of the runner's labels (ignoring case), and the runner's group lets
22//! the job's repository use it. A job whose `runs-on` names
23//! `self-hosted` (or a `group`) only ever runs on self-hosted runners; it
24//! waits, saying for which labels, until one picks it up, for a day at
25//! most.
26//!
27//! The management methods take an `actor` and an owner (`repo` or
28//! `workspace`), as secrets do. The runner's own methods take its id and
29//! credential. Mirrors `packages/contracts/src/runners.ts`.
30
31use serde::{Deserialize, Serialize};
32use serde_json::{Map, Value};
33
34use crate::User;
35use crate::repos::RepoPath;
36
37/// Every runner has these labels, whatever else it was given.
38pub const SELF_HOSTED: &str = "self-hosted";
39/// What a registration token starts with.
40pub const REGISTRATION_PREFIX: &str = "g1trt_";
41/// What a runner's credential starts with.
42pub const CREDENTIAL_PREFIX: &str = "g1tr_";
43/// How long a registration token lasts, in seconds.
44pub const REGISTRATION_TTL_SECONDS: u64 = 60 * 60;
45/// How long a job may wait for a self-hosted runner before it fails.
46pub const MAX_WAIT_HOURS: u64 = 24;
47/// A runner that has not polled for this long is offline.
48pub const ONLINE_WITHIN_MS: u64 = 90 * 1000;
49/// How long one poll waits for work before answering with none.
50pub const MAX_POLL_WAIT_MS: u64 = 20 * 1000;
51/// How often a runner's credential is replaced.
52pub const ROTATE_AFTER_MS: u64 = 24 * 60 * 60 * 1000;
53/// The most labels a runner has, and the longest one.
54pub const MAX_LABELS: usize = 30;
55pub const MAX_LABEL_LEN: usize = 64;
56/// The most runners a workspace has.
57pub const MAX_RUNNERS: u32 = 500;
58/// The most runners a workspace registers in one minute.
59pub const MAX_REGISTRATIONS_PER_MINUTE: u32 = 30;
60/// The most registration tokens a workspace makes in one hour.
61pub const MAX_TOKENS_PER_HOUR: u32 = 60;
62
63// --- What people see -------------------------------------------------------
64
65/// What a runner is doing now.
66#[derive(Clone, Debug, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct RunnerWork {
69 /// `workflow` (a workflow job) or `agent` (an agent's run, checks, a
70 /// review, the merge queue).
71 pub kind: String,
72 pub id: String,
73 /// The job's name, or what the agent is doing.
74 pub name: String,
75 /// `owner/name`.
76 pub repo: Option<String>,
The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how.77 /// For a link: the workflow run the job is in, or for `agent` work
78 /// the agent run it is (`/<owner>/<name>/agents/runs/<id>`), when
79 /// the sandbox that handed it over said which.
Fast pages, required checks on the branch, self-hosted runners, honest incidents80 pub run_id: Option<String>,
81 pub started_at: Option<String>,
82}
83
84#[derive(Clone, Debug, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct Runner {
87 pub id: String,
88 pub name: String,
89 pub workspace: String,
90 /// `owner/name` for a repository's own runner; null for the workspace's.
91 pub repo: Option<String>,
92 /// Its group's name; null for a repository's runner.
93 pub group: Option<String>,
94 /// Every label, `self-hosted`, its OS and architecture among them.
95 pub labels: Vec<String>,
96 /// `linux`, `macos` or `windows`.
97 pub os: String,
98 /// `x64` or `arm64`.
99 pub arch: String,
100 /// The `g1t-runner` version it last reported.
101 pub version: String,
102 /// Runs one job, then removes itself.
103 pub ephemeral: bool,
104 /// `online`, `busy` or `offline`.
105 pub status: String,
106 pub work: Option<RunnerWork>,
107 pub last_seen_at: Option<String>,
108 pub created_at: String,
109 pub created_by: Option<String>,
110}
111
112/// Which of a workspace's repositories may use its runners.
113#[derive(Clone, Debug, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct RunnerGroup {
116 pub id: String,
117 pub name: String,
118 /// The group runners join when none is named: every repository.
119 pub default: bool,
120 /// Repository names (without the workspace) that may use it; empty is
121 /// every repository in the workspace.
122 pub repositories: Vec<String>,
123 pub runners: u32,
124 pub updated_at: String,
125}
126
127/// A registration token, shown once.
128#[derive(Clone, Debug, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct RegistrationToken {
131 pub token: String,
132 pub expires_at: String,
133 pub workspace: String,
134 pub repo: Option<String>,
135 pub group: Option<String>,
136 /// What to pass as `--url`.
137 pub url: String,
138}
139
140/// Where a workspace's (or a repository's) agents work, and whether pull
141/// requests from forks may use its runners.
142#[derive(Clone, Debug, Default, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct RunnerSettings {
145 /// Agent runs, checks, reviews and the merge queue run on self-hosted
146 /// runners with `agent_labels`, instead of g1t's sandboxes.
147 pub agents_on_self_hosted: bool,
148 /// The labels a runner needs to take agent work; `self-hosted` is
149 /// always one.
150 pub agent_labels: Vec<String>,
151 /// Jobs of pull requests from forks may run on self-hosted runners.
152 /// Off by default: anyone who can open a pull request could run code
153 /// on the machine.
154 pub fork_pull_requests: bool,
155 /// For a repository: these are its workspace's, not its own.
156 pub inherited: bool,
157}
158
159// --- Management ------------------------------------------------------------
160
161/// A workspace's runners (`workspace`), or a repository's own (`repo`).
162#[derive(Clone, Debug, Default, Serialize, Deserialize)]
163pub struct RunnersOwner {
164 #[serde(default)]
165 pub repo: Option<RepoPath>,
166 #[serde(default)]
167 pub workspace: Option<String>,
168}
169
170/// `runners`: a workspace's runners (members), or a repository's own
171/// (its admins). Returns `Outcome<Vec<Runner>>`.
172#[derive(Debug, Serialize, Deserialize)]
173pub struct ListRunnersArgs {
174 pub actor: User,
175 #[serde(flatten)]
176 pub owner: RunnersOwner,
177}
178
179/// `create_registration_token`: owners of the workspace, or admins of the
180/// repository. Returns `Outcome<RegistrationToken>`.
181#[derive(Debug, Serialize, Deserialize)]
182pub struct CreateRegistrationTokenArgs {
183 pub actor: User,
184 #[serde(flatten)]
185 pub owner: RunnersOwner,
186 /// The group its runners join, by name or id; the default group if
187 /// left out. Not for a repository's runners.
188 #[serde(default)]
189 pub group: Option<String>,
190}
191
192/// `remove_runner`: as `create_registration_token`. A job it is running is
193/// cancelled. Returns `Outcome<bool>`.
194#[derive(Debug, Serialize, Deserialize)]
195pub struct RemoveRunnerArgs {
196 pub actor: User,
197 #[serde(flatten)]
198 pub owner: RunnersOwner,
199 pub id: String,
200}
201
202/// `runner_groups`: members. Returns `Outcome<Vec<RunnerGroup>>`.
203#[derive(Debug, Serialize, Deserialize)]
204pub struct RunnerGroupsArgs {
205 pub actor: User,
206 pub workspace: String,
207}
208
209/// `set_runner_group`: create one (no `id`) or change one. Owners.
210/// Returns `Outcome<RunnerGroup>`.
211#[derive(Debug, Serialize, Deserialize)]
212pub struct SetRunnerGroupArgs {
213 pub actor: User,
214 pub workspace: String,
215 #[serde(default)]
216 pub id: Option<String>,
217 #[serde(default)]
218 pub name: Option<String>,
219 /// Repository names; empty for every repository.
220 #[serde(default)]
221 pub repositories: Option<Vec<String>>,
222}
223
224/// `delete_runner_group`: owners. Its runners move to the default group,
225/// which cannot be deleted. Returns `Outcome<bool>`.
226#[derive(Debug, Serialize, Deserialize)]
227pub struct DeleteRunnerGroupArgs {
228 pub actor: User,
229 pub workspace: String,
230 pub id: String,
231}
232
233/// `runner_settings`: members of the workspace, or admins of the
234/// repository. Returns `Outcome<RunnerSettings>`.
235#[derive(Debug, Serialize, Deserialize)]
236pub struct RunnerSettingsArgs {
237 pub actor: User,
238 #[serde(flatten)]
239 pub owner: RunnersOwner,
240}
241
242/// `set_runner_settings`: owners, or admins of the repository. Left out is
243/// unchanged; `inherit` drops a repository's own settings. Returns
244/// `Outcome<RunnerSettings>`.
245#[derive(Debug, Serialize, Deserialize)]
246pub struct SetRunnerSettingsArgs {
247 pub actor: User,
248 #[serde(flatten)]
249 pub owner: RunnersOwner,
250 #[serde(default)]
251 pub agents_on_self_hosted: Option<bool>,
252 #[serde(default)]
253 pub agent_labels: Option<Vec<String>>,
254 #[serde(default)]
255 pub fork_pull_requests: Option<bool>,
256 #[serde(default)]
257 pub inherit: bool,
258}
259
260/// `stuck_jobs`: Mission control's Needs you. Returns `Vec<StuckJob>`.
261#[derive(Debug, Serialize, Deserialize)]
262pub struct StuckJobsArgs {
263 pub viewer: crate::Viewer,
264}
265
The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how.266/// `runner_activity`: what runs for a workspace now, on g1t's cloud and
267/// handed to its own runners, for the Runners page. Owners, as the
268/// workspace's runners are. Snake case both ways. Returns
269/// `Outcome<RunnerActivity>`.
270#[derive(Debug, Serialize, Deserialize)]
271pub struct RunnerActivityArgs {
272 pub actor: User,
273 pub workspace: String,
274}
275
276/// A workflow job running in one of g1t's sandboxes.
277#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
278pub struct CloudJob {
279 pub id: String,
280 pub name: String,
281 /// The workflow run it is in.
282 pub run_id: String,
283 /// `owner/name`.
284 pub repo: String,
285 pub started_at: Option<String>,
286}
287
288/// Agent work handed to one of the workspace's own runners, waiting for
289/// one or taken.
290#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
291pub struct HandedOverTask {
292 pub id: String,
293 /// The agent run it is, when the sandbox said; older tasks have none.
294 pub run_id: Option<String>,
295 /// `agent`, `checks`, `review`, `queue`…
296 pub kind: String,
297 pub title: String,
298 /// `owner/name`.
299 pub repo: String,
300 /// `queued` or `in_progress`.
301 pub status: String,
302 pub runner_name: Option<String>,
303 pub created_at: String,
304 pub started_at: Option<String>,
305}
306
307#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
308pub struct RunnerActivity {
309 /// Workflow jobs in g1t's sandboxes now, newest first, at most
310 /// `ACTIVITY_LIMIT`.
311 pub cloud_jobs: Vec<CloudJob>,
312 /// Workflow jobs queued for g1t's sandboxes.
313 pub cloud_jobs_queued: u32,
314 /// Agent work on, or waiting for, the workspace's own runners.
315 pub handed_over: Vec<HandedOverTask>,
316 /// Workflow jobs waiting for one of the workspace's own runners.
317 pub self_hosted_jobs_queued: u32,
318}
319
320/// The most jobs and tasks `runner_activity` lists of each.
321pub const ACTIVITY_LIMIT: u32 = 100;
322
Fast pages, required checks on the branch, self-hosted runners, honest incidents323/// A job that has waited ten minutes or more for a self-hosted runner,
324/// with none that matches online.
325#[derive(Clone, Debug, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct StuckJob {
328 pub id: String,
329 pub name: String,
330 pub run_id: String,
331 /// `owner/name`.
332 pub repo: String,
333 /// What it asks for, for people: `self-hosted, linux, gpu`.
334 pub labels: String,
335 pub queued_at: String,
336}
337
338// --- The runner's side (snake_case on the wire, as the API passes it) ------
339
340/// `runner_register`: a machine registering with a registration token.
341/// Returns `Outcome<Registered>`.
342#[derive(Clone, Debug, Default, Serialize, Deserialize)]
343pub struct RegisterArgs {
344 pub token: String,
345 pub name: String,
346 #[serde(default)]
347 pub labels: Vec<String>,
348 pub os: String,
349 pub arch: String,
350 #[serde(default)]
351 pub version: String,
352 #[serde(default)]
353 pub ephemeral: bool,
354 /// A group by name, for a workspace's token; the token's group, else
355 /// the default, if left out.
356 #[serde(default)]
357 pub group: Option<String>,
358 /// Replace a runner of the same name instead of refusing.
359 #[serde(default)]
360 pub replace: bool,
361}
362
363#[derive(Clone, Debug, Serialize, Deserialize)]
364pub struct Registered {
365 pub runner: Runner,
366 /// Shown once; the runner keeps it in its configuration.
367 pub credential: String,
368}
369
370/// Every call a registered runner makes carries its id and credential.
371#[derive(Clone, Debug, Default, Serialize, Deserialize)]
372pub struct RunnerAuth {
373 pub runner: String,
374 pub credential: String,
375}
376
377/// `runner_poll`: waits up to `wait_ms` for work. Doubles as the runner's
378/// heartbeat. Returns `Outcome<Poll>`.
379#[derive(Clone, Debug, Default, Serialize, Deserialize)]
380pub struct PollArgs {
381 #[serde(flatten)]
382 pub auth: RunnerAuth,
383 #[serde(default)]
384 pub version: String,
385 /// What it is running now; it is given nothing more while busy.
386 #[serde(default)]
387 pub running: Vec<String>,
388 #[serde(default)]
389 pub wait_ms: u64,
390}
391
392/// Work for a runner.
393#[derive(Clone, Debug, Serialize, Deserialize)]
394pub struct Assignment {
395 /// `workflow` or `agent`.
396 pub kind: String,
397 pub id: String,
398 pub name: String,
399 pub repo: String,
400 pub timeout_minutes: u32,
401 /// The image to run it in: the job's `container:`, when it names one.
402 pub image: Option<String>,
403 /// A workflow job's own token, for `ACTIONS_JOB`/`ACTIONS_TOKEN`.
404 pub token: Option<String>,
405 /// An agent task's environment, as g1t's sandbox would have been
406 /// started with: `MODE`, the run's short-lived credentials, the model
407 /// proxy's address.
408 pub env: Option<Map<String, Value>>,
409}
410
411#[derive(Clone, Debug, Default, Serialize, Deserialize)]
412pub struct Poll {
413 pub assignment: Option<Assignment>,
414 /// Work it holds that it should stop: cancelled, timed out, removed.
415 #[serde(default)]
416 pub cancel: Vec<String>,
417 /// A new credential: it replaces the old, which stops working once this
418 /// one is used.
419 pub credential: Option<String>,
420 /// The runner was removed: stop and forget the credential.
421 #[serde(default)]
422 pub removed: bool,
423}
424
425/// `runner_finished`: a runner telling what became of work it was given.
426/// For a workflow job the harness has normally reported already; this
427/// covers one that crashed or was killed. Returns `Outcome<bool>` (whether
428/// it changed anything).
429#[derive(Clone, Debug, Default, Serialize, Deserialize)]
430pub struct FinishedArgs {
431 #[serde(flatten)]
432 pub auth: RunnerAuth,
433 pub id: String,
434 pub exit_code: i32,
435 #[serde(default)]
436 pub reason: Option<String>,
437}
438
439/// `runner_remove_self`: `g1t-runner remove`. Returns `Outcome<bool>`.
440#[derive(Clone, Debug, Default, Serialize, Deserialize)]
441pub struct RemoveSelfArgs {
442 #[serde(flatten)]
443 pub auth: RunnerAuth,
444}
445
446// --- Between services (camelCase, as every service speaks) ----------------
447
448/// `runner_route`: whether a kind of g1t's own work in a repository runs on
449/// self-hosted runners, and on which labels. Asked by the runner service
450/// before it starts a sandbox. Returns `Option<Vec<String>>`.
451#[derive(Clone, Debug, Serialize, Deserialize)]
452#[serde(rename_all = "camelCase")]
453pub struct RouteArgs {
454 pub workspace: String,
455 pub repo: RepoPath,
456}
457
458/// `enqueue_task`: agent work for a self-hosted runner, from the runner
459/// service's sandbox (`sandbox` is its Durable Object's id), which is told
460/// with `task_ended` how it went. Returns `Outcome<String>` (the task's id).
461#[derive(Clone, Debug, Serialize, Deserialize)]
462#[serde(rename_all = "camelCase")]
463pub struct EnqueueTaskArgs {
464 pub sandbox: String,
465 pub workspace: String,
466 pub repo: RepoPath,
467 /// The run kind: `agent`, `checks`, `review`, `queue`…
468 pub kind: String,
469 pub title: String,
470 pub labels: Vec<String>,
471 pub env: Map<String, Value>,
472 pub timeout_minutes: u32,
The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how.473 /// The agent run the work belongs to, when the sandbox opened one: what
474 /// people's pages link a runner's agent work to.
475 #[serde(default, alias = "run_id")]
476 pub run_id: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents477}
478
479/// `cancel_task`: the sandbox's work stopped from g1t's side (a person, a
480/// time cap). Returns `Outcome<bool>`.
481#[derive(Clone, Debug, Serialize, Deserialize)]
482#[serde(rename_all = "camelCase")]
483pub struct CancelTaskArgs {
484 pub sandbox: String,
485 #[serde(default)]
486 pub reason: Option<String>,
487}
488
489// --- Labels ----------------------------------------------------------------
490
491/// A label as it is kept: trimmed and lowercase. `None` when it is not
492/// one: empty, too long, or with characters other than letters, digits,
493/// `-`, `_`, `.`, `:` and `/`.
494pub fn clean_label(label: &str) -> Option<String> {
495 let label = label.trim().to_ascii_lowercase();
496 let ok = !label.is_empty()
497 && label.len() <= MAX_LABEL_LEN
498 && label.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '/'));
499 ok.then_some(label)
500}
501
502/// A runner's labels: what it was given, cleaned, with `self-hosted`, its
503/// OS and its architecture, each once, in that order first.
504pub fn runner_labels(given: &[String], os: &str, arch: &str) -> Result<Vec<String>, String> {
505 let mut labels: Vec<String> = Vec::new();
506 for label in [SELF_HOSTED, os, arch].iter().map(|s| s.to_string()).chain(given.iter().cloned()) {
507 let Some(clean) = clean_label(&label) else {
508 return Err(format!("`{label}` is not a label: use letters, digits, `-`, `_`, `.`, `:` and `/`, up to {MAX_LABEL_LEN} characters."));
509 };
510 if !labels.contains(&clean) {
511 labels.push(clean);
512 }
513 }
514 if labels.len() > MAX_LABELS {
515 return Err(format!("A runner has at most {MAX_LABELS} labels."));
516 }
517 Ok(labels)
518}
519
520/// The OS a runner reports, as its label: `linux`, `macos` or `windows`.
521pub fn os_label(os: &str) -> Option<&'static str> {
522 match os.trim().to_ascii_lowercase().as_str() {
523 "linux" => Some("linux"),
524 "macos" | "darwin" | "osx" | "mac" => Some("macos"),
525 "windows" | "win" => Some("windows"),
526 _ => None,
527 }
528}
529
530/// The architecture a runner reports, as its label: `x64` or `arm64`.
531pub fn arch_label(arch: &str) -> Option<&'static str> {
532 match arch.trim().to_ascii_lowercase().as_str() {
533 "x64" | "x86_64" | "amd64" => Some("x64"),
534 "arm64" | "aarch64" => Some("arm64"),
535 _ => None,
536 }
537}
538
539/// What a job's `runs-on` asks for, read for self-hosted runners: its
540/// labels, cleaned, and the group it names. `self_hosted` when it names
541/// `self-hosted` or a group, which only self-hosted runners can satisfy.
542#[derive(Clone, Debug, Default, PartialEq, Eq)]
543pub struct Wanted {
544 pub labels: Vec<String>,
545 pub group: Option<String>,
546 pub self_hosted: bool,
547}
548
549impl Wanted {
550 pub fn of(labels: &[String], group: Option<&str>) -> Wanted {
551 let mut out: Vec<String> = Vec::new();
552 for label in labels {
553 let clean = label.trim().to_ascii_lowercase();
554 if !clean.is_empty() && !out.contains(&clean) {
555 out.push(clean);
556 }
557 }
558 let group = group.map(|g| g.trim().to_owned()).filter(|g| !g.is_empty());
559 let self_hosted = group.is_some() || out.iter().any(|l| l == SELF_HOSTED);
560 Wanted { labels: out, group, self_hosted }
561 }
562
563 /// The job's labels as stored on it: its labels, then `group:<name>`.
564 pub fn stored(&self) -> Vec<String> {
565 let mut out = self.labels.clone();
566 if let Some(group) = &self.group {
567 out.push(format!("group:{}", group.to_ascii_lowercase()));
568 }
569 out
570 }
571
572 /// Back from what [`Wanted::stored`] kept.
573 pub fn from_stored(stored: &[String]) -> Wanted {
574 let group = stored.iter().find_map(|l| l.strip_prefix("group:").map(str::to_owned));
575 let labels: Vec<String> = stored.iter().filter(|l| !l.starts_with("group:")).cloned().collect();
576 Wanted::of(&labels, group.as_deref())
577 }
578
579 /// Whether a runner with `labels`, in the group called `group`, can
580 /// take it: every label it asks for, and its group if it names one.
581 pub fn matches(&self, labels: &[String], group: Option<&str>) -> bool {
582 let has = |wanted: &String| labels.iter().any(|l| l.eq_ignore_ascii_case(wanted));
583 let group_ok = match (&self.group, group) {
584 (None, _) => true,
585 (Some(wanted), Some(group)) => wanted.eq_ignore_ascii_case(group),
586 (Some(_), None) => false,
587 };
588 group_ok && self.labels.iter().all(has)
589 }
590
591 /// For people: `self-hosted, linux, gpu` (in group `build`).
592 pub fn describe(&self) -> String {
593 let labels = self.labels.join(", ");
594 match &self.group {
595 Some(group) if labels.is_empty() => format!("in group {group}"),
596 Some(group) => format!("{labels} in group {group}"),
597 None => labels,
598 }
599 }
600}
601
602/// What a job waiting for a runner says.
603pub fn waiting_reason(wanted: &Wanted) -> String {
604 format!("Waiting for a self-hosted runner with labels {}.", wanted.describe())
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 fn strings(items: &[&str]) -> Vec<String> {
612 items.iter().map(|s| s.to_string()).collect()
613 }
614
615 #[test]
616 fn a_runner_always_has_self_hosted_its_os_and_its_arch() {
617 let labels = runner_labels(&strings(&["GPU", "gpu", " cuda-12 "]), "linux", "x64").unwrap();
618 assert_eq!(labels, strings(&["self-hosted", "linux", "x64", "gpu", "cuda-12"]));
619 assert!(runner_labels(&strings(&["has space"]), "linux", "x64").is_err());
620 assert!(runner_labels(&strings(&[""]), "linux", "x64").is_err());
621 }
622
623 #[test]
624 fn os_and_arch_are_read_as_people_write_them() {
625 assert_eq!(os_label("Darwin"), Some("macos"));
626 assert_eq!(os_label("Windows"), Some("windows"));
627 assert_eq!(os_label("plan9"), None);
628 assert_eq!(arch_label("x86_64"), Some("x64"));
629 assert_eq!(arch_label("aarch64"), Some("arm64"));
630 assert_eq!(arch_label("mips"), None);
631 }
632
633 #[test]
634 fn a_job_names_self_hosted_or_a_group_to_need_one() {
635 assert!(Wanted::of(&strings(&["Self-Hosted", "linux"]), None).self_hosted);
636 assert!(Wanted::of(&strings(&[]), Some("build")).self_hosted);
637 assert!(!Wanted::of(&strings(&["ubuntu-latest"]), None).self_hosted);
638 }
639
640 #[test]
641 fn every_label_the_job_names_must_be_the_runners() {
642 let runner = strings(&["self-hosted", "linux", "x64", "gpu"]);
643 assert!(Wanted::of(&strings(&["self-hosted"]), None).matches(&runner, Some("default")));
644 assert!(Wanted::of(&strings(&["self-hosted", "LINUX", "gpu"]), None).matches(&runner, None));
645 assert!(!Wanted::of(&strings(&["self-hosted", "linux", "arm64"]), None).matches(&runner, None));
646 assert!(!Wanted::of(&strings(&["self-hosted", "windows"]), None).matches(&runner, None));
647 }
648
649 #[test]
650 fn a_group_is_matched_by_name() {
651 let runner = strings(&["self-hosted", "linux", "x64"]);
652 let wanted = Wanted::of(&strings(&["linux"]), Some("Build"));
653 assert!(wanted.matches(&runner, Some("build")));
654 assert!(!wanted.matches(&runner, Some("default")));
655 assert!(!wanted.matches(&runner, None));
656 assert_eq!(Wanted::from_stored(&wanted.stored()), wanted.clone().tap_lower());
657 }
658
659 impl Wanted {
660 fn tap_lower(mut self) -> Wanted {
661 self.group = self.group.map(|g| g.to_ascii_lowercase());
662 self
663 }
664 }
665
666 #[test]
667 fn waiting_says_for_what() {
668 let wanted = Wanted::of(&strings(&["self-hosted", "linux", "gpu"]), None);
669 assert_eq!(waiting_reason(&wanted), "Waiting for a self-hosted runner with labels self-hosted, linux, gpu.");
670 }
671
672 #[test]
673 fn the_runners_wire_is_snake_case() {
674 let poll: PollArgs = serde_json::from_value(serde_json::json!({
675 "runner": "rnr_1", "credential": "g1tr_x", "version": "0.1.0", "running": ["job_1"], "wait_ms": 1000
676 }))
677 .unwrap();
678 assert_eq!(poll.auth.runner, "rnr_1");
679 assert_eq!(poll.wait_ms, 1000);
680 let assignment = serde_json::to_value(Assignment {
681 kind: "workflow".into(),
682 id: "job_1".into(),
683 name: "build".into(),
684 repo: "acme/web".into(),
685 timeout_minutes: 60,
686 image: None,
687 token: Some("t".into()),
688 env: None,
689 })
690 .unwrap();
691 assert_eq!(assignment["timeout_minutes"], 60);
692 }
693}