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