flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/contracts/src/runners.rs

630 lines23,111 bytesCodeBlame
1//! 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>,
77 /// The workflow run the job is in, for a link.
78 pub run_id: Option<String>,
79 pub started_at: Option<String>,
80}
81
82#[derive(Clone, Debug, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct Runner {
85 pub id: String,
86 pub name: String,
87 pub workspace: String,
88 /// `owner/name` for a repository's own runner; null for the workspace's.
89 pub repo: Option<String>,
90 /// Its group's name; null for a repository's runner.
91 pub group: Option<String>,
92 /// Every label, `self-hosted`, its OS and architecture among them.
93 pub labels: Vec<String>,
94 /// `linux`, `macos` or `windows`.
95 pub os: String,
96 /// `x64` or `arm64`.
97 pub arch: String,
98 /// The `g1t-runner` version it last reported.
99 pub version: String,
100 /// Runs one job, then removes itself.
101 pub ephemeral: bool,
102 /// `online`, `busy` or `offline`.
103 pub status: String,
104 pub work: Option<RunnerWork>,
105 pub last_seen_at: Option<String>,
106 pub created_at: String,
107 pub created_by: Option<String>,
108}
109
110/// Which of a workspace's repositories may use its runners.
111#[derive(Clone, Debug, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct RunnerGroup {
114 pub id: String,
115 pub name: String,
116 /// The group runners join when none is named: every repository.
117 pub default: bool,
118 /// Repository names (without the workspace) that may use it; empty is
119 /// every repository in the workspace.
120 pub repositories: Vec<String>,
121 pub runners: u32,
122 pub updated_at: String,
123}
124
125/// A registration token, shown once.
126#[derive(Clone, Debug, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct RegistrationToken {
129 pub token: String,
130 pub expires_at: String,
131 pub workspace: String,
132 pub repo: Option<String>,
133 pub group: Option<String>,
134 /// What to pass as `--url`.
135 pub url: String,
136}
137
138/// Where a workspace's (or a repository's) agents work, and whether pull
139/// requests from forks may use its runners.
140#[derive(Clone, Debug, Default, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct RunnerSettings {
143 /// Agent runs, checks, reviews and the merge queue run on self-hosted
144 /// runners with `agent_labels`, instead of g1t's sandboxes.
145 pub agents_on_self_hosted: bool,
146 /// The labels a runner needs to take agent work; `self-hosted` is
147 /// always one.
148 pub agent_labels: Vec<String>,
149 /// Jobs of pull requests from forks may run on self-hosted runners.
150 /// Off by default: anyone who can open a pull request could run code
151 /// on the machine.
152 pub fork_pull_requests: bool,
153 /// For a repository: these are its workspace's, not its own.
154 pub inherited: bool,
155}
156
157// --- Management ------------------------------------------------------------
158
159/// A workspace's runners (`workspace`), or a repository's own (`repo`).
160#[derive(Clone, Debug, Default, Serialize, Deserialize)]
161pub struct RunnersOwner {
162 #[serde(default)]
163 pub repo: Option<RepoPath>,
164 #[serde(default)]
165 pub workspace: Option<String>,
166}
167
168/// `runners`: a workspace's runners (members), or a repository's own
169/// (its admins). Returns `Outcome<Vec<Runner>>`.
170#[derive(Debug, Serialize, Deserialize)]
171pub struct ListRunnersArgs {
172 pub actor: User,
173 #[serde(flatten)]
174 pub owner: RunnersOwner,
175}
176
177/// `create_registration_token`: owners of the workspace, or admins of the
178/// repository. Returns `Outcome<RegistrationToken>`.
179#[derive(Debug, Serialize, Deserialize)]
180pub struct CreateRegistrationTokenArgs {
181 pub actor: User,
182 #[serde(flatten)]
183 pub owner: RunnersOwner,
184 /// The group its runners join, by name or id; the default group if
185 /// left out. Not for a repository's runners.
186 #[serde(default)]
187 pub group: Option<String>,
188}
189
190/// `remove_runner`: as `create_registration_token`. A job it is running is
191/// cancelled. Returns `Outcome<bool>`.
192#[derive(Debug, Serialize, Deserialize)]
193pub struct RemoveRunnerArgs {
194 pub actor: User,
195 #[serde(flatten)]
196 pub owner: RunnersOwner,
197 pub id: String,
198}
199
200/// `runner_groups`: members. Returns `Outcome<Vec<RunnerGroup>>`.
201#[derive(Debug, Serialize, Deserialize)]
202pub struct RunnerGroupsArgs {
203 pub actor: User,
204 pub workspace: String,
205}
206
207/// `set_runner_group`: create one (no `id`) or change one. Owners.
208/// Returns `Outcome<RunnerGroup>`.
209#[derive(Debug, Serialize, Deserialize)]
210pub struct SetRunnerGroupArgs {
211 pub actor: User,
212 pub workspace: String,
213 #[serde(default)]
214 pub id: Option<String>,
215 #[serde(default)]
216 pub name: Option<String>,
217 /// Repository names; empty for every repository.
218 #[serde(default)]
219 pub repositories: Option<Vec<String>>,
220}
221
222/// `delete_runner_group`: owners. Its runners move to the default group,
223/// which cannot be deleted. Returns `Outcome<bool>`.
224#[derive(Debug, Serialize, Deserialize)]
225pub struct DeleteRunnerGroupArgs {
226 pub actor: User,
227 pub workspace: String,
228 pub id: String,
229}
230
231/// `runner_settings`: members of the workspace, or admins of the
232/// repository. Returns `Outcome<RunnerSettings>`.
233#[derive(Debug, Serialize, Deserialize)]
234pub struct RunnerSettingsArgs {
235 pub actor: User,
236 #[serde(flatten)]
237 pub owner: RunnersOwner,
238}
239
240/// `set_runner_settings`: owners, or admins of the repository. Left out is
241/// unchanged; `inherit` drops a repository's own settings. Returns
242/// `Outcome<RunnerSettings>`.
243#[derive(Debug, Serialize, Deserialize)]
244pub struct SetRunnerSettingsArgs {
245 pub actor: User,
246 #[serde(flatten)]
247 pub owner: RunnersOwner,
248 #[serde(default)]
249 pub agents_on_self_hosted: Option<bool>,
250 #[serde(default)]
251 pub agent_labels: Option<Vec<String>>,
252 #[serde(default)]
253 pub fork_pull_requests: Option<bool>,
254 #[serde(default)]
255 pub inherit: bool,
256}
257
258/// `stuck_jobs`: Mission control's Needs you. Returns `Vec<StuckJob>`.
259#[derive(Debug, Serialize, Deserialize)]
260pub struct StuckJobsArgs {
261 pub viewer: crate::Viewer,
262}
263
264/// A job that has waited ten minutes or more for a self-hosted runner,
265/// with none that matches online.
266#[derive(Clone, Debug, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct StuckJob {
269 pub id: String,
270 pub name: String,
271 pub run_id: String,
272 /// `owner/name`.
273 pub repo: String,
274 /// What it asks for, for people: `self-hosted, linux, gpu`.
275 pub labels: String,
276 pub queued_at: String,
277}
278
279// --- The runner's side (snake_case on the wire, as the API passes it) ------
280
281/// `runner_register`: a machine registering with a registration token.
282/// Returns `Outcome<Registered>`.
283#[derive(Clone, Debug, Default, Serialize, Deserialize)]
284pub struct RegisterArgs {
285 pub token: String,
286 pub name: String,
287 #[serde(default)]
288 pub labels: Vec<String>,
289 pub os: String,
290 pub arch: String,
291 #[serde(default)]
292 pub version: String,
293 #[serde(default)]
294 pub ephemeral: bool,
295 /// A group by name, for a workspace's token; the token's group, else
296 /// the default, if left out.
297 #[serde(default)]
298 pub group: Option<String>,
299 /// Replace a runner of the same name instead of refusing.
300 #[serde(default)]
301 pub replace: bool,
302}
303
304#[derive(Clone, Debug, Serialize, Deserialize)]
305pub struct Registered {
306 pub runner: Runner,
307 /// Shown once; the runner keeps it in its configuration.
308 pub credential: String,
309}
310
311/// Every call a registered runner makes carries its id and credential.
312#[derive(Clone, Debug, Default, Serialize, Deserialize)]
313pub struct RunnerAuth {
314 pub runner: String,
315 pub credential: String,
316}
317
318/// `runner_poll`: waits up to `wait_ms` for work. Doubles as the runner's
319/// heartbeat. Returns `Outcome<Poll>`.
320#[derive(Clone, Debug, Default, Serialize, Deserialize)]
321pub struct PollArgs {
322 #[serde(flatten)]
323 pub auth: RunnerAuth,
324 #[serde(default)]
325 pub version: String,
326 /// What it is running now; it is given nothing more while busy.
327 #[serde(default)]
328 pub running: Vec<String>,
329 #[serde(default)]
330 pub wait_ms: u64,
331}
332
333/// Work for a runner.
334#[derive(Clone, Debug, Serialize, Deserialize)]
335pub struct Assignment {
336 /// `workflow` or `agent`.
337 pub kind: String,
338 pub id: String,
339 pub name: String,
340 pub repo: String,
341 pub timeout_minutes: u32,
342 /// The image to run it in: the job's `container:`, when it names one.
343 pub image: Option<String>,
344 /// A workflow job's own token, for `ACTIONS_JOB`/`ACTIONS_TOKEN`.
345 pub token: Option<String>,
346 /// An agent task's environment, as g1t's sandbox would have been
347 /// started with: `MODE`, the run's short-lived credentials, the model
348 /// proxy's address.
349 pub env: Option<Map<String, Value>>,
350}
351
352#[derive(Clone, Debug, Default, Serialize, Deserialize)]
353pub struct Poll {
354 pub assignment: Option<Assignment>,
355 /// Work it holds that it should stop: cancelled, timed out, removed.
356 #[serde(default)]
357 pub cancel: Vec<String>,
358 /// A new credential: it replaces the old, which stops working once this
359 /// one is used.
360 pub credential: Option<String>,
361 /// The runner was removed: stop and forget the credential.
362 #[serde(default)]
363 pub removed: bool,
364}
365
366/// `runner_finished`: a runner telling what became of work it was given.
367/// For a workflow job the harness has normally reported already; this
368/// covers one that crashed or was killed. Returns `Outcome<bool>` (whether
369/// it changed anything).
370#[derive(Clone, Debug, Default, Serialize, Deserialize)]
371pub struct FinishedArgs {
372 #[serde(flatten)]
373 pub auth: RunnerAuth,
374 pub id: String,
375 pub exit_code: i32,
376 #[serde(default)]
377 pub reason: Option<String>,
378}
379
380/// `runner_remove_self`: `g1t-runner remove`. Returns `Outcome<bool>`.
381#[derive(Clone, Debug, Default, Serialize, Deserialize)]
382pub struct RemoveSelfArgs {
383 #[serde(flatten)]
384 pub auth: RunnerAuth,
385}
386
387// --- Between services (camelCase, as every service speaks) ----------------
388
389/// `runner_route`: whether a kind of g1t's own work in a repository runs on
390/// self-hosted runners, and on which labels. Asked by the runner service
391/// before it starts a sandbox. Returns `Option<Vec<String>>`.
392#[derive(Clone, Debug, Serialize, Deserialize)]
393#[serde(rename_all = "camelCase")]
394pub struct RouteArgs {
395 pub workspace: String,
396 pub repo: RepoPath,
397}
398
399/// `enqueue_task`: agent work for a self-hosted runner, from the runner
400/// service's sandbox (`sandbox` is its Durable Object's id), which is told
401/// with `task_ended` how it went. Returns `Outcome<String>` (the task's id).
402#[derive(Clone, Debug, Serialize, Deserialize)]
403#[serde(rename_all = "camelCase")]
404pub struct EnqueueTaskArgs {
405 pub sandbox: String,
406 pub workspace: String,
407 pub repo: RepoPath,
408 /// The run kind: `agent`, `checks`, `review`, `queue`…
409 pub kind: String,
410 pub title: String,
411 pub labels: Vec<String>,
412 pub env: Map<String, Value>,
413 pub timeout_minutes: u32,
414}
415
416/// `cancel_task`: the sandbox's work stopped from g1t's side (a person, a
417/// time cap). Returns `Outcome<bool>`.
418#[derive(Clone, Debug, Serialize, Deserialize)]
419#[serde(rename_all = "camelCase")]
420pub struct CancelTaskArgs {
421 pub sandbox: String,
422 #[serde(default)]
423 pub reason: Option<String>,
424}
425
426// --- Labels ----------------------------------------------------------------
427
428/// A label as it is kept: trimmed and lowercase. `None` when it is not
429/// one: empty, too long, or with characters other than letters, digits,
430/// `-`, `_`, `.`, `:` and `/`.
431pub fn clean_label(label: &str) -> Option<String> {
432 let label = label.trim().to_ascii_lowercase();
433 let ok = !label.is_empty()
434 && label.len() <= MAX_LABEL_LEN
435 && label.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '/'));
436 ok.then_some(label)
437}
438
439/// A runner's labels: what it was given, cleaned, with `self-hosted`, its
440/// OS and its architecture, each once, in that order first.
441pub fn runner_labels(given: &[String], os: &str, arch: &str) -> Result<Vec<String>, String> {
442 let mut labels: Vec<String> = Vec::new();
443 for label in [SELF_HOSTED, os, arch].iter().map(|s| s.to_string()).chain(given.iter().cloned()) {
444 let Some(clean) = clean_label(&label) else {
445 return Err(format!("`{label}` is not a label: use letters, digits, `-`, `_`, `.`, `:` and `/`, up to {MAX_LABEL_LEN} characters."));
446 };
447 if !labels.contains(&clean) {
448 labels.push(clean);
449 }
450 }
451 if labels.len() > MAX_LABELS {
452 return Err(format!("A runner has at most {MAX_LABELS} labels."));
453 }
454 Ok(labels)
455}
456
457/// The OS a runner reports, as its label: `linux`, `macos` or `windows`.
458pub fn os_label(os: &str) -> Option<&'static str> {
459 match os.trim().to_ascii_lowercase().as_str() {
460 "linux" => Some("linux"),
461 "macos" | "darwin" | "osx" | "mac" => Some("macos"),
462 "windows" | "win" => Some("windows"),
463 _ => None,
464 }
465}
466
467/// The architecture a runner reports, as its label: `x64` or `arm64`.
468pub fn arch_label(arch: &str) -> Option<&'static str> {
469 match arch.trim().to_ascii_lowercase().as_str() {
470 "x64" | "x86_64" | "amd64" => Some("x64"),
471 "arm64" | "aarch64" => Some("arm64"),
472 _ => None,
473 }
474}
475
476/// What a job's `runs-on` asks for, read for self-hosted runners: its
477/// labels, cleaned, and the group it names. `self_hosted` when it names
478/// `self-hosted` or a group, which only self-hosted runners can satisfy.
479#[derive(Clone, Debug, Default, PartialEq, Eq)]
480pub struct Wanted {
481 pub labels: Vec<String>,
482 pub group: Option<String>,
483 pub self_hosted: bool,
484}
485
486impl Wanted {
487 pub fn of(labels: &[String], group: Option<&str>) -> Wanted {
488 let mut out: Vec<String> = Vec::new();
489 for label in labels {
490 let clean = label.trim().to_ascii_lowercase();
491 if !clean.is_empty() && !out.contains(&clean) {
492 out.push(clean);
493 }
494 }
495 let group = group.map(|g| g.trim().to_owned()).filter(|g| !g.is_empty());
496 let self_hosted = group.is_some() || out.iter().any(|l| l == SELF_HOSTED);
497 Wanted { labels: out, group, self_hosted }
498 }
499
500 /// The job's labels as stored on it: its labels, then `group:<name>`.
501 pub fn stored(&self) -> Vec<String> {
502 let mut out = self.labels.clone();
503 if let Some(group) = &self.group {
504 out.push(format!("group:{}", group.to_ascii_lowercase()));
505 }
506 out
507 }
508
509 /// Back from what [`Wanted::stored`] kept.
510 pub fn from_stored(stored: &[String]) -> Wanted {
511 let group = stored.iter().find_map(|l| l.strip_prefix("group:").map(str::to_owned));
512 let labels: Vec<String> = stored.iter().filter(|l| !l.starts_with("group:")).cloned().collect();
513 Wanted::of(&labels, group.as_deref())
514 }
515
516 /// Whether a runner with `labels`, in the group called `group`, can
517 /// take it: every label it asks for, and its group if it names one.
518 pub fn matches(&self, labels: &[String], group: Option<&str>) -> bool {
519 let has = |wanted: &String| labels.iter().any(|l| l.eq_ignore_ascii_case(wanted));
520 let group_ok = match (&self.group, group) {
521 (None, _) => true,
522 (Some(wanted), Some(group)) => wanted.eq_ignore_ascii_case(group),
523 (Some(_), None) => false,
524 };
525 group_ok && self.labels.iter().all(has)
526 }
527
528 /// For people: `self-hosted, linux, gpu` (in group `build`).
529 pub fn describe(&self) -> String {
530 let labels = self.labels.join(", ");
531 match &self.group {
532 Some(group) if labels.is_empty() => format!("in group {group}"),
533 Some(group) => format!("{labels} in group {group}"),
534 None => labels,
535 }
536 }
537}
538
539/// What a job waiting for a runner says.
540pub fn waiting_reason(wanted: &Wanted) -> String {
541 format!("Waiting for a self-hosted runner with labels {}.", wanted.describe())
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 fn strings(items: &[&str]) -> Vec<String> {
549 items.iter().map(|s| s.to_string()).collect()
550 }
551
552 #[test]
553 fn a_runner_always_has_self_hosted_its_os_and_its_arch() {
554 let labels = runner_labels(&strings(&["GPU", "gpu", " cuda-12 "]), "linux", "x64").unwrap();
555 assert_eq!(labels, strings(&["self-hosted", "linux", "x64", "gpu", "cuda-12"]));
556 assert!(runner_labels(&strings(&["has space"]), "linux", "x64").is_err());
557 assert!(runner_labels(&strings(&[""]), "linux", "x64").is_err());
558 }
559
560 #[test]
561 fn os_and_arch_are_read_as_people_write_them() {
562 assert_eq!(os_label("Darwin"), Some("macos"));
563 assert_eq!(os_label("Windows"), Some("windows"));
564 assert_eq!(os_label("plan9"), None);
565 assert_eq!(arch_label("x86_64"), Some("x64"));
566 assert_eq!(arch_label("aarch64"), Some("arm64"));
567 assert_eq!(arch_label("mips"), None);
568 }
569
570 #[test]
571 fn a_job_names_self_hosted_or_a_group_to_need_one() {
572 assert!(Wanted::of(&strings(&["Self-Hosted", "linux"]), None).self_hosted);
573 assert!(Wanted::of(&strings(&[]), Some("build")).self_hosted);
574 assert!(!Wanted::of(&strings(&["ubuntu-latest"]), None).self_hosted);
575 }
576
577 #[test]
578 fn every_label_the_job_names_must_be_the_runners() {
579 let runner = strings(&["self-hosted", "linux", "x64", "gpu"]);
580 assert!(Wanted::of(&strings(&["self-hosted"]), None).matches(&runner, Some("default")));
581 assert!(Wanted::of(&strings(&["self-hosted", "LINUX", "gpu"]), None).matches(&runner, None));
582 assert!(!Wanted::of(&strings(&["self-hosted", "linux", "arm64"]), None).matches(&runner, None));
583 assert!(!Wanted::of(&strings(&["self-hosted", "windows"]), None).matches(&runner, None));
584 }
585
586 #[test]
587 fn a_group_is_matched_by_name() {
588 let runner = strings(&["self-hosted", "linux", "x64"]);
589 let wanted = Wanted::of(&strings(&["linux"]), Some("Build"));
590 assert!(wanted.matches(&runner, Some("build")));
591 assert!(!wanted.matches(&runner, Some("default")));
592 assert!(!wanted.matches(&runner, None));
593 assert_eq!(Wanted::from_stored(&wanted.stored()), wanted.clone().tap_lower());
594 }
595
596 impl Wanted {
597 fn tap_lower(mut self) -> Wanted {
598 self.group = self.group.map(|g| g.to_ascii_lowercase());
599 self
600 }
601 }
602
603 #[test]
604 fn waiting_says_for_what() {
605 let wanted = Wanted::of(&strings(&["self-hosted", "linux", "gpu"]), None);
606 assert_eq!(waiting_reason(&wanted), "Waiting for a self-hosted runner with labels self-hosted, linux, gpu.");
607 }
608
609 #[test]
610 fn the_runners_wire_is_snake_case() {
611 let poll: PollArgs = serde_json::from_value(serde_json::json!({
612 "runner": "rnr_1", "credential": "g1tr_x", "version": "0.1.0", "running": ["job_1"], "wait_ms": 1000
613 }))
614 .unwrap();
615 assert_eq!(poll.auth.runner, "rnr_1");
616 assert_eq!(poll.wait_ms, 1000);
617 let assignment = serde_json::to_value(Assignment {
618 kind: "workflow".into(),
619 id: "job_1".into(),
620 name: "build".into(),
621 repo: "acme/web".into(),
622 timeout_minutes: 60,
623 image: None,
624 token: Some("t".into()),
625 env: None,
626 })
627 .unwrap();
628 assert_eq!(assignment["timeout_minutes"], 60);
629 }
630}