| 1 | //! What the runner is doing, and when it is not running or is failing, why |
| 2 | //! and how to fix it: the one catalogue of those words. The tray shows a |
| 3 | //! diagnosis's title, the settings window all of it with its buttons, and |
| 4 | //! the site shows it as the app sends it, word for word, on its Runners |
| 5 | //! page (and keeps it, so a person's other computers are listed with |
| 6 | //! theirs). The site adds nothing of its own: what it says of a computer |
| 7 | //! whose g1t is closed is `closed` here, sent with every report. |
| 8 | //! |
| 9 | //! Each action is an id the settings window and the site turn into a |
| 10 | //! button; bridge.rs and settings.js do what it says. |
| 11 | |
| 12 | use serde::Serialize; |
| 13 | |
| 14 | /// Every state the runner can be in. |
| 15 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] |
| 16 | #[serde(rename_all = "snake_case")] |
| 17 | pub enum Code { |
| 18 | /// Running jobs is turned off in settings. |
| 19 | #[default] |
| 20 | Off, |
| 21 | /// The person pressed Stop. |
| 22 | Stopped, |
| 23 | Starting, |
| 24 | /// Waiting for the site to make a registration token. |
| 25 | Registering, |
| 26 | /// Nobody is signed in to g1t in the app. |
| 27 | SignedOut, |
| 28 | /// Signed in, owning no workspace: nowhere to register. |
| 29 | NoWorkspace, |
| 30 | /// The person may not add runners to the workspace. |
| 31 | NotAllowed, |
| 32 | RegisterFailed, |
| 33 | Idle, |
| 34 | Busy, |
| 35 | NoDocker, |
| 36 | NoSandbox, |
| 37 | NoSysbox, |
| 38 | Updating, |
| 39 | UpdateFailed, |
| 40 | Offline, |
| 41 | Crashed, |
| 42 | } |
| 43 | |
| 44 | /// How a state should look: a dot's colour. |
| 45 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] |
| 46 | #[serde(rename_all = "snake_case")] |
| 47 | pub enum Tone { |
| 48 | Ok, |
| 49 | Working, |
| 50 | Off, |
| 51 | Warning, |
| 52 | Problem, |
| 53 | } |
| 54 | |
| 55 | /// A one-click fix. |
| 56 | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] |
| 57 | pub struct Action { |
| 58 | pub id: &'static str, |
| 59 | pub label: &'static str, |
| 60 | } |
| 61 | |
| 62 | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] |
| 63 | pub struct Diagnosis { |
| 64 | pub code: Code, |
| 65 | pub tone: Tone, |
| 66 | /// One line: the tray's, and the heading everywhere else. |
| 67 | pub title: String, |
| 68 | /// Why, in a sentence. |
| 69 | pub cause: Option<String>, |
| 70 | /// What to do about it. |
| 71 | pub fix: Option<String>, |
| 72 | pub actions: Vec<Action>, |
| 73 | } |
| 74 | |
| 75 | /// What the words are filled in with. |
| 76 | #[derive(Clone, Debug, Default)] |
| 77 | pub struct Facts { |
| 78 | pub workspace: Option<String>, |
| 79 | /// How jobs run, as `sandboxed`, `in Docker`, `on this computer`. |
| 80 | pub backend: &'static str, |
| 81 | pub machine: String, |
| 82 | pub job: Option<String>, |
| 83 | pub detail: Option<String>, |
| 84 | pub exit_code: Option<i32>, |
| 85 | pub wait_seconds: Option<u64>, |
| 86 | /// The server's host: `g1t.sh`. |
| 87 | pub server: String, |
| 88 | pub platform: String, |
| 89 | } |
| 90 | |
| 91 | const START: Action = Action { id: "start", label: "Start" }; |
| 92 | const TURN_ON: Action = Action { id: "start", label: "Turn on" }; |
| 93 | const RETRY: Action = Action { id: "retry", label: "Retry" }; |
| 94 | const RESTART: Action = Action { id: "restart", label: "Restart" }; |
| 95 | const SIGN_IN: Action = Action { id: "sign_in", label: "Sign in" }; |
| 96 | const CHOOSE_WORKSPACE: Action = Action { id: "choose_workspace", label: "Choose workspace" }; |
| 97 | const NEW_WORKSPACE: Action = Action { id: "new_workspace", label: "Create a workspace" }; |
| 98 | const CHOOSE_BACKEND: Action = Action { id: "choose_backend", label: "Choose how jobs run" }; |
| 99 | const OPEN_DOCKER: Action = Action { id: "open_docker", label: "Open Docker" }; |
| 100 | const GET_DOCKER: Action = Action { id: "get_docker", label: "Get Docker" }; |
| 101 | const INSTALL_SYSBOX: Action = Action { id: "install_sysbox", label: "Install Sysbox" }; |
| 102 | const LEARN_MORE: Action = Action { id: "learn_more", label: "Learn more" }; |
| 103 | const VIEW_LOG: Action = Action { id: "view_log", label: "View log" }; |
| 104 | const REPORT: Action = Action { id: "report", label: "Report" }; |
| 105 | const VIEW_RUNNERS: Action = Action { id: "view_runners", label: "View on g1t" }; |
| 106 | |
| 107 | fn some(text: impl Into<String>) -> Option<String> { |
| 108 | Some(text.into()) |
| 109 | } |
| 110 | |
| 111 | /// The catalogue. |
| 112 | pub fn diagnose(code: Code, facts: &Facts) -> Diagnosis { |
| 113 | let workspace = facts.workspace.as_deref().unwrap_or("your workspace"); |
| 114 | let detail = facts.detail.clone(); |
| 115 | let (tone, title, cause, fix, actions): (Tone, String, Option<String>, Option<String>, Vec<Action>) = match code { |
| 116 | Code::Off => ( |
| 117 | Tone::Off, |
| 118 | "Not running: turned off".into(), |
| 119 | some("Running jobs on this computer is turned off in g1t's settings."), |
| 120 | some("Turn it on to run your workspace's self-hosted jobs here while g1t is open."), |
| 121 | vec![TURN_ON], |
| 122 | ), |
| 123 | Code::Stopped => ( |
| 124 | Tone::Off, |
| 125 | "Not running: stopped".into(), |
| 126 | some("You stopped the runner. It starts again when you press Start, or when g1t next opens."), |
| 127 | None, |
| 128 | vec![START], |
| 129 | ), |
| 130 | Code::Starting => (Tone::Working, "Starting".into(), detail, None, vec![]), |
| 131 | Code::Registering => ( |
| 132 | Tone::Working, |
| 133 | format!("Registering with {workspace}"), |
| 134 | some(format!("g1t is registering {} as a runner for {workspace}.", facts.machine)), |
| 135 | some("Keep g1t open and signed in; it takes a few seconds."), |
| 136 | vec![], |
| 137 | ), |
| 138 | Code::SignedOut => ( |
| 139 | Tone::Warning, |
| 140 | "Not running: signed out".into(), |
| 141 | some("This computer isn't registered yet, and registering it needs you signed in to g1t."), |
| 142 | some("Sign in, as an owner of the workspace the runner is for."), |
| 143 | vec![SIGN_IN], |
| 144 | ), |
| 145 | Code::NoWorkspace => ( |
| 146 | Tone::Warning, |
| 147 | "Not running: no workspace to run jobs for".into(), |
| 148 | some("Only a workspace's owners add runners to it, and you don't own one."), |
| 149 | some("Ask an owner of your workspace to run it, or create a workspace of your own."), |
| 150 | vec![NEW_WORKSPACE], |
| 151 | ), |
| 152 | Code::NotAllowed => ( |
| 153 | Tone::Problem, |
| 154 | format!("Couldn't register with {workspace}"), |
| 155 | some(format!("You can't add runners to {workspace}: only its owners can.")), |
| 156 | some(format!("Ask an owner of {workspace} to make you one, or choose a workspace you own.")), |
| 157 | vec![CHOOSE_WORKSPACE], |
| 158 | ), |
| 159 | Code::RegisterFailed => ( |
| 160 | Tone::Problem, |
| 161 | format!("Couldn't register with {workspace}"), |
| 162 | detail.or_else(|| some("g1t didn't make a registration for this computer.")), |
| 163 | some("It tries again by itself in a few minutes."), |
| 164 | vec![RETRY, VIEW_LOG], |
| 165 | ), |
| 166 | Code::Idle => ( |
| 167 | Tone::Ok, |
| 168 | "Waiting for jobs".into(), |
| 169 | some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)), |
| 170 | None, |
| 171 | vec![VIEW_RUNNERS], |
| 172 | ), |
| 173 | Code::Busy => ( |
| 174 | Tone::Working, |
| 175 | format!("Running {}", facts.job.as_deref().unwrap_or("a job")), |
| 176 | some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)), |
| 177 | None, |
| 178 | vec![VIEW_RUNNERS], |
| 179 | ), |
| 180 | Code::NoDocker => { |
| 181 | let app = match facts.platform.as_str() { |
| 182 | "windows" | "macos" => "Docker Desktop", |
| 183 | _ => "Docker", |
| 184 | }; |
| 185 | ( |
| 186 | Tone::Problem, |
| 187 | "Not running: Docker isn't running".into(), |
| 188 | some(format!("Running jobs {} needs Docker, and Docker isn't running on this computer, or isn't installed.", facts.backend)), |
| 189 | some(format!("Start {app}, or install it. The runner starts by itself once Docker answers.")), |
| 190 | if app == "Docker Desktop" { vec![OPEN_DOCKER, GET_DOCKER] } else { vec![GET_DOCKER] }, |
| 191 | ) |
| 192 | } |
| 193 | Code::NoSandbox => ( |
| 194 | Tone::Warning, |
| 195 | "Not running: a sandbox isn't available on this computer yet".into(), |
| 196 | some("The sandbox keeps everything a job does away from this computer. It isn't built for this system yet."), |
| 197 | some("Wait for it, or choose to run jobs in Docker or directly on this computer. Neither is a sandbox: settings say what each can reach."), |
| 198 | vec![LEARN_MORE, CHOOSE_BACKEND], |
| 199 | ), |
| 200 | Code::NoSysbox => ( |
| 201 | Tone::Problem, |
| 202 | "Not running: Sysbox isn't installed".into(), |
| 203 | some("The sandbox on Linux is the runner's image under Sysbox, and this computer's Docker doesn't have Sysbox."), |
| 204 | some("Install Sysbox; the runner starts by itself once Docker has it."), |
| 205 | vec![INSTALL_SYSBOX, CHOOSE_BACKEND], |
| 206 | ), |
| 207 | Code::Updating => (Tone::Working, "Updating".into(), detail, some("The runner starts again as soon as it's done."), vec![]), |
| 208 | Code::UpdateFailed => ( |
| 209 | Tone::Warning, |
| 210 | "Update failed".into(), |
| 211 | detail, |
| 212 | some("The runner keeps running the version it has. Try again, or report it if it keeps happening."), |
| 213 | vec![RETRY, REPORT], |
| 214 | ), |
| 215 | Code::Offline => ( |
| 216 | Tone::Warning, |
| 217 | format!("Offline: {} unreachable", facts.server), |
| 218 | some(format!("This computer can't reach {}, so the runner can't take jobs.", facts.server)), |
| 219 | some("Check the connection. The runner carries on by itself when it's back."), |
| 220 | vec![RETRY], |
| 221 | ), |
| 222 | Code::Crashed => ( |
| 223 | Tone::Problem, |
| 224 | match facts.exit_code { |
| 225 | Some(exit) => format!("Runner stopped (exit code {exit})"), |
| 226 | None => "Runner stopped".into(), |
| 227 | }, |
| 228 | detail, |
| 229 | Some(match facts.wait_seconds { |
| 230 | Some(wait) => format!("It starts again by itself in {wait} seconds. Restart now, or look at its log."), |
| 231 | None => "Restart it, or look at its log.".into(), |
| 232 | }), |
| 233 | vec![RESTART, VIEW_LOG], |
| 234 | ), |
| 235 | }; |
| 236 | Diagnosis { code, tone, title, cause, fix, actions } |
| 237 | } |
| 238 | |
| 239 | /// What the site says of this computer while g1t is closed on it. |
| 240 | pub fn closed(facts: &Facts) -> Diagnosis { |
| 241 | Diagnosis { |
| 242 | code: Code::Off, |
| 243 | tone: Tone::Off, |
| 244 | title: format!("g1t is closed on {}", facts.machine), |
| 245 | cause: some(format!("This runner runs while g1t is open on {}.", facts.machine)), |
| 246 | fix: some(format!("Open g1t on {} to run jobs there again.", facts.machine)), |
| 247 | actions: vec![], |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | #[cfg(test)] |
| 252 | mod tests { |
| 253 | use super::*; |
| 254 | |
| 255 | fn facts() -> Facts { |
| 256 | Facts { workspace: Some("acme".into()), backend: "sandboxed", machine: "build-box".into(), server: "g1t.sh".into(), platform: "windows".into(), ..Facts::default() } |
| 257 | } |
| 258 | |
| 259 | #[test] |
| 260 | fn every_failure_says_why_and_how_to_fix_it() { |
| 261 | for code in [Code::SignedOut, Code::NoWorkspace, Code::NotAllowed, Code::RegisterFailed, Code::NoDocker, Code::NoSandbox, Code::NoSysbox, Code::UpdateFailed, Code::Offline, Code::Crashed] { |
| 262 | let diagnosis = diagnose(code, &Facts { detail: Some("why".into()), ..facts() }); |
| 263 | assert!(diagnosis.cause.is_some(), "{code:?} has no cause"); |
| 264 | assert!(diagnosis.fix.is_some(), "{code:?} has no fix"); |
| 265 | assert!(!diagnosis.actions.is_empty(), "{code:?} has no action"); |
| 266 | assert!(matches!(diagnosis.tone, Tone::Warning | Tone::Problem), "{code:?}"); |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | #[test] |
| 271 | fn the_words_name_the_workspace_the_machine_and_the_job() { |
| 272 | let not_allowed = diagnose(Code::NotAllowed, &facts()); |
| 273 | assert_eq!(not_allowed.cause.as_deref(), Some("You can't add runners to acme: only its owners can.")); |
| 274 | assert_eq!(not_allowed.actions[0].id, "choose_workspace"); |
| 275 | let busy = diagnose(Code::Busy, &Facts { job: Some("Build (acme/web)".into()), ..facts() }); |
| 276 | assert_eq!(busy.title, "Running Build (acme/web)"); |
| 277 | assert_eq!(busy.cause.as_deref(), Some("Running sandboxed for acme on build-box.")); |
| 278 | let crashed = diagnose(Code::Crashed, &Facts { exit_code: Some(101), wait_seconds: Some(30), ..facts() }); |
| 279 | assert_eq!(crashed.title, "Runner stopped (exit code 101)"); |
| 280 | assert_eq!(crashed.actions.iter().map(|a| a.id).collect::<Vec<_>>(), ["restart", "view_log"]); |
| 281 | assert_eq!(diagnose(Code::NoDocker, &facts()).actions[0].id, "open_docker"); |
| 282 | assert_eq!(closed(&facts()).title, "g1t is closed on build-box"); |
| 283 | } |
| 284 | } |