A public repository's job is no longer handed to a self-hosted runner in host mode, only to fail there. Runners say how they are isolated on every poll as well as when they register (an older g1t ignores it), so a runner restarted with or without Docker is known as it is now; the queue gives a host-mode runner no public repository's jobs or agent work, reading each repository's visibility once a poll, and a job that no online runner which may take it could, sandboxed and with its labels, group and repository, waits saying "Waiting for a sandboxed runner: this repository is public, and host-mode runners don't run public repositories' jobs." until one comes online. A runner that never said how it is isolated is given the job as before and refuses it itself. The Self-hosted runners guide says so, with the message in its troubleshooting table.
- Claude-Session:
- https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
6 files+208−150/6 viewed
| 70 | 70 | args["version"] = json!(body["version"].as_str().unwrap_or_default()); | |
| 71 | 71 | args["running"] = json!(body["running"].as_array().cloned().unwrap_or_default()); | |
| 72 | 72 | args["wait_ms"] = json!(body["wait_ms"].as_u64().unwrap_or(0)); | |
| 73 | + | args["isolation"] = json!(body["isolation"].as_str()); | |
| 73 | 74 | "runner_poll" | |
| 74 | 75 | } | |
| 75 | 76 | "finished" => { |
| 144 | 144 | jobs directly on this machine (--no-docker), unsandboxed.* | |
| 145 | 145 | - Every job's log starts with a warning: *This job ran on the host, | |
| 146 | 146 | unsandboxed …* | |
| 147 | − | - It refuses a public repository's work, which anyone can propose code | |
| 148 | − | to: *acme/web is public, and this self-hosted runner runs jobs directly | |
| 149 | − | on its machine (--no-docker), unsandboxed, so it does not take a public | |
| 150 | − | repository's work.* The job fails with that message. | |
| 147 | + | - It is not given a public repository's work, which anyone can propose | |
| 148 | + | code to. The runner says how it is isolated when it registers and on | |
| 149 | + | every poll, and g1t hands a public repository's jobs and agent work | |
| 150 | + | only to runners that are not in host mode. A job no other online runner | |
| 151 | + | could take waits, and its page says *Waiting for a sandboxed runner: | |
| 152 | + | this repository is public, and host-mode runners don't run public | |
| 153 | + | repositories' jobs.* It starts once a sandboxed runner with its labels | |
| 154 | + | comes online. | |
| 155 | + | - A runner from before this, which does not say how it is isolated, may | |
| 156 | + | still be given such a job and refuses it itself: *acme/web is public, | |
| 157 | + | and this self-hosted runner runs jobs directly on its machine | |
| 158 | + | (--no-docker), unsandboxed, so it does not take a public repository's | |
| 159 | + | work.* The job fails with that message. Update the runner. | |
| 151 | 160 | ||
| 152 | 161 | Move a Linux runner to [the container](#run-it-as-a-container). A macOS | |
| 153 | 162 | or Windows runner stays in host mode until sandboxes for them are built. | |
| ⋯ | |||
| 550 | 559 | | `run` says *g1t no longer knows this runner* | It was removed, or its credential was. Register it again. | | |
| 551 | 560 | | *this runner is not sandboxed from its machine, so it will not take work* | The runner's container can reach the machine: it lists why. Remove the machine's Docker socket and `--privileged`, and run it with `--runtime=sysbox-runc`. See [run it as a container](#run-it-as-a-container). | | |
| 552 | 561 | | *the runner's own Docker Engine could not start* | The container is not running under Sysbox. Add `--runtime=sysbox-runc`, and check that Sysbox is installed (`docker info` lists `sysbox-runc` under runtimes). | | |
| 553 | − | | *… is public, and this self-hosted runner runs jobs directly on its machine* | A runner in host mode does not take public repositories' work. Run the runner's image under Sysbox. | | |
| 562 | + | | The job says *Waiting for a sandboxed runner: this repository is public …* | Every online runner with the job's labels is in host mode, which never runs a public repository's jobs. Run a runner's image under Sysbox, or register one without `--no-docker`. | | |
| 563 | + | | *… is public, and this self-hosted runner runs jobs directly on its machine* | An older runner in host mode refused a public repository's job. Update it, so g1t gives the job to a sandboxed runner instead, and run the runner's image under Sysbox. | | |
| 554 | 564 | | A step's `sudo` fails | Job containers have `no-new-privileges`. Steps run as the image's user, root in most CI images, so drop the `sudo`. | | |
| 555 | 565 | | *could not run docker* | Install Docker and start it, and give the runner's user access to it. | | |
| 556 | 566 | | Agent work fails at once | Give the runner `--agent-image`. | | |
| 361 | 361 | /// How the runner keeps work from its machine: `nested-container` (a | |
| 362 | 362 | /// container per job, inside the runner's own sandboxed container), | |
| 363 | 363 | /// `container` (a container per job, on a Docker Engine it shares with | |
| 364 | − | /// its machine) or `host` (directly on the machine). Kept, and shown | |
| 365 | − | /// nowhere yet; older runners leave it out. | |
| 364 | + | /// its machine) or `host` (directly on the machine). A `host` runner is | |
| 365 | + | /// given no public repository's work ([`isolation_allows`]); older | |
| 366 | + | /// runners leave it out. | |
| 366 | 367 | #[serde(default)] | |
| 367 | 368 | pub isolation: Option<String>, | |
| 368 | 369 | } | |
| ⋯ | |||
| 397 | 398 | pub running: Vec<String>, | |
| 398 | 399 | #[serde(default)] | |
| 399 | 400 | pub wait_ms: u64, | |
| 401 | + | /// How the runner keeps work from its machine now, as at registration | |
| 402 | + | /// (`RegisterArgs::isolation`); kept up to date from each poll. Older | |
| 403 | + | /// runners leave it out. | |
| 404 | + | #[serde(default)] | |
| 405 | + | pub isolation: Option<String>, | |
| 400 | 406 | } | |
| 401 | 407 | ||
| 402 | 408 | /// Work for a runner. | |
| ⋯ | |||
| 614 | 620 | } | |
| 615 | 621 | } | |
| 616 | 622 | ||
| 623 | + | /// What a public repository's job says while the only runners that could | |
| 624 | + | /// take it run jobs directly on their machines (`host` isolation), which | |
| 625 | + | /// are never given a public repository's work. | |
| 626 | + | pub const WAITING_FOR_SANDBOX: &str = | |
| 627 | + | "Waiting for a sandboxed runner: this repository is public, and host-mode runners don't run public repositories' jobs."; | |
| 628 | + | ||
| 629 | + | /// Whether a runner of `isolation` may be given a repository's work, by | |
| 630 | + | /// its visibility (`public` or `private`; unknown lets it through, and the | |
| 631 | + | /// runner decides). A host-mode runner takes no public repository's work. | |
| 632 | + | pub fn isolation_allows(isolation: Option<&str>, visibility: Option<&str>) -> bool { | |
| 633 | + | !(isolation == Some("host") && visibility == Some("public")) | |
| 634 | + | } | |
| 635 | + | ||
| 617 | 636 | /// What a job waiting for a runner says. | |
| 618 | 637 | pub fn waiting_reason(wanted: &Wanted) -> String { | |
| 619 | 638 | format!("Waiting for a self-hosted runner with labels {}.", wanted.describe()) | |
| ⋯ | |||
| 628 | 647 | } | |
| 629 | 648 | ||
| 630 | 649 | #[test] | |
| 650 | + | fn a_host_mode_runner_takes_no_public_repositorys_work() { | |
| 651 | + | assert!(!isolation_allows(Some("host"), Some("public"))); | |
| 652 | + | assert!(isolation_allows(Some("host"), Some("private"))); | |
| 653 | + | assert!(isolation_allows(Some("host"), None)); | |
| 654 | + | assert!(isolation_allows(Some("container"), Some("public"))); | |
| 655 | + | assert!(isolation_allows(None, Some("public")), "an older runner decides itself"); | |
| 656 | + | let poll: PollArgs = serde_json::from_value(serde_json::json!({ "runner": "rnr_1", "credential": "c" })).unwrap(); | |
| 657 | + | assert_eq!(poll.isolation, None, "an older runner's poll"); | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | #[test] | |
| 631 | 661 | fn a_runner_always_has_self_hosted_its_os_and_its_arch() { | |
| 632 | 662 | let labels = runner_labels(&strings(&["GPU", "gpu", " cuda-12 "]), "linux", "x64").unwrap(); | |
| 633 | 663 | assert_eq!(labels, strings(&["self-hosted", "linux", "x64", "gpu", "cuda-12"])); | |
| 113 | 113 | Ok(serde_json::from_value(answer)?) | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | − | pub fn poll(&self, running: &[String], wait_ms: u64) -> std::result::Result<Poll, Failure> { | |
| 117 | − | let body = json!({ "version": VERSION, "running": running, "wait_ms": wait_ms }); | |
| 116 | + | /// Asks for work. `isolation` is how this runner keeps work from its | |
| 117 | + | /// machine now, which g1t keeps up to date from each poll (it gives a | |
| 118 | + | /// host-mode runner no public repository's work); an older g1t ignores it. | |
| 119 | + | pub fn poll(&self, running: &[String], wait_ms: u64, isolation: &str) -> std::result::Result<Poll, Failure> { | |
| 120 | + | let body = poll_body(running, wait_ms, isolation); | |
| 118 | 121 | let answer = post(&format!("{}/runners/{}/poll", self.base, self.runner), Some(&self.credential), &body)?; | |
| 119 | 122 | serde_json::from_value(answer).map_err(|e| Failure::Unreachable(e.to_string())) | |
| 120 | 123 | } | |
| ⋯ | |||
| 149 | 152 | std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes)?; | |
| 150 | 153 | Ok(bytes) | |
| 151 | 154 | } | |
| 155 | + | ||
| 156 | + | fn poll_body(running: &[String], wait_ms: u64, isolation: &str) -> Value { | |
| 157 | + | json!({ "version": VERSION, "running": running, "wait_ms": wait_ms, "isolation": isolation }) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[cfg(test)] | |
| 161 | + | mod tests { | |
| 162 | + | use super::*; | |
| 163 | + | ||
| 164 | + | #[test] | |
| 165 | + | fn a_poll_says_how_the_runner_is_isolated() { | |
| 166 | + | let body = poll_body(&["job_1".to_owned()], 0, "host"); | |
| 167 | + | assert_eq!(body["isolation"], "host"); | |
| 168 | + | assert_eq!(body["running"], json!(["job_1"])); | |
| 169 | + | assert_eq!(body["version"], VERSION); | |
| 170 | + | } | |
| 171 | + | } | |
| 499 | 499 | } | |
| 500 | 500 | let running: Vec<String> = current.iter().map(|r| r.id.clone()).collect(); | |
| 501 | 501 | let wait = if current.is_some() { 0 } else { IDLE_WAIT_MS }; | |
| 502 | − | let poll = match api.poll(&running, wait) { | |
| 502 | + | let poll = match api.poll(&running, wait, isolation.as_str()) { | |
| 503 | 503 | Ok(poll) => { | |
| 504 | 504 | backoff = Duration::from_secs(2); | |
| 505 | 505 | poll |
| 32 | 32 | use crate::plan::JobRow; | |
| 33 | 33 | use crate::{Actions, Count, SILENT_MS, check, fail, optional}; | |
| 34 | 34 | ||
| 35 | + | /// What one poll has learned while it looks for work, so its looks again | |
| 36 | + | /// do not ask again: each repository's visibility, and the jobs it already | |
| 37 | + | /// said wait for a sandboxed runner. | |
| 38 | + | #[derive(Default)] | |
| 39 | + | struct Looked { | |
| 40 | + | visibilities: std::collections::HashMap<String, Option<String>>, | |
| 41 | + | noted: std::collections::HashSet<String>, | |
| 42 | + | } | |
| 43 | + | ||
| 35 | 44 | /// How often a poll looks again for work while it waits. | |
| 36 | 45 | const POLL_EVERY_MS: u64 = 2_500; | |
| 37 | 46 | /// A runner that has been offline this long is removed, as on GitHub. | |
| ⋯ | |||
| 69 | 78 | pub last_seen_at: Option<String>, | |
| 70 | 79 | pub created_at: String, | |
| 71 | 80 | pub created_by: Option<String>, | |
| 81 | + | /// `nested-container`, `container` or `host` (migration 0015); none | |
| 82 | + | /// for a runner that never said. | |
| 83 | + | #[serde(default)] | |
| 84 | + | pub isolation: Option<String>, | |
| 72 | 85 | } | |
| 73 | 86 | ||
| 74 | 87 | impl RunnerRow { | |
| ⋯ | |||
| 953 | 966 | let version: String = if a.version.is_empty() { row.version.clone() } else { a.version.chars().take(40).collect() }; | |
| 954 | 967 | let work_id = active.as_ref().map(|(id, _)| id.as_str()); | |
| 955 | 968 | let work_kind = active.as_ref().map(|(_, kind)| *kind); | |
| 956 | − | let changed = version != row.version || work_id != row.work_id.as_deref() || work_kind != row.work_kind.as_deref(); | |
| 969 | + | // How it is isolated, as it says now: a runner re-registered or | |
| 970 | + | // restarted with or without Docker says so on its next poll. | |
| 971 | + | let isolation = a | |
| 972 | + | .isolation | |
| 973 | + | .as_deref() | |
| 974 | + | .filter(|level| model::ISOLATIONS.contains(level)) | |
| 975 | + | .map(str::to_owned) | |
| 976 | + | .or_else(|| row.isolation.clone()); | |
| 977 | + | let changed = version != row.version | |
| 978 | + | || work_id != row.work_id.as_deref() | |
| 979 | + | || work_kind != row.work_kind.as_deref() | |
| 980 | + | || isolation != row.isolation; | |
| 957 | 981 | if changed || seen_is_stale(row.last_seen_at.as_deref(), at) { | |
| 958 | 982 | self.db | |
| 959 | − | .prepare("UPDATE runners SET last_seen_at = ?, version = ?, work_id = ?, work_kind = ? WHERE id = ?") | |
| 960 | − | .bind(&[seen.as_str().into(), version.into(), optional(work_id), optional(work_kind), row.id.as_str().into()])? | |
| 983 | + | .prepare("UPDATE runners SET last_seen_at = ?, version = ?, work_id = ?, work_kind = ?, isolation = ? WHERE id = ?") | |
| 984 | + | .bind(&[ | |
| 985 | + | seen.as_str().into(), | |
| 986 | + | version.into(), | |
| 987 | + | optional(work_id), | |
| 988 | + | optional(work_kind), | |
| 989 | + | optional(isolation.as_deref()), | |
| 990 | + | row.id.as_str().into(), | |
| 991 | + | ])? | |
| 961 | 992 | .run() | |
| 962 | 993 | .await?; | |
| 963 | 994 | } | |
| 995 | + | let row = RunnerRow { isolation, ..row }; | |
| 964 | 996 | let mut poll = Poll { assignment: None, cancel, credential, removed: false }; | |
| 965 | 997 | // Busy, or an ephemeral runner that has had its one job. | |
| 966 | 998 | if active.is_some() || !a.running.is_empty() { | |
| ⋯ | |||
| 974 | 1006 | let groups = self.group_rows(&row.workspace).await?; | |
| 975 | 1007 | let group = row.group_id.as_ref().and_then(|id| groups.iter().find(|g| &g.id == id)).cloned(); | |
| 976 | 1008 | let deadline = at + a.wait_ms.min(model::MAX_POLL_WAIT_MS); | |
| 1009 | + | let mut looked = Looked::default(); | |
| 977 | 1010 | loop { | |
| 978 | − | if let Some(assignment) = self.claim(&row, group.as_ref()).await? { | |
| 1011 | + | if let Some(assignment) = self.claim(&row, group.as_ref(), &mut looked).await? { | |
| 979 | 1012 | poll.assignment = Some(assignment); | |
| 980 | 1013 | return Ok(Outcome::Ok(poll)); | |
| 981 | 1014 | } | |
| ⋯ | |||
| 987 | 1020 | } | |
| 988 | 1021 | ||
| 989 | 1022 | /// Takes the oldest work this runner can do, if there is any. | |
| 990 | − | async fn claim(&self, runner: &RunnerRow, group: Option<&GroupRow>) -> Result<Option<Assignment>> { | |
| 1023 | + | async fn claim(&self, runner: &RunnerRow, group: Option<&GroupRow>, looked: &mut Looked) -> Result<Option<Assignment>> { | |
| 991 | 1024 | let labels = runner.labels(); | |
| 992 | 1025 | let group_name = group.map(|g| g.name.clone()); | |
| 993 | 1026 | // Workflow jobs. | |
| ⋯ | |||
| 1021 | 1054 | if runner.repo_id.is_none() && !group.is_none_or(|g| g.allows(&run_repo)) { | |
| 1022 | 1055 | continue; | |
| 1023 | 1056 | } | |
| 1057 | + | // A host-mode runner takes no public repository's job: it waits | |
| 1058 | + | // for a sandboxed runner, and says so when none could take it. | |
| 1059 | + | if runner.isolation.as_deref() == Some("host") { | |
| 1060 | + | let visibility = self.visibility_once(looked, &job.repo_id).await; | |
| 1061 | + | if !model::isolation_allows(runner.isolation.as_deref(), visibility.as_deref()) { | |
| 1062 | + | if looked.noted.insert(job.id.clone()) { | |
| 1063 | + | self.wait_for_sandbox(&job, &run_repo, &wanted).await?; | |
| 1064 | + | } | |
| 1065 | + | continue; | |
| 1066 | + | } | |
| 1067 | + | } | |
| 1024 | 1068 | if let Some(max) = job.max_parallel { | |
| 1025 | 1069 | let siblings = self | |
| 1026 | 1070 | .db | |
| ⋯ | |||
| 1103 | 1147 | if !wanted.matches(&labels, None) { | |
| 1104 | 1148 | continue; | |
| 1105 | 1149 | } | |
| 1150 | + | // Nor a public repository's agent work. | |
| 1151 | + | if runner.isolation.as_deref() == Some("host") | |
| 1152 | + | && let Some(repo_id) = &task.repo_id | |
| 1153 | + | { | |
| 1154 | + | let visibility = self.visibility_once(looked, repo_id).await; | |
| 1155 | + | if !model::isolation_allows(runner.isolation.as_deref(), visibility.as_deref()) { | |
| 1156 | + | continue; | |
| 1157 | + | } | |
| 1158 | + | } | |
| 1106 | 1159 | if let Some(repo_id) = &runner.repo_id | |
| 1107 | 1160 | && task.repo_id.as_deref() != Some(repo_id.as_str()) | |
| 1108 | 1161 | { | |
| ⋯ | |||
| 1168 | 1221 | Ok(None) | |
| 1169 | 1222 | } | |
| 1170 | 1223 | ||
| 1224 | + | /// [`Self::visibility`], asked once per repository in a poll. | |
| 1225 | + | async fn visibility_once(&self, looked: &mut Looked, repo_id: &str) -> Option<String> { | |
| 1226 | + | if let Some(known) = looked.visibilities.get(repo_id) { | |
| 1227 | + | return known.clone(); | |
| 1228 | + | } | |
| 1229 | + | let found = self.visibility(repo_id).await; | |
| 1230 | + | looked.visibilities.insert(repo_id.to_owned(), found.clone()); | |
| 1231 | + | found | |
| 1232 | + | } | |
| 1233 | + | ||
| 1234 | + | /// A public repository's job that a host-mode runner passed over: when | |
| 1235 | + | /// no online runner that may take it could (its labels, group, | |
| 1236 | + | /// repository, and isolation), its page says it waits for a sandboxed | |
| 1237 | + | /// one. Written only when that changes what it says. | |
| 1238 | + | async fn wait_for_sandbox(&self, job: &JobRow, run_repo: &str, wanted: &Wanted) -> Result<()> { | |
| 1239 | + | let at = now_ms(); | |
| 1240 | + | let groups = self.group_rows(&job.namespace.to_lowercase()).await?; | |
| 1241 | + | let runners = self.runner_rows(&job.namespace.to_lowercase()).await?; | |
| 1242 | + | let sandboxed = runners.iter().any(|other| takes_public_job(other, &groups, &job.repo_id, run_repo, wanted, at)); | |
| 1243 | + | if sandboxed { | |
| 1244 | + | return Ok(()); | |
| 1245 | + | } | |
| 1246 | + | self.db | |
| 1247 | + | .prepare("UPDATE jobs SET reason = ? WHERE id = ? AND status = 'queued' AND reason IS NOT ?") | |
| 1248 | + | .bind(&[model::WAITING_FOR_SANDBOX.into(), job.id.as_str().into(), model::WAITING_FOR_SANDBOX.into()])? | |
| 1249 | + | .run() | |
| 1250 | + | .await?; | |
| 1251 | + | Ok(()) | |
| 1252 | + | } | |
| 1253 | + | ||
| 1171 | 1254 | /// A repository's visibility for a runner, `public` or `private`; none | |
| 1172 | 1255 | /// when it cannot be read, and the runner decides without it. | |
| 1173 | 1256 | async fn visibility(&self, repo_id: &str) -> Option<String> { | |
| ⋯ | |||
| 1605 | 1688 | id: String, | |
| 1606 | 1689 | } | |
| 1607 | 1690 | ||
| 1691 | + | /// Whether `runner` is online and could take a public repository's job: | |
| 1692 | + | /// not host-mode, and its repository, group and labels let it. | |
| 1693 | + | fn takes_public_job(runner: &RunnerRow, groups: &[GroupRow], repo_id: &str, run_repo: &str, wanted: &Wanted, at: u64) -> bool { | |
| 1694 | + | let group = runner.group_id.as_ref().and_then(|id| groups.iter().find(|g| &g.id == id)); | |
| 1695 | + | runner.online(at) | |
| 1696 | + | && model::isolation_allows(runner.isolation.as_deref(), Some("public")) | |
| 1697 | + | && runner.repo_id.as_deref().is_none_or(|repo| repo == repo_id) | |
| 1698 | + | && (runner.repo_id.is_some() || group.is_none_or(|g| g.allows(run_repo))) | |
| 1699 | + | && wanted.matches(&runner.labels(), group.map(|g| g.name.as_str())) | |
| 1700 | + | } | |
| 1701 | + | ||
| 1608 | 1702 | fn group_view(group: &GroupRow, runners: &[RunnerRow]) -> RunnerGroup { | |
| 1609 | 1703 | RunnerGroup { | |
| 1610 | 1704 | id: group.id.clone(), | |
| ⋯ | |||
| 1661 | 1755 | } | |
| 1662 | 1756 | ||
| 1663 | 1757 | #[test] | |
| 1758 | + | fn only_a_sandboxed_runner_that_could_take_it_serves_a_public_job() { | |
| 1759 | + | let at = 1_000_000_000; | |
| 1760 | + | let runner = |isolation: Option<&str>| RunnerRow { | |
| 1761 | + | id: "rnr_1".into(), | |
| 1762 | + | workspace: "acme".into(), | |
| 1763 | + | repo_id: None, | |
| 1764 | + | repo: None, | |
| 1765 | + | group_id: None, | |
| 1766 | + | name: "a".into(), | |
| 1767 | + | labels: r#"["self-hosted","linux","x64"]"#.into(), | |
| 1768 | + | os: "linux".into(), | |
| 1769 | + | arch: "x64".into(), | |
| 1770 | + | version: String::new(), | |
| 1771 | + | ephemeral: 0, | |
| 1772 | + | credential_hash: String::new(), | |
| 1773 | + | previous_hash: None, | |
| 1774 | + | rotated_at: String::new(), | |
| 1775 | + | work_id: None, | |
| 1776 | + | work_kind: None, | |
| 1777 | + | spent: 0, | |
| 1778 | + | last_seen_at: Some(rfc3339(at)), | |
| 1779 | + | created_at: String::new(), | |
| 1780 | + | created_by: None, | |
| 1781 | + | isolation: isolation.map(str::to_owned), | |
| 1782 | + | }; | |
| 1783 | + | let wanted = Wanted::of(&["self-hosted".to_owned(), "linux".to_owned()], None); | |
| 1784 | + | let takes = |row: &RunnerRow| takes_public_job(row, &[], "rep_1", "acme/web", &wanted, at); | |
| 1785 | + | assert!(!takes(&runner(Some("host")))); | |
| 1786 | + | assert!(takes(&runner(Some("container")))); | |
| 1787 | + | assert!(takes(&runner(Some("nested-container")))); | |
| 1788 | + | assert!(takes(&runner(None)), "an older runner, which refuses for itself"); | |
| 1789 | + | assert!(!takes(&RunnerRow { repo_id: Some("rep_2".into()), ..runner(None) }), "another repository's"); | |
| 1790 | + | assert!(!takes(&RunnerRow { labels: r#"["self-hosted","macos"]"#.into(), ..runner(None) })); | |
| 1791 | + | assert!(!takes(&RunnerRow { last_seen_at: None, ..runner(None) }), "offline"); | |
| 1792 | + | } | |
| 1793 | + | ||
| 1794 | + | #[test] | |
| 1664 | 1795 | fn online_means_seen_in_the_last_ninety_seconds() { | |
| 1665 | 1796 | let row = RunnerRow { | |
| 1666 | 1797 | id: "rnr_1".into(), | |
| ⋯ | |||
| 1683 | 1814 | last_seen_at: Some(rfc3339(1_000_000_000)), | |
| 1684 | 1815 | created_at: String::new(), | |
| 1685 | 1816 | created_by: None, | |
| 1817 | + | isolation: None, | |
| 1686 | 1818 | }; | |
| 1687 | 1819 | assert!(row.online(1_000_000_000 + 60_000)); | |
| 1688 | 1820 | assert!(!row.online(1_000_000_000 + 120_000)); | |