| 1 | //! Guardrails: how long a workspace's runs may take. Kept by the work |
| 2 | //! service. |
| 3 | //! |
| 4 | //! A workspace sets defaults and each project may override them: a time |
| 5 | //! cap for each kind of run, after which g1t stops it. Sandboxes reach the |
| 6 | //! internet as any CI machine does; guardrails say nothing about the |
| 7 | //! network. |
| 8 | //! |
| 9 | //! Levels saved when guardrails also had network rules (restricting the |
| 10 | //! network, package registries, allowed domains and workflow-only |
| 11 | //! domains), command rules, deny patterns and a cost cap still read: those |
| 12 | //! fields are ignored, and dropped when the level is saved again. |
| 13 | //! |
| 14 | //! Each `*Args` struct is the argument of the method of the same name, |
| 15 | //! served at `POST /rpc/<method>`. |
| 16 | |
| 17 | use std::collections::BTreeMap; |
| 18 | |
| 19 | use serde::{Deserialize, Serialize}; |
| 20 | |
| 21 | use crate::agents::RunKind; |
| 22 | use crate::repos::RepoPath; |
| 23 | use crate::{User, Viewer}; |
| 24 | |
| 25 | /// The longest any run may be allowed to take, in minutes. |
| 26 | pub const MAX_MINUTES: u32 = 240; |
| 27 | |
| 28 | /// One run's spend cap unless an owner or g1t staff set another, in |
| 29 | /// micro-dollars: billing's spend cap per run. |
| 30 | pub const DEFAULT_RUN_CAP_MICROS: i64 = 2_000_000; |
| 31 | |
| 32 | /// How long each kind of run may take unless the workspace says otherwise. |
| 33 | pub fn default_minutes(kind: RunKind) -> u32 { |
| 34 | match kind { |
| 35 | RunKind::Implement => 90, |
| 36 | RunKind::Revise => 60, |
| 37 | RunKind::Review => 30, |
| 38 | RunKind::Answer => 20, |
| 39 | RunKind::Update => 45, |
| 40 | RunKind::Plan => 30, |
| 41 | RunKind::Checks => 45, |
| 42 | RunKind::Queue => 45, |
| 43 | RunKind::Mergecheck => 10, |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | /// What one level, the workspace or a project, sets. Anything left unset |
| 48 | /// is inherited: a project from its workspace, a workspace from g1t's |
| 49 | /// defaults. |
| 50 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] |
| 51 | #[serde(rename_all = "camelCase", default)] |
| 52 | pub struct GuardrailSettings { |
| 53 | /// How long a run may take, in minutes, by kind of run. |
| 54 | pub minutes: BTreeMap<String, u32>, |
| 55 | /// Username of whoever last changed this level. |
| 56 | pub updated_by: Option<String>, |
| 57 | /// RFC 3339. |
| 58 | pub updated_at: Option<String>, |
| 59 | } |
| 60 | |
| 61 | /// The guardrails a run actually gets: g1t's defaults, then the |
| 62 | /// workspace's, then the project's. |
| 63 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 64 | #[serde(rename_all = "camelCase")] |
| 65 | pub struct Guardrails { |
| 66 | /// Every kind of run. |
| 67 | pub minutes: BTreeMap<String, u32>, |
| 68 | /// Always false, and `hosts` always empty: sent for a runner deployed |
| 69 | /// before network rules were removed, which reads them to start its |
| 70 | /// sandboxes with an open network rather than failing to start them. |
| 71 | /// Never read. Remove once the runner after this change is deployed. |
| 72 | #[serde(skip_deserializing)] |
| 73 | pub restrict_network: bool, |
| 74 | #[serde(skip_deserializing)] |
| 75 | pub hosts: Vec<String>, |
| 76 | } |
| 77 | |
| 78 | impl Guardrails { |
| 79 | /// g1t's defaults: a time cap for each kind of run. |
| 80 | pub fn defaults() -> Self { |
| 81 | Guardrails { |
| 82 | minutes: RunKind::ALL |
| 83 | .into_iter() |
| 84 | .map(|kind| (kind.as_str().to_owned(), default_minutes(kind))) |
| 85 | .collect(), |
| 86 | restrict_network: false, |
| 87 | hosts: Vec::new(), |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | /// One level laid over what it inherits. |
| 92 | pub fn apply(mut self, level: &GuardrailSettings) -> Self { |
| 93 | for (kind, minutes) in &level.minutes { |
| 94 | if let Some(cap) = self.minutes.get_mut(kind) { |
| 95 | *cap = *minutes; |
| 96 | } |
| 97 | } |
| 98 | self |
| 99 | } |
| 100 | |
| 101 | /// The workspace's defaults with a project's overrides on top. |
| 102 | pub fn merge(workspace: &GuardrailSettings, project: Option<&GuardrailSettings>) -> Self { |
| 103 | let inherited = Guardrails::defaults().apply(workspace); |
| 104 | match project { |
| 105 | Some(project) => inherited.apply(project), |
| 106 | None => inherited, |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// The time cap of a kind of run, in minutes. |
| 111 | pub fn minutes_for(&self, kind: RunKind) -> u32 { |
| 112 | self.minutes |
| 113 | .get(kind.as_str()) |
| 114 | .copied() |
| 115 | .unwrap_or_else(|| default_minutes(kind)) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | /// A host name as it is kept: lower case, no scheme, path or port, |
| 120 | /// optionally `*.` for its subdomains. Refused if it is not a host name. |
| 121 | /// The hosts a brokered secret is for are kept this way. |
| 122 | pub fn normalize_domain(input: &str) -> Result<String, String> { |
| 123 | let mut domain = input.trim().to_lowercase(); |
| 124 | for scheme in ["https://", "http://"] { |
| 125 | if let Some(rest) = domain.strip_prefix(scheme) { |
| 126 | domain = rest.to_owned(); |
| 127 | } |
| 128 | } |
| 129 | if let Some(at) = domain.find(['/', ':']) { |
| 130 | domain.truncate(at); |
| 131 | } |
| 132 | let domain = domain.trim_end_matches('.').to_owned(); |
| 133 | let bare = domain.strip_prefix("*.").unwrap_or(&domain); |
| 134 | let labels: Vec<&str> = bare.split('.').collect(); |
| 135 | let valid = labels.len() >= 2 |
| 136 | && bare.len() <= 253 |
| 137 | && labels.iter().all(|label| { |
| 138 | !label.is_empty() |
| 139 | && label.len() <= 63 |
| 140 | && !label.starts_with('-') |
| 141 | && !label.ends_with('-') |
| 142 | && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') |
| 143 | }); |
| 144 | if valid { |
| 145 | Ok(domain) |
| 146 | } else { |
| 147 | Err(format!("{} is not a domain. Use a host name such as example.com, or *.example.com for its subdomains.", input.trim())) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | /// One level's settings, checked and tidied before they are kept. |
| 152 | pub fn validate(settings: GuardrailSettings) -> Result<GuardrailSettings, String> { |
| 153 | let mut minutes = BTreeMap::new(); |
| 154 | for (kind, cap) in settings.minutes { |
| 155 | if RunKind::parse(&kind).is_none() { |
| 156 | return Err(format!("{kind} is not a kind of run.")); |
| 157 | } |
| 158 | if !(1..=MAX_MINUTES).contains(&cap) { |
| 159 | return Err(format!("A run's time cap is between 1 and {MAX_MINUTES} minutes.")); |
| 160 | } |
| 161 | minutes.insert(kind, cap); |
| 162 | } |
| 163 | Ok(GuardrailSettings { |
| 164 | minutes, |
| 165 | updated_by: settings.updated_by, |
| 166 | updated_at: settings.updated_at, |
| 167 | }) |
| 168 | } |
| 169 | |
| 170 | /// Everything the settings pages show: each level as it was set, what |
| 171 | /// each inherits, and what is in force. |
| 172 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 173 | #[serde(rename_all = "camelCase")] |
| 174 | pub struct GuardrailsView { |
| 175 | pub workspace: GuardrailSettings, |
| 176 | /// None when no project was asked about. |
| 177 | pub project: Option<GuardrailSettings>, |
| 178 | pub defaults: Guardrails, |
| 179 | /// g1t's defaults with the workspace's: what a project inherits. |
| 180 | pub inherited: Guardrails, |
| 181 | /// What runs get: the project's, or with no project, the workspace's. |
| 182 | pub effective: Guardrails, |
| 183 | } |
| 184 | |
| 185 | impl GuardrailsView { |
| 186 | pub fn new(workspace: GuardrailSettings, project: Option<GuardrailSettings>) -> Self { |
| 187 | let inherited = Guardrails::merge(&workspace, None); |
| 188 | let effective = Guardrails::merge(&workspace, project.as_ref()); |
| 189 | GuardrailsView { |
| 190 | workspace, |
| 191 | project, |
| 192 | defaults: Guardrails::defaults(), |
| 193 | inherited, |
| 194 | effective, |
| 195 | } |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | /// `get_guardrails`: a workspace's guardrails, and with `repo`, that |
| 200 | /// project's too. Members only. Returns `Outcome<GuardrailsView>`. |
| 201 | #[derive(Debug, Serialize, Deserialize)] |
| 202 | pub struct GetGuardrailsArgs { |
| 203 | pub viewer: Viewer, |
| 204 | pub workspace: String, |
| 205 | #[serde(default)] |
| 206 | pub repo: Option<RepoPath>, |
| 207 | } |
| 208 | |
| 209 | /// `update_guardrails`: replaces one level's settings: the workspace's |
| 210 | /// (owners only) or, with `repo`, that project's (members). Returns |
| 211 | /// `Outcome<GuardrailsView>`. |
| 212 | #[derive(Debug, Serialize, Deserialize)] |
| 213 | pub struct UpdateGuardrailsArgs { |
| 214 | pub actor: User, |
| 215 | pub workspace: String, |
| 216 | #[serde(default)] |
| 217 | pub repo: Option<RepoPath>, |
| 218 | pub settings: GuardrailSettings, |
| 219 | } |
| 220 | |
| 221 | /// `run_guardrails`: what a run in `repo` gets. For the runner service, |
| 222 | /// which is trusted. Returns `Outcome<Guardrails>`. |
| 223 | /// |
| 224 | /// A run's guardrails are always its project's: `repo_id`, when given, |
| 225 | /// names that repository however it has moved since, and a pull |
| 226 | /// request's working copy (`pulls/<pull id>`) stands for the repository |
| 227 | /// the pull request is to. |
| 228 | #[derive(Debug, Serialize, Deserialize)] |
| 229 | pub struct RunGuardrailsArgs { |
| 230 | pub repo: RepoPath, |
| 231 | #[serde(default)] |
| 232 | pub repo_id: Option<String>, |
| 233 | } |
| 234 | |
| 235 | /// Why g1t stopped a run by itself. |
| 236 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 237 | #[serde(rename_all = "snake_case")] |
| 238 | pub enum Halt { |
| 239 | /// It reached its cost cap. |
| 240 | Budget, |
| 241 | /// It reached its time cap. |
| 242 | Time, |
| 243 | /// Its sandbox looked like it was mining cryptocurrency: CPU pinned for |
| 244 | /// a long time with little I/O and no progress. Held for review. |
| 245 | Abuse, |
| 246 | } |
| 247 | |
| 248 | impl Halt { |
| 249 | pub fn as_str(self) -> &'static str { |
| 250 | match self { |
| 251 | Halt::Budget => "budget", |
| 252 | Halt::Time => "time", |
| 253 | Halt::Abuse => "abuse", |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | pub fn parse(value: &str) -> Option<Halt> { |
| 258 | [Halt::Budget, Halt::Time, Halt::Abuse].into_iter().find(|halt| halt.as_str() == value) |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | #[cfg(test)] |
| 263 | mod tests { |
| 264 | use super::*; |
| 265 | |
| 266 | fn level() -> GuardrailSettings { |
| 267 | GuardrailSettings::default() |
| 268 | } |
| 269 | |
| 270 | #[test] |
| 271 | fn defaults_cap_every_kind_of_run() { |
| 272 | let defaults = Guardrails::defaults(); |
| 273 | assert_eq!(defaults.minutes_for(RunKind::Implement), 90); |
| 274 | assert_eq!(defaults.minutes.len(), RunKind::ALL.len()); |
| 275 | } |
| 276 | |
| 277 | #[test] |
| 278 | fn nothing_set_inherits_everything() { |
| 279 | assert_eq!(Guardrails::merge(&level(), Some(&level())), Guardrails::defaults()); |
| 280 | } |
| 281 | |
| 282 | #[test] |
| 283 | fn a_project_overrides_its_workspace() { |
| 284 | let workspace = GuardrailSettings { |
| 285 | minutes: BTreeMap::from([("implement".to_owned(), 30), ("review".to_owned(), 20)]), |
| 286 | ..level() |
| 287 | }; |
| 288 | let project = GuardrailSettings { |
| 289 | minutes: BTreeMap::from([("review".to_owned(), 15)]), |
| 290 | ..level() |
| 291 | }; |
| 292 | let inherited = Guardrails::merge(&workspace, None); |
| 293 | assert_eq!(inherited.minutes_for(RunKind::Review), 20); |
| 294 | let effective = Guardrails::merge(&workspace, Some(&project)); |
| 295 | // Inherited where the project says nothing. |
| 296 | assert_eq!(effective.minutes_for(RunKind::Implement), 30); |
| 297 | assert_eq!(effective.minutes_for(RunKind::Review), 15); |
| 298 | } |
| 299 | |
| 300 | #[test] |
| 301 | fn levels_saved_with_network_rules_still_read_and_drop_them() { |
| 302 | // Network rules, command rules, deny patterns and a cost cap, from |
| 303 | // before, are ignored, and gone once the level is saved again. |
| 304 | let old: GuardrailSettings = serde_json::from_str( |
| 305 | r#"{"restrictNetwork":true,"registries":["npm"],"domains":["example.com"], |
| 306 | "workflowDomains":[{"domain":"api.cloudflare.com","workflows":["deploy.yml"],"environments":[]}], |
| 307 | "rules":{"sudo":false},"deny":["Bash(rm:*)"],"budgetUsd":2.5,"minutes":{"implement":45}}"#, |
| 308 | ) |
| 309 | .unwrap(); |
| 310 | assert_eq!(old.minutes, BTreeMap::from([("implement".to_owned(), 45)])); |
| 311 | let saved = serde_json::to_value(validate(old).unwrap()).unwrap(); |
| 312 | assert_eq!(saved, serde_json::json!({ "minutes": { "implement": 45 }, "updatedBy": null, "updatedAt": null })); |
| 313 | // What a run got before reads as its time caps. |
| 314 | let old: Guardrails = serde_json::from_value(serde_json::json!({ |
| 315 | "restrictNetwork": true, "registries": [], "domains": [], "hosts": ["g1t.sh"], "workflowDomains": [], "minutes": { "implement": 90 } |
| 316 | })) |
| 317 | .unwrap(); |
| 318 | assert_eq!(old.minutes_for(RunKind::Implement), 90); |
| 319 | assert!(!old.restrict_network && old.hosts.is_empty()); |
| 320 | } |
| 321 | |
| 322 | #[test] |
| 323 | fn a_runner_from_before_reads_an_open_network() { |
| 324 | let sent = serde_json::to_value(Guardrails::defaults()).unwrap(); |
| 325 | assert_eq!(sent["restrictNetwork"], false); |
| 326 | assert_eq!(sent["hosts"], serde_json::json!([])); |
| 327 | } |
| 328 | |
| 329 | #[test] |
| 330 | fn domains_are_tidied_or_refused() { |
| 331 | assert_eq!(normalize_domain(" HTTPS://Api.Stripe.com/v1 ").unwrap(), "api.stripe.com"); |
| 332 | assert_eq!(normalize_domain("*.example.com").unwrap(), "*.example.com"); |
| 333 | assert_eq!(normalize_domain("example.com:8443").unwrap(), "example.com"); |
| 334 | assert!(normalize_domain("localhost").is_err()); |
| 335 | assert!(normalize_domain("*.*.com").is_err()); |
| 336 | assert!(normalize_domain("exa mple.com").is_err()); |
| 337 | assert!(normalize_domain("*").is_err()); |
| 338 | } |
| 339 | |
| 340 | #[test] |
| 341 | fn validation_refuses_bad_caps() { |
| 342 | assert!( |
| 343 | validate(GuardrailSettings { |
| 344 | minutes: BTreeMap::from([("implement".to_owned(), 0)]), |
| 345 | ..level() |
| 346 | }) |
| 347 | .is_err() |
| 348 | ); |
| 349 | assert!( |
| 350 | validate(GuardrailSettings { |
| 351 | minutes: BTreeMap::from([("lunch".to_owned(), 10)]), |
| 352 | ..level() |
| 353 | }) |
| 354 | .is_err() |
| 355 | ); |
| 356 | assert!( |
| 357 | validate(GuardrailSettings { |
| 358 | minutes: BTreeMap::from([("implement".to_owned(), MAX_MINUTES)]), |
| 359 | ..level() |
| 360 | }) |
| 361 | .is_ok() |
| 362 | ); |
| 363 | } |
| 364 | } |