Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Fixes from the page sweep | 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>, | |
| The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how. | 77 | /// For a link: the workflow run the job is in, or for `agent` work |
| 78 | /// the agent run it is (`/<owner>/<name>/agents/runs/<id>`), when | |
| 79 | /// the sandbox that handed it over said which. | |
| Fixes from the page sweep | 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>, | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 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>, | |
| Fixes from the page sweep | 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 | ||
| Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it. | 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 { | |
| Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it. | 253 | /// `idle`, `busy`, `no_docker`, `no_wsl`, `sandbox_failed`, `not_allowed`, … |
| Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it. | 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>, | |
| Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it. | 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>, | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 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, | |
| The desktop app is 0.3.2, and its runner updates are fast, seen and only ever g1t's. The app now trusts a g1t-runner release only when its manifest's Ed25519 signature, latest.json.sig, checks out against g1t's runner release key, which is built into the app (the public half of RUNNER_RELEASE_PUBLIC_KEY, kept in runner/native.rs, where development builds have it too; G1T_RUNNER_RELEASE_KEY overrides it for a g1t with its own key), as the runner already did with its own updates. A manifest with no signature, or one that doesn't check out, is refused and nothing in it is used: the runner keeps the version it has and says "Update refused: not signed by g1t", or, with no runner installed yet, "Not running: the runner's release isn't signed by g1t", each with why and what to do. It looks every hour rather than every six, on a thread of its own so the runner is looked after meanwhile, and what it finds waits, downloaded and checked, until each kind of job is idle, then goes in kind by kind, never cutting a job short: Linux jobs take the release's image (sandbox update) as soon as they have no job, while Windows jobs, whose runner is the program itself, are stopped after their job (sandbox down --only windows --drain), the program is replaced and they start again, the other kind running on throughout; a runner in Docker or on the computer stops between jobs and starts on the new one. The program there is moved aside rather than overwritten, so a status the app is asking at that moment can't stop it. Each kind of job's card in Settings shows its runner version, when the app last looked ("Checked 12 min ago") and a newer one waiting ("0.3.2 ready, installs after this job", "Installing 0.3.2"), and "Check for runner updates" beside the computer's name looks at once and installs what it finds, even with "Download updates by themselves" off, which otherwise stops the hourly look. The desktop report carries the same (snake_case: the diagnosis's update, with checked_at, ready and note, and each kind's update), so the Runners page's Desktops cards show it, "Checked" with how long ago, and this computer's row has the button too. All the words are diagnosis.rs's. The desktop guide says how updates work, what each card shows, the new troubleshooting rows and where the release key lives. Tests cover the signature with a key pair of their own and with release 0.3.0's manifest and signature as g1t.sh published them, kept byte for byte (.gitattributes), the wording, the cards and the report. | 270 | /// How its runner's updates stand. From newer apps; older ones leave it out. |
| 271 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 272 | pub update: Option<DesktopUpdate>, | |
| Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it. | 273 | } |
| 274 | ||
| The desktop app is 0.3.2, and its runner updates are fast, seen and only ever g1t's. The app now trusts a g1t-runner release only when its manifest's Ed25519 signature, latest.json.sig, checks out against g1t's runner release key, which is built into the app (the public half of RUNNER_RELEASE_PUBLIC_KEY, kept in runner/native.rs, where development builds have it too; G1T_RUNNER_RELEASE_KEY overrides it for a g1t with its own key), as the runner already did with its own updates. A manifest with no signature, or one that doesn't check out, is refused and nothing in it is used: the runner keeps the version it has and says "Update refused: not signed by g1t", or, with no runner installed yet, "Not running: the runner's release isn't signed by g1t", each with why and what to do. It looks every hour rather than every six, on a thread of its own so the runner is looked after meanwhile, and what it finds waits, downloaded and checked, until each kind of job is idle, then goes in kind by kind, never cutting a job short: Linux jobs take the release's image (sandbox update) as soon as they have no job, while Windows jobs, whose runner is the program itself, are stopped after their job (sandbox down --only windows --drain), the program is replaced and they start again, the other kind running on throughout; a runner in Docker or on the computer stops between jobs and starts on the new one. The program there is moved aside rather than overwritten, so a status the app is asking at that moment can't stop it. Each kind of job's card in Settings shows its runner version, when the app last looked ("Checked 12 min ago") and a newer one waiting ("0.3.2 ready, installs after this job", "Installing 0.3.2"), and "Check for runner updates" beside the computer's name looks at once and installs what it finds, even with "Download updates by themselves" off, which otherwise stops the hourly look. The desktop report carries the same (snake_case: the diagnosis's update, with checked_at, ready and note, and each kind's update), so the Runners page's Desktops cards show it, "Checked" with how long ago, and this computer's row has the button too. All the words are diagnosis.rs's. The desktop guide says how updates work, what each card shows, the new troubleshooting rows and where the release key lives. Tests cover the signature with a key pair of their own and with release 0.3.0's manifest and signature as g1t.sh published them, kept byte for byte (.gitattributes), the wording, the cards and the report. | 275 | /// How a desktop's runner updates stand, as its app says it. The app |
| 276 | /// looks every hour, and installs a newer runner kind of job by kind of | |
| 277 | /// job, as each is idle. | |
| 278 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 279 | pub struct DesktopUpdate { | |
| 280 | /// When the app last heard from the runner's releases, RFC 3339. | |
| 281 | #[serde(default)] | |
| 282 | pub checked_at: Option<String>, | |
| 283 | /// A newer version, downloaded and checked, waiting to be installed. | |
| 284 | #[serde(default)] | |
| 285 | pub ready: Option<String>, | |
| 286 | /// What becomes of it, for a desktop with no kinds of job of its own: | |
| 287 | /// `0.3.2 ready, installs after this job`. | |
| 288 | #[serde(default)] | |
| 289 | pub note: Option<String>, | |
| 290 | } | |
| 291 | ||
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 292 | /// One kind of job a sandboxed desktop runs, as its app says it. Each |
| 293 | /// starts and stops on its own: `enabled` is false for one the person | |
| 294 | /// stopped, which is not failing. The fields after `fix` come from newer | |
| 295 | /// apps; older ones leave them out. | |
| Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it. | 296 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] |
| 297 | pub struct DesktopCapability { | |
| 298 | /// `linux`, `windows` or `macos`. | |
| 299 | pub id: String, | |
| 300 | /// `Linux jobs`. | |
| 301 | pub label: String, | |
| 302 | pub tone: String, | |
| 303 | /// `Waiting for jobs`, `Not available`. | |
| 304 | pub title: String, | |
| 305 | #[serde(default)] | |
| 306 | pub cause: Option<String>, | |
| 307 | #[serde(default)] | |
| 308 | pub fix: Option<String>, | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 309 | /// `busy`, `idle`, `starting`, `downloading`, `updating`, `waiting`, |
| 310 | /// `stopped`, `unavailable` or `problem`. | |
| 311 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 312 | pub state: Option<String>, | |
| 313 | /// What is wrong, in a few words: `WSL isn't installed`. | |
| 314 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 315 | pub headline: Option<String>, | |
| 316 | /// What it is, in a line. | |
| 317 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 318 | pub about: Option<String>, | |
| 319 | /// On in the person's settings; false when they stopped it. | |
| 320 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 321 | pub enabled: Option<bool>, | |
| 322 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 323 | pub job: Option<DesktopJob>, | |
| 324 | /// Its runner's name on g1t: `build-box-linux`. | |
| 325 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 326 | pub runner: Option<String>, | |
| 327 | /// `nested-container` or `vm`. | |
| 328 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 329 | pub isolation: Option<String>, | |
| 330 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 331 | pub version: Option<String>, | |
| The desktop app is 0.3.2, and its runner updates are fast, seen and only ever g1t's. The app now trusts a g1t-runner release only when its manifest's Ed25519 signature, latest.json.sig, checks out against g1t's runner release key, which is built into the app (the public half of RUNNER_RELEASE_PUBLIC_KEY, kept in runner/native.rs, where development builds have it too; G1T_RUNNER_RELEASE_KEY overrides it for a g1t with its own key), as the runner already did with its own updates. A manifest with no signature, or one that doesn't check out, is refused and nothing in it is used: the runner keeps the version it has and says "Update refused: not signed by g1t", or, with no runner installed yet, "Not running: the runner's release isn't signed by g1t", each with why and what to do. It looks every hour rather than every six, on a thread of its own so the runner is looked after meanwhile, and what it finds waits, downloaded and checked, until each kind of job is idle, then goes in kind by kind, never cutting a job short: Linux jobs take the release's image (sandbox update) as soon as they have no job, while Windows jobs, whose runner is the program itself, are stopped after their job (sandbox down --only windows --drain), the program is replaced and they start again, the other kind running on throughout; a runner in Docker or on the computer stops between jobs and starts on the new one. The program there is moved aside rather than overwritten, so a status the app is asking at that moment can't stop it. Each kind of job's card in Settings shows its runner version, when the app last looked ("Checked 12 min ago") and a newer one waiting ("0.3.2 ready, installs after this job", "Installing 0.3.2"), and "Check for runner updates" beside the computer's name looks at once and installs what it finds, even with "Download updates by themselves" off, which otherwise stops the hourly look. The desktop report carries the same (snake_case: the diagnosis's update, with checked_at, ready and note, and each kind's update), so the Runners page's Desktops cards show it, "Checked" with how long ago, and this computer's row has the button too. All the words are diagnosis.rs's. The desktop guide says how updates work, what each card shows, the new troubleshooting rows and where the release key lives. Tests cover the signature with a key pair of their own and with release 0.3.0's manifest and signature as g1t.sh published them, kept byte for byte (.gitattributes), the wording, the cards and the report. | 332 | /// A newer version waiting for it: `0.3.2 ready, installs after this job`. |
| 333 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 334 | pub update: Option<String>, | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 335 | } |
| 336 | ||
| 337 | /// What a desktop's kind of job is running. | |
| 338 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 339 | pub struct DesktopJob { | |
| 340 | /// `workflow` or `agent`. | |
| 341 | pub kind: String, | |
| 342 | pub name: String, | |
| 343 | /// `acme/web`. | |
| 344 | pub repo: String, | |
| Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it. | 345 | } |
| 346 | ||
| 347 | /// The most kinds of job a desktop reports. | |
| 348 | pub const MAX_DESKTOP_CAPABILITIES: usize = 4; | |
| 349 | ||
| Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it. | 350 | /// One person's computer, as its desktop app last said. |
| 351 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 352 | #[serde(rename_all = "camelCase")] | |
| 353 | pub struct DesktopRunner { | |
| 354 | pub workspace: String, | |
| 355 | /// The computer's name, which its runner has too. | |
| 356 | pub machine: String, | |
| 357 | /// Whose computer: their username. | |
| 358 | pub person: String, | |
| 359 | /// `sandboxed`, `docker` or `host`. | |
| 360 | pub backend: String, | |
| 361 | pub version: Option<String>, | |
| 362 | pub diagnosis: DesktopDiagnosis, | |
| 363 | /// What to show while g1t is closed on it. | |
| 364 | pub closed: DesktopDiagnosis, | |
| 365 | /// Heard from within `DESKTOP_OPEN_WITHIN_MS`: g1t is open there. | |
| 366 | pub open: bool, | |
| 367 | pub reported_at: String, | |
| 368 | } | |
| 369 | ||
| 370 | /// `report_desktop`: the person's own desktop app, through the site, for a | |
| 371 | /// workspace they own. Returns `Outcome<bool>`. | |
| 372 | #[derive(Debug, Serialize, Deserialize)] | |
| 373 | pub struct ReportDesktopArgs { | |
| 374 | pub actor: User, | |
| 375 | pub workspace: String, | |
| 376 | pub machine: String, | |
| 377 | pub backend: String, | |
| 378 | #[serde(default)] | |
| 379 | pub version: Option<String>, | |
| 380 | pub diagnosis: DesktopDiagnosis, | |
| 381 | pub closed: DesktopDiagnosis, | |
| 382 | } | |
| 383 | ||
| 384 | /// `desktop_runners`: owners of the workspace. Returns `Outcome<Vec<DesktopRunner>>`. | |
| 385 | #[derive(Debug, Serialize, Deserialize)] | |
| 386 | pub struct DesktopRunnersArgs { | |
| 387 | pub actor: User, | |
| 388 | pub workspace: String, | |
| 389 | } | |
| 390 | ||
| Fixes from the page sweep | 391 | /// `runner_settings`: members of the workspace, or admins of the |
| 392 | /// repository. Returns `Outcome<RunnerSettings>`. | |
| 393 | #[derive(Debug, Serialize, Deserialize)] | |
| 394 | pub struct RunnerSettingsArgs { | |
| 395 | pub actor: User, | |
| 396 | #[serde(flatten)] | |
| 397 | pub owner: RunnersOwner, | |
| 398 | } | |
| 399 | ||
| 400 | /// `set_runner_settings`: owners, or admins of the repository. Left out is | |
| 401 | /// unchanged; `inherit` drops a repository's own settings. Returns | |
| 402 | /// `Outcome<RunnerSettings>`. | |
| 403 | #[derive(Debug, Serialize, Deserialize)] | |
| 404 | pub struct SetRunnerSettingsArgs { | |
| 405 | pub actor: User, | |
| 406 | #[serde(flatten)] | |
| 407 | pub owner: RunnersOwner, | |
| 408 | #[serde(default)] | |
| 409 | pub agents_on_self_hosted: Option<bool>, | |
| 410 | #[serde(default)] | |
| 411 | pub agent_labels: Option<Vec<String>>, | |
| 412 | #[serde(default)] | |
| 413 | pub fork_pull_requests: Option<bool>, | |
| 414 | #[serde(default)] | |
| 415 | pub inherit: bool, | |
| 416 | } | |
| 417 | ||
| 418 | /// `stuck_jobs`: Mission control's Needs you. Returns `Vec<StuckJob>`. | |
| 419 | #[derive(Debug, Serialize, Deserialize)] | |
| 420 | pub struct StuckJobsArgs { | |
| 421 | pub viewer: crate::Viewer, | |
| 422 | } | |
| 423 | ||
| The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how. | 424 | /// `runner_activity`: what runs for a workspace now, on g1t's cloud and |
| 425 | /// handed to its own runners, for the Runners page. Owners, as the | |
| 426 | /// workspace's runners are. Snake case both ways. Returns | |
| 427 | /// `Outcome<RunnerActivity>`. | |
| 428 | #[derive(Debug, Serialize, Deserialize)] | |
| 429 | pub struct RunnerActivityArgs { | |
| 430 | pub actor: User, | |
| 431 | pub workspace: String, | |
| 432 | } | |
| 433 | ||
| 434 | /// A workflow job running in one of g1t's sandboxes. | |
| 435 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 436 | pub struct CloudJob { | |
| 437 | pub id: String, | |
| 438 | pub name: String, | |
| 439 | /// The workflow run it is in. | |
| 440 | pub run_id: String, | |
| 441 | /// `owner/name`. | |
| 442 | pub repo: String, | |
| 443 | pub started_at: Option<String>, | |
| 444 | } | |
| 445 | ||
| 446 | /// Agent work handed to one of the workspace's own runners, waiting for | |
| 447 | /// one or taken. | |
| 448 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 449 | pub struct HandedOverTask { | |
| 450 | pub id: String, | |
| 451 | /// The agent run it is, when the sandbox said; older tasks have none. | |
| 452 | pub run_id: Option<String>, | |
| 453 | /// `agent`, `checks`, `review`, `queue`… | |
| 454 | pub kind: String, | |
| 455 | pub title: String, | |
| 456 | /// `owner/name`. | |
| 457 | pub repo: String, | |
| 458 | /// `queued` or `in_progress`. | |
| 459 | pub status: String, | |
| 460 | pub runner_name: Option<String>, | |
| 461 | pub created_at: String, | |
| 462 | pub started_at: Option<String>, | |
| 463 | } | |
| 464 | ||
| 465 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 466 | pub struct RunnerActivity { | |
| 467 | /// Workflow jobs in g1t's sandboxes now, newest first, at most | |
| 468 | /// `ACTIVITY_LIMIT`. | |
| 469 | pub cloud_jobs: Vec<CloudJob>, | |
| 470 | /// Workflow jobs queued for g1t's sandboxes. | |
| 471 | pub cloud_jobs_queued: u32, | |
| 472 | /// Agent work on, or waiting for, the workspace's own runners. | |
| 473 | pub handed_over: Vec<HandedOverTask>, | |
| 474 | /// Workflow jobs waiting for one of the workspace's own runners. | |
| 475 | pub self_hosted_jobs_queued: u32, | |
| 476 | } | |
| 477 | ||
| 478 | /// The most jobs and tasks `runner_activity` lists of each. | |
| 479 | pub const ACTIVITY_LIMIT: u32 = 100; | |
| 480 | ||
| Fixes from the page sweep | 481 | /// A job that has waited ten minutes or more for a self-hosted runner, |
| 482 | /// with none that matches online. | |
| 483 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 484 | #[serde(rename_all = "camelCase")] | |
| 485 | pub struct StuckJob { | |
| 486 | pub id: String, | |
| 487 | pub name: String, | |
| 488 | pub run_id: String, | |
| 489 | /// `owner/name`. | |
| 490 | pub repo: String, | |
| 491 | /// What it asks for, for people: `self-hosted, linux, gpu`. | |
| 492 | pub labels: String, | |
| 493 | pub queued_at: String, | |
| 494 | } | |
| 495 | ||
| 496 | // --- The runner's side (snake_case on the wire, as the API passes it) ------ | |
| 497 | ||
| 498 | /// `runner_register`: a machine registering with a registration token. | |
| 499 | /// Returns `Outcome<Registered>`. | |
| 500 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 501 | pub struct RegisterArgs { | |
| 502 | pub token: String, | |
| 503 | pub name: String, | |
| 504 | #[serde(default)] | |
| 505 | pub labels: Vec<String>, | |
| 506 | pub os: String, | |
| 507 | pub arch: String, | |
| 508 | #[serde(default)] | |
| 509 | pub version: String, | |
| 510 | #[serde(default)] | |
| 511 | pub ephemeral: bool, | |
| 512 | /// A group by name, for a workspace's token; the token's group, else | |
| 513 | /// the default, if left out. | |
| 514 | #[serde(default)] | |
| 515 | pub group: Option<String>, | |
| 516 | /// Replace a runner of the same name instead of refusing. | |
| 517 | #[serde(default)] | |
| 518 | pub replace: bool, | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 519 | /// How the runner keeps work from its machine: `nested-container` (a |
| 520 | /// container per job, inside the runner's own sandboxed container), | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 521 | /// `vm` (a throwaway VM per job, native to the machine's OS), |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 522 | /// `container` (a container per job, on a Docker Engine it shares with |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 523 | /// its machine) or `host` (directly on the machine). A `host` runner is |
| 524 | /// given no public repository's work ([`isolation_allows`]); older | |
| 525 | /// runners leave it out. | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 526 | #[serde(default)] |
| 527 | pub isolation: Option<String>, | |
| Fixes from the page sweep | 528 | } |
| 529 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 530 | /// The isolation levels a runner may say it has (`RegisterArgs::isolation`). |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 531 | pub const ISOLATIONS: &[&str] = &["nested-container", "vm", "container", "host"]; |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 532 | |
| Fixes from the page sweep | 533 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 534 | pub struct Registered { | |
| 535 | pub runner: Runner, | |
| 536 | /// Shown once; the runner keeps it in its configuration. | |
| 537 | pub credential: String, | |
| 538 | } | |
| 539 | ||
| 540 | /// Every call a registered runner makes carries its id and credential. | |
| 541 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 542 | pub struct RunnerAuth { | |
| 543 | pub runner: String, | |
| 544 | pub credential: String, | |
| 545 | } | |
| 546 | ||
| 547 | /// `runner_poll`: waits up to `wait_ms` for work. Doubles as the runner's | |
| 548 | /// heartbeat. Returns `Outcome<Poll>`. | |
| 549 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 550 | pub struct PollArgs { | |
| 551 | #[serde(flatten)] | |
| 552 | pub auth: RunnerAuth, | |
| 553 | #[serde(default)] | |
| 554 | pub version: String, | |
| 555 | /// What it is running now; it is given nothing more while busy. | |
| 556 | #[serde(default)] | |
| 557 | pub running: Vec<String>, | |
| 558 | #[serde(default)] | |
| 559 | pub wait_ms: u64, | |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 560 | /// How the runner keeps work from its machine now, as at registration |
| 561 | /// (`RegisterArgs::isolation`); kept up to date from each poll. Older | |
| 562 | /// runners leave it out. | |
| 563 | #[serde(default)] | |
| 564 | pub isolation: Option<String>, | |
| Fixes from the page sweep | 565 | } |
| 566 | ||
| 567 | /// Work for a runner. | |
| 568 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 569 | pub struct Assignment { | |
| 570 | /// `workflow` or `agent`. | |
| 571 | pub kind: String, | |
| 572 | pub id: String, | |
| 573 | pub name: String, | |
| 574 | pub repo: String, | |
| 575 | pub timeout_minutes: u32, | |
| 576 | /// The image to run it in: the job's `container:`, when it names one. | |
| 577 | pub image: Option<String>, | |
| 578 | /// A workflow job's own token, for `ACTIONS_JOB`/`ACTIONS_TOKEN`. | |
| 579 | pub token: Option<String>, | |
| 580 | /// An agent task's environment, as g1t's sandbox would have been | |
| 581 | /// started with: `MODE`, the run's short-lived credentials, the model | |
| 582 | /// proxy's address. | |
| 583 | pub env: Option<Map<String, Value>>, | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 584 | /// The repository's visibility, `public` or `private`, when g1t knows |
| 585 | /// it: a runner that runs work directly on its machine refuses a | |
| 586 | /// public repository's. Older g1t leaves it out. | |
| 587 | #[serde(default)] | |
| 588 | pub visibility: Option<String>, | |
| Fixes from the page sweep | 589 | } |
| 590 | ||
| 591 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 592 | pub struct Poll { | |
| 593 | pub assignment: Option<Assignment>, | |
| 594 | /// Work it holds that it should stop: cancelled, timed out, removed. | |
| 595 | #[serde(default)] | |
| 596 | pub cancel: Vec<String>, | |
| 597 | /// A new credential: it replaces the old, which stops working once this | |
| 598 | /// one is used. | |
| 599 | pub credential: Option<String>, | |
| 600 | /// The runner was removed: stop and forget the credential. | |
| 601 | #[serde(default)] | |
| 602 | pub removed: bool, | |
| 603 | } | |
| 604 | ||
| 605 | /// `runner_finished`: a runner telling what became of work it was given. | |
| 606 | /// For a workflow job the harness has normally reported already; this | |
| 607 | /// covers one that crashed or was killed. Returns `Outcome<bool>` (whether | |
| 608 | /// it changed anything). | |
| 609 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 610 | pub struct FinishedArgs { | |
| 611 | #[serde(flatten)] | |
| 612 | pub auth: RunnerAuth, | |
| 613 | pub id: String, | |
| 614 | pub exit_code: i32, | |
| 615 | #[serde(default)] | |
| 616 | pub reason: Option<String>, | |
| 617 | } | |
| 618 | ||
| 619 | /// `runner_remove_self`: `g1t-runner remove`. Returns `Outcome<bool>`. | |
| 620 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 621 | pub struct RemoveSelfArgs { | |
| 622 | #[serde(flatten)] | |
| 623 | pub auth: RunnerAuth, | |
| 624 | } | |
| 625 | ||
| 626 | // --- Between services (camelCase, as every service speaks) ---------------- | |
| 627 | ||
| 628 | /// `runner_route`: whether a kind of g1t's own work in a repository runs on | |
| 629 | /// self-hosted runners, and on which labels. Asked by the runner service | |
| 630 | /// before it starts a sandbox. Returns `Option<Vec<String>>`. | |
| 631 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 632 | #[serde(rename_all = "camelCase")] | |
| 633 | pub struct RouteArgs { | |
| 634 | pub workspace: String, | |
| 635 | pub repo: RepoPath, | |
| 636 | } | |
| 637 | ||
| 638 | /// `enqueue_task`: agent work for a self-hosted runner, from the runner | |
| 639 | /// service's sandbox (`sandbox` is its Durable Object's id), which is told | |
| 640 | /// with `task_ended` how it went. Returns `Outcome<String>` (the task's id). | |
| 641 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 642 | #[serde(rename_all = "camelCase")] | |
| 643 | pub struct EnqueueTaskArgs { | |
| 644 | pub sandbox: String, | |
| 645 | pub workspace: String, | |
| 646 | pub repo: RepoPath, | |
| 647 | /// The run kind: `agent`, `checks`, `review`, `queue`… | |
| 648 | pub kind: String, | |
| 649 | pub title: String, | |
| 650 | pub labels: Vec<String>, | |
| 651 | pub env: Map<String, Value>, | |
| 652 | pub timeout_minutes: u32, | |
| The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how. | 653 | /// The agent run the work belongs to, when the sandbox opened one: what |
| 654 | /// people's pages link a runner's agent work to. | |
| 655 | #[serde(default, alias = "run_id")] | |
| 656 | pub run_id: Option<String>, | |
| Fixes from the page sweep | 657 | } |
| 658 | ||
| 659 | /// `cancel_task`: the sandbox's work stopped from g1t's side (a person, a | |
| 660 | /// time cap). Returns `Outcome<bool>`. | |
| 661 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 662 | #[serde(rename_all = "camelCase")] | |
| 663 | pub struct CancelTaskArgs { | |
| 664 | pub sandbox: String, | |
| 665 | #[serde(default)] | |
| 666 | pub reason: Option<String>, | |
| 667 | } | |
| 668 | ||
| 669 | // --- Labels ---------------------------------------------------------------- | |
| 670 | ||
| 671 | /// A label as it is kept: trimmed and lowercase. `None` when it is not | |
| 672 | /// one: empty, too long, or with characters other than letters, digits, | |
| 673 | /// `-`, `_`, `.`, `:` and `/`. | |
| 674 | pub fn clean_label(label: &str) -> Option<String> { | |
| 675 | let label = label.trim().to_ascii_lowercase(); | |
| 676 | let ok = !label.is_empty() | |
| 677 | && label.len() <= MAX_LABEL_LEN | |
| 678 | && label.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '/')); | |
| 679 | ok.then_some(label) | |
| 680 | } | |
| 681 | ||
| 682 | /// A runner's labels: what it was given, cleaned, with `self-hosted`, its | |
| 683 | /// OS and its architecture, each once, in that order first. | |
| 684 | pub fn runner_labels(given: &[String], os: &str, arch: &str) -> Result<Vec<String>, String> { | |
| 685 | let mut labels: Vec<String> = Vec::new(); | |
| 686 | for label in [SELF_HOSTED, os, arch].iter().map(|s| s.to_string()).chain(given.iter().cloned()) { | |
| 687 | let Some(clean) = clean_label(&label) else { | |
| 688 | return Err(format!("`{label}` is not a label: use letters, digits, `-`, `_`, `.`, `:` and `/`, up to {MAX_LABEL_LEN} characters.")); | |
| 689 | }; | |
| 690 | if !labels.contains(&clean) { | |
| 691 | labels.push(clean); | |
| 692 | } | |
| 693 | } | |
| 694 | if labels.len() > MAX_LABELS { | |
| 695 | return Err(format!("A runner has at most {MAX_LABELS} labels.")); | |
| 696 | } | |
| 697 | Ok(labels) | |
| 698 | } | |
| 699 | ||
| 700 | /// The OS a runner reports, as its label: `linux`, `macos` or `windows`. | |
| 701 | pub fn os_label(os: &str) -> Option<&'static str> { | |
| 702 | match os.trim().to_ascii_lowercase().as_str() { | |
| 703 | "linux" => Some("linux"), | |
| 704 | "macos" | "darwin" | "osx" | "mac" => Some("macos"), | |
| 705 | "windows" | "win" => Some("windows"), | |
| 706 | _ => None, | |
| 707 | } | |
| 708 | } | |
| 709 | ||
| 710 | /// The architecture a runner reports, as its label: `x64` or `arm64`. | |
| 711 | pub fn arch_label(arch: &str) -> Option<&'static str> { | |
| 712 | match arch.trim().to_ascii_lowercase().as_str() { | |
| 713 | "x64" | "x86_64" | "amd64" => Some("x64"), | |
| 714 | "arm64" | "aarch64" => Some("arm64"), | |
| 715 | _ => None, | |
| 716 | } | |
| 717 | } | |
| 718 | ||
| 719 | /// What a job's `runs-on` asks for, read for self-hosted runners: its | |
| 720 | /// labels, cleaned, and the group it names. `self_hosted` when it names | |
| 721 | /// `self-hosted` or a group, which only self-hosted runners can satisfy. | |
| 722 | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 723 | pub struct Wanted { | |
| 724 | pub labels: Vec<String>, | |
| 725 | pub group: Option<String>, | |
| 726 | pub self_hosted: bool, | |
| 727 | } | |
| 728 | ||
| 729 | impl Wanted { | |
| 730 | pub fn of(labels: &[String], group: Option<&str>) -> Wanted { | |
| 731 | let mut out: Vec<String> = Vec::new(); | |
| 732 | for label in labels { | |
| 733 | let clean = label.trim().to_ascii_lowercase(); | |
| 734 | if !clean.is_empty() && !out.contains(&clean) { | |
| 735 | out.push(clean); | |
| 736 | } | |
| 737 | } | |
| 738 | let group = group.map(|g| g.trim().to_owned()).filter(|g| !g.is_empty()); | |
| 739 | let self_hosted = group.is_some() || out.iter().any(|l| l == SELF_HOSTED); | |
| 740 | Wanted { labels: out, group, self_hosted } | |
| 741 | } | |
| 742 | ||
| 743 | /// The job's labels as stored on it: its labels, then `group:<name>`. | |
| 744 | pub fn stored(&self) -> Vec<String> { | |
| 745 | let mut out = self.labels.clone(); | |
| 746 | if let Some(group) = &self.group { | |
| 747 | out.push(format!("group:{}", group.to_ascii_lowercase())); | |
| 748 | } | |
| 749 | out | |
| 750 | } | |
| 751 | ||
| 752 | /// Back from what [`Wanted::stored`] kept. | |
| 753 | pub fn from_stored(stored: &[String]) -> Wanted { | |
| 754 | let group = stored.iter().find_map(|l| l.strip_prefix("group:").map(str::to_owned)); | |
| 755 | let labels: Vec<String> = stored.iter().filter(|l| !l.starts_with("group:")).cloned().collect(); | |
| 756 | Wanted::of(&labels, group.as_deref()) | |
| 757 | } | |
| 758 | ||
| 759 | /// Whether a runner with `labels`, in the group called `group`, can | |
| 760 | /// take it: every label it asks for, and its group if it names one. | |
| 761 | pub fn matches(&self, labels: &[String], group: Option<&str>) -> bool { | |
| 762 | let has = |wanted: &String| labels.iter().any(|l| l.eq_ignore_ascii_case(wanted)); | |
| 763 | let group_ok = match (&self.group, group) { | |
| 764 | (None, _) => true, | |
| 765 | (Some(wanted), Some(group)) => wanted.eq_ignore_ascii_case(group), | |
| 766 | (Some(_), None) => false, | |
| 767 | }; | |
| 768 | group_ok && self.labels.iter().all(has) | |
| 769 | } | |
| 770 | ||
| 771 | /// For people: `self-hosted, linux, gpu` (in group `build`). | |
| 772 | pub fn describe(&self) -> String { | |
| 773 | let labels = self.labels.join(", "); | |
| 774 | match &self.group { | |
| 775 | Some(group) if labels.is_empty() => format!("in group {group}"), | |
| 776 | Some(group) => format!("{labels} in group {group}"), | |
| 777 | None => labels, | |
| 778 | } | |
| 779 | } | |
| 780 | } | |
| 781 | ||
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 782 | /// What a public repository's job says while the only runners that could |
| 783 | /// take it run jobs directly on their machines (`host` isolation), which | |
| 784 | /// are never given a public repository's work. | |
| 785 | pub const WAITING_FOR_SANDBOX: &str = | |
| 786 | "Waiting for a sandboxed runner: this repository is public, and host-mode runners don't run public repositories' jobs."; | |
| 787 | ||
| 788 | /// Whether a runner of `isolation` may be given a repository's work, by | |
| 789 | /// its visibility (`public` or `private`; unknown lets it through, and the | |
| 790 | /// runner decides). A host-mode runner takes no public repository's work. | |
| 791 | pub fn isolation_allows(isolation: Option<&str>, visibility: Option<&str>) -> bool { | |
| 792 | !(isolation == Some("host") && visibility == Some("public")) | |
| 793 | } | |
| 794 | ||
| Fixes from the page sweep | 795 | /// What a job waiting for a runner says. |
| 796 | pub fn waiting_reason(wanted: &Wanted) -> String { | |
| 797 | format!("Waiting for a self-hosted runner with labels {}.", wanted.describe()) | |
| 798 | } | |
| 799 | ||
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 800 | // --- Staff only (sudo.g1t.sh) --- |
| 801 | ||
| 802 | /// `admin_runners`: a workspace's self-hosted runners, its own and its | |
| 803 | /// repositories', for staff. Returns `Vec<AdminRunner>`. | |
| 804 | #[derive(Debug, Serialize, Deserialize)] | |
| 805 | pub struct AdminRunnersArgs { | |
| 806 | pub workspace: String, | |
| 807 | } | |
| 808 | ||
| 809 | /// A self-hosted runner as staff see it. | |
| 810 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 811 | #[serde(rename_all = "camelCase")] | |
| 812 | pub struct AdminRunner { | |
| 813 | pub id: String, | |
| 814 | pub name: String, | |
| 815 | /// `workspace/repo` for a repository's own runner. | |
| 816 | pub repo: Option<String>, | |
| 817 | pub os: String, | |
| 818 | pub version: String, | |
| 819 | /// Running something now. | |
| 820 | pub busy: bool, | |
| 821 | /// RFC 3339. | |
| 822 | pub last_seen_at: Option<String>, | |
| 823 | /// RFC 3339. | |
| 824 | pub created_at: String, | |
| 825 | pub created_by: Option<String>, | |
| 826 | } | |
| 827 | ||
| 828 | /// `admin_remove_runners`: staff remove one of a workspace's runners | |
| 829 | /// (`id`), or every one (`id` null), and end the registration tokens it | |
| 830 | /// has out. What they were running fails; their credentials stop at once. | |
| 831 | /// Returns `Outcome<u32>`, how many were removed. | |
| 832 | #[derive(Debug, Serialize, Deserialize)] | |
| 833 | pub struct AdminRemoveRunnersArgs { | |
| 834 | pub workspace: String, | |
| 835 | #[serde(default)] | |
| 836 | pub id: Option<String>, | |
| 837 | pub staff: String, | |
| 838 | } | |
| 839 | ||
| Fixes from the page sweep | 840 | #[cfg(test)] |
| 841 | mod tests { | |
| 842 | use super::*; | |
| 843 | ||
| 844 | fn strings(items: &[&str]) -> Vec<String> { | |
| 845 | items.iter().map(|s| s.to_string()).collect() | |
| 846 | } | |
| 847 | ||
| 848 | #[test] | |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 849 | fn a_host_mode_runner_takes_no_public_repositorys_work() { |
| 850 | assert!(!isolation_allows(Some("host"), Some("public"))); | |
| 851 | assert!(isolation_allows(Some("host"), Some("private"))); | |
| 852 | assert!(isolation_allows(Some("host"), None)); | |
| 853 | assert!(isolation_allows(Some("container"), Some("public"))); | |
| 854 | assert!(isolation_allows(None, Some("public")), "an older runner decides itself"); | |
| 855 | let poll: PollArgs = serde_json::from_value(serde_json::json!({ "runner": "rnr_1", "credential": "c" })).unwrap(); | |
| 856 | assert_eq!(poll.isolation, None, "an older runner's poll"); | |
| 857 | } | |
| 858 | ||
| 859 | #[test] | |
| Fixes from the page sweep | 860 | fn a_runner_always_has_self_hosted_its_os_and_its_arch() { |
| 861 | let labels = runner_labels(&strings(&["GPU", "gpu", " cuda-12 "]), "linux", "x64").unwrap(); | |
| 862 | assert_eq!(labels, strings(&["self-hosted", "linux", "x64", "gpu", "cuda-12"])); | |
| 863 | assert!(runner_labels(&strings(&["has space"]), "linux", "x64").is_err()); | |
| 864 | assert!(runner_labels(&strings(&[""]), "linux", "x64").is_err()); | |
| 865 | } | |
| 866 | ||
| 867 | #[test] | |
| 868 | fn os_and_arch_are_read_as_people_write_them() { | |
| 869 | assert_eq!(os_label("Darwin"), Some("macos")); | |
| 870 | assert_eq!(os_label("Windows"), Some("windows")); | |
| 871 | assert_eq!(os_label("plan9"), None); | |
| 872 | assert_eq!(arch_label("x86_64"), Some("x64")); | |
| 873 | assert_eq!(arch_label("aarch64"), Some("arm64")); | |
| 874 | assert_eq!(arch_label("mips"), None); | |
| 875 | } | |
| 876 | ||
| 877 | #[test] | |
| 878 | fn a_job_names_self_hosted_or_a_group_to_need_one() { | |
| 879 | assert!(Wanted::of(&strings(&["Self-Hosted", "linux"]), None).self_hosted); | |
| 880 | assert!(Wanted::of(&strings(&[]), Some("build")).self_hosted); | |
| 881 | assert!(!Wanted::of(&strings(&["ubuntu-latest"]), None).self_hosted); | |
| 882 | } | |
| 883 | ||
| 884 | #[test] | |
| 885 | fn every_label_the_job_names_must_be_the_runners() { | |
| 886 | let runner = strings(&["self-hosted", "linux", "x64", "gpu"]); | |
| 887 | assert!(Wanted::of(&strings(&["self-hosted"]), None).matches(&runner, Some("default"))); | |
| 888 | assert!(Wanted::of(&strings(&["self-hosted", "LINUX", "gpu"]), None).matches(&runner, None)); | |
| 889 | assert!(!Wanted::of(&strings(&["self-hosted", "linux", "arm64"]), None).matches(&runner, None)); | |
| 890 | assert!(!Wanted::of(&strings(&["self-hosted", "windows"]), None).matches(&runner, None)); | |
| 891 | } | |
| 892 | ||
| 893 | #[test] | |
| 894 | fn a_group_is_matched_by_name() { | |
| 895 | let runner = strings(&["self-hosted", "linux", "x64"]); | |
| 896 | let wanted = Wanted::of(&strings(&["linux"]), Some("Build")); | |
| 897 | assert!(wanted.matches(&runner, Some("build"))); | |
| 898 | assert!(!wanted.matches(&runner, Some("default"))); | |
| 899 | assert!(!wanted.matches(&runner, None)); | |
| 900 | assert_eq!(Wanted::from_stored(&wanted.stored()), wanted.clone().tap_lower()); | |
| 901 | } | |
| 902 | ||
| 903 | impl Wanted { | |
| 904 | fn tap_lower(mut self) -> Wanted { | |
| 905 | self.group = self.group.map(|g| g.to_ascii_lowercase()); | |
| 906 | self | |
| 907 | } | |
| 908 | } | |
| 909 | ||
| 910 | #[test] | |
| 911 | fn waiting_says_for_what() { | |
| 912 | let wanted = Wanted::of(&strings(&["self-hosted", "linux", "gpu"]), None); | |
| 913 | assert_eq!(waiting_reason(&wanted), "Waiting for a self-hosted runner with labels self-hosted, linux, gpu."); | |
| 914 | } | |
| 915 | ||
| 916 | #[test] | |
| 917 | fn the_runners_wire_is_snake_case() { | |
| 918 | let poll: PollArgs = serde_json::from_value(serde_json::json!({ | |
| 919 | "runner": "rnr_1", "credential": "g1tr_x", "version": "0.1.0", "running": ["job_1"], "wait_ms": 1000 | |
| 920 | })) | |
| 921 | .unwrap(); | |
| 922 | assert_eq!(poll.auth.runner, "rnr_1"); | |
| 923 | assert_eq!(poll.wait_ms, 1000); | |
| 924 | let assignment = serde_json::to_value(Assignment { | |
| 925 | kind: "workflow".into(), | |
| 926 | id: "job_1".into(), | |
| 927 | name: "build".into(), | |
| 928 | repo: "acme/web".into(), | |
| 929 | timeout_minutes: 60, | |
| 930 | image: None, | |
| 931 | token: Some("t".into()), | |
| 932 | env: None, | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 933 | visibility: Some("private".into()), |
| Fixes from the page sweep | 934 | }) |
| 935 | .unwrap(); | |
| 936 | assert_eq!(assignment["timeout_minutes"], 60); | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 937 | assert_eq!(assignment["visibility"], "private"); |
| 938 | // An older runner's registration, without an isolation level. | |
| 939 | let register: RegisterArgs = serde_json::from_value(serde_json::json!({ "token": "g1trt_x", "name": "a", "os": "linux", "arch": "x64" })).unwrap(); | |
| 940 | assert_eq!(register.isolation, None); | |
| Fixes from the page sweep | 941 | } |
| 942 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.