Skip to content
364 linesCodeBlameRaw
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
17use std::collections::BTreeMap;
18
19use serde::{Deserialize, Serialize};
20
21use crate::agents::RunKind;
22use crate::repos::RepoPath;
23use crate::{User, Viewer};
24
25/// The longest any run may be allowed to take, in minutes.
26pub 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.
30pub const DEFAULT_RUN_CAP_MICROS: i64 = 2_000_000;
31
32/// How long each kind of run may take unless the workspace says otherwise.
33pub 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)]
52pub 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")]
65pub 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
78impl 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.
122pub 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.
152pub 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")]
174pub 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
185impl 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)]
202pub 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)]
213pub 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)]
229pub 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")]
238pub 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
248impl 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)]
263mod 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}