Skip to content

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.sh
952 linesCodeBlameRaw
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 /// 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.
80 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 /// How it keeps work from its machine, as it last said
111 /// ([`ISOLATIONS`]); null for a runner from before runners said.
112 #[serde(default)]
113 pub isolation: Option<String>,
114}
115
116/// Which of a workspace's repositories may use its runners.
117#[derive(Clone, Debug, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct RunnerGroup {
120 pub id: String,
121 pub name: String,
122 /// The group runners join when none is named: every repository.
123 pub default: bool,
124 /// Repository names (without the workspace) that may use it; empty is
125 /// every repository in the workspace.
126 pub repositories: Vec<String>,
127 pub runners: u32,
128 pub updated_at: String,
129}
130
131/// A registration token, shown once.
132#[derive(Clone, Debug, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct RegistrationToken {
135 pub token: String,
136 pub expires_at: String,
137 pub workspace: String,
138 pub repo: Option<String>,
139 pub group: Option<String>,
140 /// What to pass as `--url`.
141 pub url: String,
142}
143
144/// Where a workspace's (or a repository's) agents work, and whether pull
145/// requests from forks may use its runners.
146#[derive(Clone, Debug, Default, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct RunnerSettings {
149 /// g1t's own work (checks, merge checks and the merge queue) runs on
150 /// self-hosted runners with `agent_labels`, instead of g1t's sandboxes.
151 /// The names are kept from when that work included agent runs.
152 pub agents_on_self_hosted: bool,
153 /// The labels a runner needs to take g1t's own work; `self-hosted` is
154 /// always one.
155 pub agent_labels: Vec<String>,
156 /// Jobs of pull requests from forks may run on self-hosted runners.
157 /// Off by default: anyone who can open a pull request could run code
158 /// on the machine.
159 pub fork_pull_requests: bool,
160 /// For a repository: these are its workspace's, not its own.
161 pub inherited: bool,
162}
163
164// --- Management ------------------------------------------------------------
165
166/// A workspace's runners (`workspace`), or a repository's own (`repo`).
167#[derive(Clone, Debug, Default, Serialize, Deserialize)]
168pub struct RunnersOwner {
169 #[serde(default)]
170 pub repo: Option<RepoPath>,
171 #[serde(default)]
172 pub workspace: Option<String>,
173}
174
175/// `runners`: a workspace's runners (members), or a repository's own
176/// (its admins). Returns `Outcome<Vec<Runner>>`.
177#[derive(Debug, Serialize, Deserialize)]
178pub struct ListRunnersArgs {
179 pub actor: User,
180 #[serde(flatten)]
181 pub owner: RunnersOwner,
182}
183
184/// `create_registration_token`: owners of the workspace, or admins of the
185/// repository. Returns `Outcome<RegistrationToken>`.
186#[derive(Debug, Serialize, Deserialize)]
187pub struct CreateRegistrationTokenArgs {
188 pub actor: User,
189 #[serde(flatten)]
190 pub owner: RunnersOwner,
191 /// The group its runners join, by name or id; the default group if
192 /// left out. Not for a repository's runners.
193 #[serde(default)]
194 pub group: Option<String>,
195}
196
197/// `remove_runner`: as `create_registration_token`. A job it is running is
198/// cancelled. Returns `Outcome<bool>`.
199#[derive(Debug, Serialize, Deserialize)]
200pub struct RemoveRunnerArgs {
201 pub actor: User,
202 #[serde(flatten)]
203 pub owner: RunnersOwner,
204 pub id: String,
205}
206
207/// `runner_groups`: members. Returns `Outcome<Vec<RunnerGroup>>`.
208#[derive(Debug, Serialize, Deserialize)]
209pub struct RunnerGroupsArgs {
210 pub actor: User,
211 pub workspace: String,
212}
213
214/// `set_runner_group`: create one (no `id`) or change one. Owners.
215/// Returns `Outcome<RunnerGroup>`.
216#[derive(Debug, Serialize, Deserialize)]
217pub struct SetRunnerGroupArgs {
218 pub actor: User,
219 pub workspace: String,
220 #[serde(default)]
221 pub id: Option<String>,
222 #[serde(default)]
223 pub name: Option<String>,
224 /// Repository names; empty for every repository.
225 #[serde(default)]
226 pub repositories: Option<Vec<String>>,
227}
228
229/// `delete_runner_group`: owners. Its runners move to the default group,
230/// which cannot be deleted. Returns `Outcome<bool>`.
231#[derive(Debug, Serialize, Deserialize)]
232pub struct DeleteRunnerGroupArgs {
233 pub actor: User,
234 pub workspace: String,
235 pub id: String,
236}
237
238// --- The desktop app's runners ----------------------------------------------
239
240/// The desktop app keeps a runner on a person's computer while it is open
241/// (apps/desktop/src-tauri/src/runner). What it says of that runner, as it
242/// says it in its tray and settings (its diagnosis.rs, the one catalogue
243/// of those words), is kept here per computer, so the workspace's Runners
244/// page lists every desktop with the same state, why, and the fix. A
245/// desktop not heard from for `DESKTOP_OPEN_WITHIN_MS` has g1t closed, and
246/// shows `closed`, which the app sent too.
247pub const DESKTOP_OPEN_WITHIN_MS: u64 = 10 * 60 * 1000;
248/// The most desktops a workspace keeps reports for.
249pub const MAX_DESKTOPS: u32 = 200;
250
251/// A diagnosis, as the app made it.
252#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
253pub struct DesktopDiagnosis {
254 /// `idle`, `busy`, `no_docker`, `no_wsl`, `sandbox_failed`, `not_allowed`, …
255 pub code: String,
256 /// `ok`, `working`, `off`, `warning` or `problem`.
257 pub tone: String,
258 pub title: String,
259 #[serde(default)]
260 pub cause: Option<String>,
261 #[serde(default)]
262 pub fix: Option<String>,
263 /// A sandboxed desktop's kinds of job, each with how it is: Linux jobs
264 /// can run while Windows jobs cannot. Empty for the other backends.
265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
266 pub capabilities: Vec<DesktopCapability>,
267 /// The kinds of job say all of this, each for itself: a page showing
268 /// them shows no line of the whole runner's as well.
269 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
270 pub in_capabilities: bool,
271 /// How its runner's updates stand. From newer apps; older ones leave it out.
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub update: Option<DesktopUpdate>,
274}
275
276/// How a desktop's runner updates stand, as its app says it. The app
277/// looks every hour, and installs a newer runner kind of job by kind of
278/// job, as each is idle.
279#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
280pub struct DesktopUpdate {
281 /// When the app last heard from the runner's releases, RFC 3339.
282 #[serde(default)]
283 pub checked_at: Option<String>,
284 /// A newer version, downloaded and checked, waiting to be installed.
285 #[serde(default)]
286 pub ready: Option<String>,
287 /// What becomes of it, for a desktop with no kinds of job of its own:
288 /// `0.3.2 ready, installs after this job`.
289 #[serde(default)]
290 pub note: Option<String>,
291}
292
293/// One kind of job a sandboxed desktop runs, as its app says it. Each
294/// starts and stops on its own: `enabled` is false for one the person
295/// stopped, which is not failing. The fields after `fix` come from newer
296/// apps; older ones leave them out.
297#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
298pub struct DesktopCapability {
299 /// `linux`, `windows` or `macos`.
300 pub id: String,
301 /// `Linux jobs`.
302 pub label: String,
303 pub tone: String,
304 /// `Waiting for jobs`, `Not available`.
305 pub title: String,
306 #[serde(default)]
307 pub cause: Option<String>,
308 #[serde(default)]
309 pub fix: Option<String>,
310 /// `busy`, `idle`, `starting`, `downloading`, `updating`, `waiting`,
311 /// `stopped`, `unavailable` or `problem`.
312 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub state: Option<String>,
314 /// What is wrong, in a few words: `WSL isn't installed`.
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub headline: Option<String>,
317 /// What it is, in a line.
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub about: Option<String>,
320 /// On in the person's settings; false when they stopped it.
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub enabled: Option<bool>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub job: Option<DesktopJob>,
325 /// Its runner's name on g1t: `build-box-linux`.
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub runner: Option<String>,
328 /// `nested-container` or `vm`.
329 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub isolation: Option<String>,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub version: Option<String>,
333 /// A newer version waiting for it: `0.3.2 ready, installs after this job`.
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub update: Option<String>,
336}
337
338/// What a desktop's kind of job is running.
339#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
340pub struct DesktopJob {
341 /// `workflow` or `agent`.
342 pub kind: String,
343 pub name: String,
344 /// `acme/web`.
345 pub repo: String,
346}
347
348/// The most kinds of job a desktop reports.
349pub const MAX_DESKTOP_CAPABILITIES: usize = 4;
350
351/// One person's computer, as its desktop app last said.
352#[derive(Clone, Debug, Serialize, Deserialize)]
353#[serde(rename_all = "camelCase")]
354pub struct DesktopRunner {
355 pub workspace: String,
356 /// The computer's name, which its runner has too.
357 pub machine: String,
358 /// Whose computer: their username.
359 pub person: String,
360 /// `sandboxed`, `docker` or `host`.
361 pub backend: String,
362 pub version: Option<String>,
363 pub diagnosis: DesktopDiagnosis,
364 /// What to show while g1t is closed on it.
365 pub closed: DesktopDiagnosis,
366 /// Heard from within `DESKTOP_OPEN_WITHIN_MS`: g1t is open there.
367 pub open: bool,
368 pub reported_at: String,
369}
370
371/// `report_desktop`: the person's own desktop app, through the site, for a
372/// workspace they own. Returns `Outcome<bool>`.
373#[derive(Debug, Serialize, Deserialize)]
374pub struct ReportDesktopArgs {
375 pub actor: User,
376 pub workspace: String,
377 pub machine: String,
378 pub backend: String,
379 #[serde(default)]
380 pub version: Option<String>,
381 pub diagnosis: DesktopDiagnosis,
382 pub closed: DesktopDiagnosis,
383}
384
385/// `desktop_runners`: owners of the workspace. Returns `Outcome<Vec<DesktopRunner>>`.
386#[derive(Debug, Serialize, Deserialize)]
387pub struct DesktopRunnersArgs {
388 pub actor: User,
389 pub workspace: String,
390}
391
392/// `runner_settings`: members of the workspace, or admins of the
393/// repository. Returns `Outcome<RunnerSettings>`.
394#[derive(Debug, Serialize, Deserialize)]
395pub struct RunnerSettingsArgs {
396 pub actor: User,
397 #[serde(flatten)]
398 pub owner: RunnersOwner,
399}
400
401/// `set_runner_settings`: owners, or admins of the repository. Left out is
402/// unchanged; `inherit` drops a repository's own settings. Returns
403/// `Outcome<RunnerSettings>`.
404#[derive(Debug, Serialize, Deserialize)]
405pub struct SetRunnerSettingsArgs {
406 pub actor: User,
407 #[serde(flatten)]
408 pub owner: RunnersOwner,
409 #[serde(default)]
410 pub agents_on_self_hosted: Option<bool>,
411 #[serde(default)]
412 pub agent_labels: Option<Vec<String>>,
413 #[serde(default)]
414 pub fork_pull_requests: Option<bool>,
415 #[serde(default)]
416 pub inherit: bool,
417}
418
419/// `stuck_jobs`: Mission control's Needs you. Returns `Vec<StuckJob>`.
420#[derive(Debug, Serialize, Deserialize)]
421pub struct StuckJobsArgs {
422 pub viewer: crate::Viewer,
423}
424
425/// `runner_activity`: what runs for a workspace now, on g1t's cloud and
426/// handed to its own runners, for the Runners page. Owners, as the
427/// workspace's runners are. Snake case both ways. Returns
428/// `Outcome<RunnerActivity>`.
429#[derive(Debug, Serialize, Deserialize)]
430pub struct RunnerActivityArgs {
431 pub actor: User,
432 pub workspace: String,
433}
434
435/// A workflow job running in one of g1t's sandboxes.
436#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
437pub struct CloudJob {
438 pub id: String,
439 pub name: String,
440 /// The workflow run it is in.
441 pub run_id: String,
442 /// `owner/name`.
443 pub repo: String,
444 pub started_at: Option<String>,
445}
446
447/// Agent work handed to one of the workspace's own runners, waiting for
448/// one or taken.
449#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
450pub struct HandedOverTask {
451 pub id: String,
452 /// The agent run it is, when the sandbox said; older tasks have none.
453 pub run_id: Option<String>,
454 /// `agent`, `checks`, `review`, `queue`…
455 pub kind: String,
456 pub title: String,
457 /// `owner/name`.
458 pub repo: String,
459 /// `queued` or `in_progress`.
460 pub status: String,
461 pub runner_name: Option<String>,
462 pub created_at: String,
463 pub started_at: Option<String>,
464}
465
466#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
467pub struct RunnerActivity {
468 /// Workflow jobs in g1t's sandboxes now, newest first, at most
469 /// `ACTIVITY_LIMIT`.
470 pub cloud_jobs: Vec<CloudJob>,
471 /// Workflow jobs queued for g1t's sandboxes.
472 pub cloud_jobs_queued: u32,
473 /// g1t's own work (checks, merge checks, the merge queue) on, or waiting
474 /// for, the workspace's own runners.
475 pub handed_over: Vec<HandedOverTask>,
476 /// Workflow jobs waiting for one of the workspace's own runners.
477 pub self_hosted_jobs_queued: u32,
478}
479
480/// The most jobs and tasks `runner_activity` lists of each.
481pub const ACTIVITY_LIMIT: u32 = 100;
482
483/// A job that has waited ten minutes or more for a self-hosted runner,
484/// with none that matches online.
485#[derive(Clone, Debug, Serialize, Deserialize)]
486#[serde(rename_all = "camelCase")]
487pub struct StuckJob {
488 pub id: String,
489 pub name: String,
490 pub run_id: String,
491 /// `owner/name`.
492 pub repo: String,
493 /// What it asks for, for people: `self-hosted, linux, gpu`.
494 pub labels: String,
495 pub queued_at: String,
496}
497
498// --- The runner's side (snake_case on the wire, as the API passes it) ------
499
500/// `runner_register`: a machine registering with a registration token.
501/// Returns `Outcome<Registered>`.
502#[derive(Clone, Debug, Default, Serialize, Deserialize)]
503pub struct RegisterArgs {
504 pub token: String,
505 pub name: String,
506 #[serde(default)]
507 pub labels: Vec<String>,
508 pub os: String,
509 pub arch: String,
510 #[serde(default)]
511 pub version: String,
512 #[serde(default)]
513 pub ephemeral: bool,
514 /// A group by name, for a workspace's token; the token's group, else
515 /// the default, if left out.
516 #[serde(default)]
517 pub group: Option<String>,
518 /// Replace a runner of the same name instead of refusing.
519 #[serde(default)]
520 pub replace: bool,
521 /// How the runner keeps work from its machine: `nested-container` (a
522 /// container per job, inside the runner's own sandboxed container),
523 /// `vm` (a throwaway VM per job, native to the machine's OS),
524 /// `container` (a container per job, on a Docker Engine it shares with
525 /// its machine) or `host` (directly on the machine). A `host` runner is
526 /// given no public repository's work ([`isolation_allows`]); older
527 /// runners leave it out.
528 #[serde(default)]
529 pub isolation: Option<String>,
530 /// Which computer it is: a fingerprint the runner derives from its
531 /// operating system's machine id, never the id itself. A runner of the
532 /// same name registered from the same computer is taken over without
533 /// `replace`, so a computer whose sandbox was made again registers
534 /// again by itself. Older runners, and those in a container that can't
535 /// tell, leave it out.
536 #[serde(default)]
537 pub machine: Option<String>,
538}
539
540/// The isolation levels a runner may say it has (`RegisterArgs::isolation`).
541pub const ISOLATIONS: &[&str] = &["nested-container", "vm", "container", "host"];
542
543#[derive(Clone, Debug, Serialize, Deserialize)]
544pub struct Registered {
545 pub runner: Runner,
546 /// Shown once; the runner keeps it in its configuration.
547 pub credential: String,
548}
549
550/// Every call a registered runner makes carries its id and credential.
551#[derive(Clone, Debug, Default, Serialize, Deserialize)]
552pub struct RunnerAuth {
553 pub runner: String,
554 pub credential: String,
555}
556
557/// `runner_poll`: waits up to `wait_ms` for work. Doubles as the runner's
558/// heartbeat. Returns `Outcome<Poll>`.
559#[derive(Clone, Debug, Default, Serialize, Deserialize)]
560pub struct PollArgs {
561 #[serde(flatten)]
562 pub auth: RunnerAuth,
563 #[serde(default)]
564 pub version: String,
565 /// What it is running now; it is given nothing more while busy.
566 #[serde(default)]
567 pub running: Vec<String>,
568 #[serde(default)]
569 pub wait_ms: u64,
570 /// How the runner keeps work from its machine now, as at registration
571 /// (`RegisterArgs::isolation`); kept up to date from each poll. Older
572 /// runners leave it out.
573 #[serde(default)]
574 pub isolation: Option<String>,
575}
576
577/// Work for a runner.
578#[derive(Clone, Debug, Serialize, Deserialize)]
579pub struct Assignment {
580 /// `workflow` or `agent`.
581 pub kind: String,
582 pub id: String,
583 pub name: String,
584 pub repo: String,
585 pub timeout_minutes: u32,
586 /// The image to run it in: the job's `container:`, when it names one.
587 pub image: Option<String>,
588 /// A workflow job's own token, for `ACTIONS_JOB`/`ACTIONS_TOKEN`.
589 pub token: Option<String>,
590 /// An agent task's environment, as g1t's sandbox would have been
591 /// started with: `MODE`, the run's short-lived credentials, the model
592 /// proxy's address.
593 pub env: Option<Map<String, Value>>,
594 /// The repository's visibility, `public` or `private`, when g1t knows
595 /// it: a runner that runs work directly on its machine refuses a
596 /// public repository's. Older g1t leaves it out.
597 #[serde(default)]
598 pub visibility: Option<String>,
599}
600
601#[derive(Clone, Debug, Default, Serialize, Deserialize)]
602pub struct Poll {
603 pub assignment: Option<Assignment>,
604 /// Work it holds that it should stop: cancelled, timed out, removed.
605 #[serde(default)]
606 pub cancel: Vec<String>,
607 /// A new credential: it replaces the old, which stops working once this
608 /// one is used.
609 pub credential: Option<String>,
610 /// The runner was removed: stop and forget the credential.
611 #[serde(default)]
612 pub removed: bool,
613}
614
615/// `runner_finished`: a runner telling what became of work it was given.
616/// For a workflow job the harness has normally reported already; this
617/// covers one that crashed or was killed. Returns `Outcome<bool>` (whether
618/// it changed anything).
619#[derive(Clone, Debug, Default, Serialize, Deserialize)]
620pub struct FinishedArgs {
621 #[serde(flatten)]
622 pub auth: RunnerAuth,
623 pub id: String,
624 pub exit_code: i32,
625 #[serde(default)]
626 pub reason: Option<String>,
627}
628
629/// `runner_remove_self`: `g1t-runner remove`. Returns `Outcome<bool>`.
630#[derive(Clone, Debug, Default, Serialize, Deserialize)]
631pub struct RemoveSelfArgs {
632 #[serde(flatten)]
633 pub auth: RunnerAuth,
634}
635
636// --- Between services (camelCase, as every service speaks) ----------------
637
638/// `runner_route`: whether a kind of g1t's own work in a repository runs on
639/// self-hosted runners, and on which labels. Asked by the runner service
640/// before it starts a sandbox. Returns `Option<Vec<String>>`.
641#[derive(Clone, Debug, Serialize, Deserialize)]
642#[serde(rename_all = "camelCase")]
643pub struct RouteArgs {
644 pub workspace: String,
645 pub repo: RepoPath,
646}
647
648/// `enqueue_task`: agent work for a self-hosted runner, from the runner
649/// service's sandbox (`sandbox` is its Durable Object's id), which is told
650/// with `task_ended` how it went. Returns `Outcome<String>` (the task's id).
651#[derive(Clone, Debug, Serialize, Deserialize)]
652#[serde(rename_all = "camelCase")]
653pub struct EnqueueTaskArgs {
654 pub sandbox: String,
655 pub workspace: String,
656 pub repo: RepoPath,
657 /// The run kind: `agent`, `checks`, `review`, `queue`…
658 pub kind: String,
659 pub title: String,
660 pub labels: Vec<String>,
661 pub env: Map<String, Value>,
662 pub timeout_minutes: u32,
663 /// The agent run the work belongs to, when the sandbox opened one: what
664 /// people's pages link a runner's agent work to.
665 #[serde(default, alias = "run_id")]
666 pub run_id: Option<String>,
667}
668
669/// `cancel_task`: the sandbox's work stopped from g1t's side (a person, a
670/// time cap). Returns `Outcome<bool>`.
671#[derive(Clone, Debug, Serialize, Deserialize)]
672#[serde(rename_all = "camelCase")]
673pub struct CancelTaskArgs {
674 pub sandbox: String,
675 #[serde(default)]
676 pub reason: Option<String>,
677}
678
679// --- Labels ----------------------------------------------------------------
680
681/// A label as it is kept: trimmed and lowercase. `None` when it is not
682/// one: empty, too long, or with characters other than letters, digits,
683/// `-`, `_`, `.`, `:` and `/`.
684pub fn clean_label(label: &str) -> Option<String> {
685 let label = label.trim().to_ascii_lowercase();
686 let ok = !label.is_empty()
687 && label.len() <= MAX_LABEL_LEN
688 && label.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '/'));
689 ok.then_some(label)
690}
691
692/// A runner's labels: what it was given, cleaned, with `self-hosted`, its
693/// OS and its architecture, each once, in that order first.
694pub fn runner_labels(given: &[String], os: &str, arch: &str) -> Result<Vec<String>, String> {
695 let mut labels: Vec<String> = Vec::new();
696 for label in [SELF_HOSTED, os, arch].iter().map(|s| s.to_string()).chain(given.iter().cloned()) {
697 let Some(clean) = clean_label(&label) else {
698 return Err(format!("`{label}` is not a label: use letters, digits, `-`, `_`, `.`, `:` and `/`, up to {MAX_LABEL_LEN} characters."));
699 };
700 if !labels.contains(&clean) {
701 labels.push(clean);
702 }
703 }
704 if labels.len() > MAX_LABELS {
705 return Err(format!("A runner has at most {MAX_LABELS} labels."));
706 }
707 Ok(labels)
708}
709
710/// The OS a runner reports, as its label: `linux`, `macos` or `windows`.
711pub fn os_label(os: &str) -> Option<&'static str> {
712 match os.trim().to_ascii_lowercase().as_str() {
713 "linux" => Some("linux"),
714 "macos" | "darwin" | "osx" | "mac" => Some("macos"),
715 "windows" | "win" => Some("windows"),
716 _ => None,
717 }
718}
719
720/// The architecture a runner reports, as its label: `x64` or `arm64`.
721pub fn arch_label(arch: &str) -> Option<&'static str> {
722 match arch.trim().to_ascii_lowercase().as_str() {
723 "x64" | "x86_64" | "amd64" => Some("x64"),
724 "arm64" | "aarch64" => Some("arm64"),
725 _ => None,
726 }
727}
728
729/// What a job's `runs-on` asks for, read for self-hosted runners: its
730/// labels, cleaned, and the group it names. `self_hosted` when it names
731/// `self-hosted` or a group, which only self-hosted runners can satisfy.
732#[derive(Clone, Debug, Default, PartialEq, Eq)]
733pub struct Wanted {
734 pub labels: Vec<String>,
735 pub group: Option<String>,
736 pub self_hosted: bool,
737}
738
739impl Wanted {
740 pub fn of(labels: &[String], group: Option<&str>) -> Wanted {
741 let mut out: Vec<String> = Vec::new();
742 for label in labels {
743 let clean = label.trim().to_ascii_lowercase();
744 if !clean.is_empty() && !out.contains(&clean) {
745 out.push(clean);
746 }
747 }
748 let group = group.map(|g| g.trim().to_owned()).filter(|g| !g.is_empty());
749 let self_hosted = group.is_some() || out.iter().any(|l| l == SELF_HOSTED);
750 Wanted { labels: out, group, self_hosted }
751 }
752
753 /// The job's labels as stored on it: its labels, then `group:<name>`.
754 pub fn stored(&self) -> Vec<String> {
755 let mut out = self.labels.clone();
756 if let Some(group) = &self.group {
757 out.push(format!("group:{}", group.to_ascii_lowercase()));
758 }
759 out
760 }
761
762 /// Back from what [`Wanted::stored`] kept.
763 pub fn from_stored(stored: &[String]) -> Wanted {
764 let group = stored.iter().find_map(|l| l.strip_prefix("group:").map(str::to_owned));
765 let labels: Vec<String> = stored.iter().filter(|l| !l.starts_with("group:")).cloned().collect();
766 Wanted::of(&labels, group.as_deref())
767 }
768
769 /// Whether a runner with `labels`, in the group called `group`, can
770 /// take it: every label it asks for, and its group if it names one.
771 pub fn matches(&self, labels: &[String], group: Option<&str>) -> bool {
772 let has = |wanted: &String| labels.iter().any(|l| l.eq_ignore_ascii_case(wanted));
773 let group_ok = match (&self.group, group) {
774 (None, _) => true,
775 (Some(wanted), Some(group)) => wanted.eq_ignore_ascii_case(group),
776 (Some(_), None) => false,
777 };
778 group_ok && self.labels.iter().all(has)
779 }
780
781 /// For people: `self-hosted, linux, gpu` (in group `build`).
782 pub fn describe(&self) -> String {
783 let labels = self.labels.join(", ");
784 match &self.group {
785 Some(group) if labels.is_empty() => format!("in group {group}"),
786 Some(group) => format!("{labels} in group {group}"),
787 None => labels,
788 }
789 }
790}
791
792/// What a public repository's job says while the only runners that could
793/// take it run jobs directly on their machines (`host` isolation), which
794/// are never given a public repository's work.
795pub const WAITING_FOR_SANDBOX: &str =
796 "Waiting for a sandboxed runner: this repository is public, and host-mode runners don't run public repositories' jobs.";
797
798/// Whether a runner of `isolation` may be given a repository's work, by
799/// its visibility (`public` or `private`; unknown lets it through, and the
800/// runner decides). A host-mode runner takes no public repository's work.
801pub fn isolation_allows(isolation: Option<&str>, visibility: Option<&str>) -> bool {
802 !(isolation == Some("host") && visibility == Some("public"))
803}
804
805/// What a job waiting for a runner says.
806pub fn waiting_reason(wanted: &Wanted) -> String {
807 format!("Waiting for a self-hosted runner with labels {}.", wanted.describe())
808}
809
810// --- Staff only (sudo.g1t.sh) ---
811
812/// `admin_runners`: a workspace's self-hosted runners, its own and its
813/// repositories', for staff. Returns `Vec<AdminRunner>`.
814#[derive(Debug, Serialize, Deserialize)]
815pub struct AdminRunnersArgs {
816 pub workspace: String,
817}
818
819/// A self-hosted runner as staff see it.
820#[derive(Clone, Debug, Serialize, Deserialize)]
821#[serde(rename_all = "camelCase")]
822pub struct AdminRunner {
823 pub id: String,
824 pub name: String,
825 /// `workspace/repo` for a repository's own runner.
826 pub repo: Option<String>,
827 pub os: String,
828 pub version: String,
829 /// Running something now.
830 pub busy: bool,
831 /// RFC 3339.
832 pub last_seen_at: Option<String>,
833 /// RFC 3339.
834 pub created_at: String,
835 pub created_by: Option<String>,
836}
837
838/// `admin_remove_runners`: staff remove one of a workspace's runners
839/// (`id`), or every one (`id` null), and end the registration tokens it
840/// has out. What they were running fails; their credentials stop at once.
841/// Returns `Outcome<u32>`, how many were removed.
842#[derive(Debug, Serialize, Deserialize)]
843pub struct AdminRemoveRunnersArgs {
844 pub workspace: String,
845 #[serde(default)]
846 pub id: Option<String>,
847 pub staff: String,
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853
854 fn strings(items: &[&str]) -> Vec<String> {
855 items.iter().map(|s| s.to_string()).collect()
856 }
857
858 #[test]
859 fn a_host_mode_runner_takes_no_public_repositorys_work() {
860 assert!(!isolation_allows(Some("host"), Some("public")));
861 assert!(isolation_allows(Some("host"), Some("private")));
862 assert!(isolation_allows(Some("host"), None));
863 assert!(isolation_allows(Some("container"), Some("public")));
864 assert!(isolation_allows(None, Some("public")), "an older runner decides itself");
865 let poll: PollArgs = serde_json::from_value(serde_json::json!({ "runner": "rnr_1", "credential": "c" })).unwrap();
866 assert_eq!(poll.isolation, None, "an older runner's poll");
867 }
868
869 #[test]
870 fn a_runner_always_has_self_hosted_its_os_and_its_arch() {
871 let labels = runner_labels(&strings(&["GPU", "gpu", " cuda-12 "]), "linux", "x64").unwrap();
872 assert_eq!(labels, strings(&["self-hosted", "linux", "x64", "gpu", "cuda-12"]));
873 assert!(runner_labels(&strings(&["has space"]), "linux", "x64").is_err());
874 assert!(runner_labels(&strings(&[""]), "linux", "x64").is_err());
875 }
876
877 #[test]
878 fn os_and_arch_are_read_as_people_write_them() {
879 assert_eq!(os_label("Darwin"), Some("macos"));
880 assert_eq!(os_label("Windows"), Some("windows"));
881 assert_eq!(os_label("plan9"), None);
882 assert_eq!(arch_label("x86_64"), Some("x64"));
883 assert_eq!(arch_label("aarch64"), Some("arm64"));
884 assert_eq!(arch_label("mips"), None);
885 }
886
887 #[test]
888 fn a_job_names_self_hosted_or_a_group_to_need_one() {
889 assert!(Wanted::of(&strings(&["Self-Hosted", "linux"]), None).self_hosted);
890 assert!(Wanted::of(&strings(&[]), Some("build")).self_hosted);
891 assert!(!Wanted::of(&strings(&["ubuntu-latest"]), None).self_hosted);
892 }
893
894 #[test]
895 fn every_label_the_job_names_must_be_the_runners() {
896 let runner = strings(&["self-hosted", "linux", "x64", "gpu"]);
897 assert!(Wanted::of(&strings(&["self-hosted"]), None).matches(&runner, Some("default")));
898 assert!(Wanted::of(&strings(&["self-hosted", "LINUX", "gpu"]), None).matches(&runner, None));
899 assert!(!Wanted::of(&strings(&["self-hosted", "linux", "arm64"]), None).matches(&runner, None));
900 assert!(!Wanted::of(&strings(&["self-hosted", "windows"]), None).matches(&runner, None));
901 }
902
903 #[test]
904 fn a_group_is_matched_by_name() {
905 let runner = strings(&["self-hosted", "linux", "x64"]);
906 let wanted = Wanted::of(&strings(&["linux"]), Some("Build"));
907 assert!(wanted.matches(&runner, Some("build")));
908 assert!(!wanted.matches(&runner, Some("default")));
909 assert!(!wanted.matches(&runner, None));
910 assert_eq!(Wanted::from_stored(&wanted.stored()), wanted.clone().tap_lower());
911 }
912
913 impl Wanted {
914 fn tap_lower(mut self) -> Wanted {
915 self.group = self.group.map(|g| g.to_ascii_lowercase());
916 self
917 }
918 }
919
920 #[test]
921 fn waiting_says_for_what() {
922 let wanted = Wanted::of(&strings(&["self-hosted", "linux", "gpu"]), None);
923 assert_eq!(waiting_reason(&wanted), "Waiting for a self-hosted runner with labels self-hosted, linux, gpu.");
924 }
925
926 #[test]
927 fn the_runners_wire_is_snake_case() {
928 let poll: PollArgs = serde_json::from_value(serde_json::json!({
929 "runner": "rnr_1", "credential": "g1tr_x", "version": "0.1.0", "running": ["job_1"], "wait_ms": 1000
930 }))
931 .unwrap();
932 assert_eq!(poll.auth.runner, "rnr_1");
933 assert_eq!(poll.wait_ms, 1000);
934 let assignment = serde_json::to_value(Assignment {
935 kind: "workflow".into(),
936 id: "job_1".into(),
937 name: "build".into(),
938 repo: "acme/web".into(),
939 timeout_minutes: 60,
940 image: None,
941 token: Some("t".into()),
942 env: None,
943 visibility: Some("private".into()),
944 })
945 .unwrap();
946 assert_eq!(assignment["timeout_minutes"], 60);
947 assert_eq!(assignment["visibility"], "private");
948 // An older runner's registration, without an isolation level.
949 let register: RegisterArgs = serde_json::from_value(serde_json::json!({ "token": "g1trt_x", "name": "a", "os": "linux", "arch": "x64" })).unwrap();
950 assert_eq!(register.isolation, None);
951 }
952}