atagen

roundabout

public
Star0
Watch0
ssh://brewery@pls.lobotomise.me:33/atagen/roundabout.git
src/config.rsRust451 lines12.2 KB
1
use std::{collections::BTreeMap, fs, path::PathBuf};
2
3
use serde::Deserialize;
4
5
use crate::{
6
    paths,
7
    units::{Amount, Bytes, Dur},
8
};
9
10
#[derive(Debug, Deserialize)]
11
#[serde(default, deny_unknown_fields)]
12
pub struct Config {
13
    pub budget: Budget,
14
    pub heavy: Heavy,
15
    pub sizes: BTreeMap<String, Preset>,
16
    pub queue: Queue,
17
    pub overrun: Overrun,
18
    pub enforce: Enforce,
19
    pub artifacts: Artifacts,
20
    pub nix: Nix,
21
    pub detect: Detect,
22
}
23
24
#[derive(Debug, Deserialize)]
25
#[serde(default, deny_unknown_fields)]
26
pub struct Budget {
27
    pub ram: Amount<Bytes>,
28
    pub ram_reserve: Bytes,
29
    pub cores: Amount<u32>,
30
    pub disk_reserve: Bytes,
31
    /// every lease's disk estimate is charged to all of these as well as its
32
    /// cwd. pessimistic when they share a fs, which is the point
33
    pub disk_paths: Vec<PathBuf>,
34
    /// memory psi `some avg10`. only gates heavies
35
    pub psi_max: Option<f64>,
36
}
37
38
#[derive(Debug, Deserialize)]
39
#[serde(default, deny_unknown_fields)]
40
pub struct Heavy {
41
    pub ram: Bytes,
42
    pub cores: Amount<u32>,
43
    pub disk: Bytes,
44
}
45
46
#[derive(Clone, Copy, Debug, Deserialize)]
47
#[serde(deny_unknown_fields)]
48
pub struct Preset {
49
    pub ram: Bytes,
50
    pub disk: Bytes,
51
    pub cores: Amount<u32>,
52
    pub time: Dur,
53
}
54
55
#[derive(Debug, Deserialize)]
56
#[serde(default, deny_unknown_fields)]
57
pub struct Queue {
58
    pub linger: Dur,
59
    pub backfill_max_time: Dur,
60
    pub max_wait: Option<Dur>,
61
    pub elastic: bool,
62
    pub intent_ttl: Dur,
63
}
64
65
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
66
#[serde(rename_all = "lowercase")]
67
pub enum RamOverrun {
68
    Warn,
69
    Throttle,
70
}
71
72
#[derive(Debug, Deserialize)]
73
#[serde(default, deny_unknown_fields)]
74
pub struct Overrun {
75
    pub ram: RamOverrun,
76
    /// multiple of the ram estimate
77
    pub ram_slack: f64,
78
    /// multiple of the time estimate
79
    pub time_grace: f64,
80
    pub remind_every: Dur,
81
}
82
83
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
84
#[serde(rename_all = "lowercase")]
85
pub enum Unwrapped {
86
    Deny,
87
    Wrap,
88
    Allow,
89
}
90
91
#[derive(Debug, Deserialize)]
92
#[serde(default, deny_unknown_fields)]
93
pub struct Enforce {
94
    pub unwrapped: Unwrapped,
95
    pub default_size: String,
96
}
97
98
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
99
#[serde(rename_all = "lowercase")]
100
pub enum OverCap {
101
    Warn,
102
    Block,
103
}
104
105
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
106
#[serde(rename_all = "lowercase")]
107
pub enum StopPolicy {
108
    Block,
109
    Nudge,
110
    Warn,
111
    Off,
112
}
113
114
#[derive(Clone, Debug, Deserialize)]
115
#[serde(deny_unknown_fields)]
116
pub struct Rule {
117
    /// trailing `*` for a prefix match, that's all the glob you get
118
    pub name: String,
119
    /// sibling that must exist, so we don't go deleting someone's `build/`
120
    /// because it had a suggestive name
121
    pub marker: Option<String>,
122
    pub clean: Option<Vec<String>>,
123
}
124
125
#[derive(Debug, Deserialize)]
126
#[serde(default, deny_unknown_fields)]
127
pub struct Artifacts {
128
    pub project_cap: Bytes,
129
    pub global_cap: Bytes,
130
    pub idle_after: Dur,
131
    pub size_ttl: Dur,
132
    pub over_cap: OverCap,
133
    pub stop: StopPolicy,
134
    pub rules: Vec<Rule>,
135
    pub extra_rules: Vec<Rule>,
136
    pub nix_gc: bool,
137
    pub nix_min_free: Bytes,
138
}
139
140
#[derive(Debug, Deserialize)]
141
#[serde(default, deny_unknown_fields)]
142
pub struct Nix {
143
    pub inject: bool,
144
    pub cores_per_job: u32,
145
}
146
147
#[derive(Debug, Deserialize)]
148
#[serde(default, deny_unknown_fields)]
149
pub struct Detect {
150
    pub commands: Vec<String>,
151
    pub extra: Vec<String>,
152
}
153
154
impl Default for Config {
155
    fn default() -> Self {
156
        let preset = |ram, disk, cores: &str, time| Preset {
157
            ram,
158
            disk,
159
            cores: cores.parse().unwrap_or(Amount::Abs(1)),
160
            time: Dur::secs(time),
161
        };
162
        let sizes = BTreeMap::from([
163
            (
164
                "s".into(),
165
                preset(Bytes::mib(512), Bytes::mib(256), "1", 60),
166
            ),
167
            ("m".into(), preset(Bytes::gib(2), Bytes::gib(1), "4", 300)),
168
            ("l".into(), preset(Bytes::gib(6), Bytes::gib(5), "50%", 900)),
169
            (
170
                "xl".into(),
171
                preset(Bytes::gib(12), Bytes::gib(20), "100%", 2700),
172
            ),
173
        ]);
174
        Self {
175
            budget: Budget::default(),
176
            heavy: Heavy::default(),
177
            sizes,
178
            queue: Queue::default(),
179
            overrun: Overrun::default(),
180
            enforce: Enforce::default(),
181
            artifacts: Artifacts::default(),
182
            nix: Nix::default(),
183
            detect: Detect::default(),
184
        }
185
    }
186
}
187
188
impl Default for Budget {
189
    fn default() -> Self {
190
        Self {
191
            ram: Amount::Percent(75.0),
192
            ram_reserve: Bytes::gib(4),
193
            cores: Amount::Percent(100.0),
194
            disk_reserve: Bytes::gib(20),
195
            disk_paths: vec!["/nix/store".into()],
196
            psi_max: Some(40.0),
197
        }
198
    }
199
}
200
201
impl Default for Heavy {
202
    fn default() -> Self {
203
        Self {
204
            ram: Bytes::gib(4),
205
            cores: Amount::Percent(50.0),
206
            disk: Bytes::gib(5),
207
        }
208
    }
209
}
210
211
impl Default for Queue {
212
    fn default() -> Self {
213
        Self {
214
            linger: Dur::secs(5),
215
            backfill_max_time: Dur::secs(120),
216
            max_wait: None,
217
            elastic: true,
218
            intent_ttl: Dur::secs(1200),
219
        }
220
    }
221
}
222
223
impl Default for Overrun {
224
    fn default() -> Self {
225
        Self {
226
            ram: RamOverrun::Warn,
227
            ram_slack: 1.25,
228
            time_grace: 1.5,
229
            remind_every: Dur::secs(300),
230
        }
231
    }
232
}
233
234
impl Default for Enforce {
235
    fn default() -> Self {
236
        Self {
237
            unwrapped: Unwrapped::Deny,
238
            default_size: "m".into(),
239
        }
240
    }
241
}
242
243
impl Default for Artifacts {
244
    fn default() -> Self {
245
        let rule = |name: &str, marker: Option<&str>| Rule {
246
            name: name.into(),
247
            marker: marker.map(Into::into),
248
            clean: None,
249
        };
250
        Self {
251
            project_cap: Bytes::gib(10),
252
            global_cap: Bytes::gib(60),
253
            idle_after: Dur::secs(86_400),
254
            size_ttl: Dur::secs(600),
255
            over_cap: OverCap::Warn,
256
            stop: StopPolicy::Nudge,
257
            rules: vec![
258
                rule("target", Some("Cargo.toml")),
259
                rule("result*", None),
260
                rule("node_modules", Some("package.json")),
261
                rule("zig-out", Some("build.zig")),
262
                rule(".zig-cache", Some("build.zig")),
263
                rule("_build", Some("dune-project")),
264
                rule("dist-newstyle", Some("cabal.project")),
265
            ],
266
            extra_rules: Vec::new(),
267
            nix_gc: false,
268
            nix_min_free: Bytes::gib(30),
269
        }
270
    }
271
}
272
273
impl Default for Nix {
274
    fn default() -> Self {
275
        Self {
276
            inject: true,
277
            cores_per_job: 4,
278
        }
279
    }
280
}
281
282
impl Default for Detect {
283
    fn default() -> Self {
284
        let commands = [
285
            "cargo build",
286
            "cargo b",
287
            "cargo test",
288
            "cargo t",
289
            "cargo check",
290
            "cargo c",
291
            "cargo clippy",
292
            "cargo run",
293
            "cargo r",
294
            "cargo nextest",
295
            "cargo bench",
296
            "cargo install",
297
            "cargo doc",
298
            "nix build",
299
            "nix flake check",
300
            "nix develop",
301
            "nix run",
302
            "nix shell",
303
            "nix-build",
304
            "nix-shell",
305
            "nixos-rebuild",
306
            "home-manager switch",
307
            "home-manager build",
308
            "make",
309
            "gmake",
310
            "cmake --build",
311
            "ninja",
312
            "meson compile",
313
            "go build",
314
            "go test",
315
            "zig build",
316
            "dune build",
317
            "cabal build",
318
            "stack build",
319
            "npm run build",
320
            "npm test",
321
            "npm ci",
322
            "npm install",
323
            "pnpm build",
324
            "pnpm install",
325
            "yarn build",
326
            "bun run build",
327
            "docker build",
328
            "podman build",
329
            "mvn",
330
            "gradle",
331
            "./gradlew",
332
            "bazel build",
333
            "bazel test",
334
        ];
335
        Self {
336
            commands: commands.into_iter().map(Into::into).collect(),
337
            extra: Vec::new(),
338
        }
339
    }
340
}
341
342
impl Config {
343
    pub fn load() -> Result<Self, String> {
344
        let path = paths::config_file();
345
        match fs::read_to_string(&path) {
346
            Ok(text) => Self::parse(&text).map_err(|e| format!("{}: {e}", path.display())),
347
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
348
            Err(e) => Err(format!("{}: {e}", path.display())),
349
        }
350
    }
351
352
    pub fn load_for(cwd: &std::path::Path) -> Result<Self, String> {
353
        let global = paths::config_file();
354
        let mut base = match fs::read_to_string(&global) {
355
            Ok(text) => toml::from_str(&text).map_err(|e| format!("{}: {e}", global.display()))?,
356
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
357
                toml::Value::Table(toml::map::Map::new())
358
            }
359
            Err(e) => return Err(format!("{}: {e}", global.display())),
360
        };
361
        if let Some(path) = paths::project_config_file(cwd) {
362
            let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
363
            let overlay: toml::Value =
364
                toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
365
            if !overlay.is_table() {
366
                return Err(format!("{}: expected a TOML table", path.display()));
367
            }
368
            merge_values(&mut base, overlay);
369
        }
370
        let mut config: Self = base
371
            .try_into()
372
            .map_err(|e| format!("{}: {e}", global.display()))?;
373
        for (name, preset) in Self::default().sizes {
374
            config.sizes.entry(name).or_insert(preset);
375
        }
376
        Ok(config)
377
    }
378
379
    pub fn parse(text: &str) -> Result<Self, toml::de::Error> {
380
        let mut config: Self = toml::from_str(text)?;
381
        for (name, preset) in Self::default().sizes {
382
            config.sizes.entry(name).or_insert(preset);
383
        }
384
        Ok(config)
385
    }
386
387
    pub fn preset(&self, name: &str) -> Result<Preset, String> {
388
        self.sizes.get(name).copied().ok_or_else(|| {
389
            let known: Vec<_> = self.sizes.keys().map(String::as_str).collect();
390
            format!(
391
                "unknown size '{name}', known sizes are {}",
392
                known.join(", ")
393
            )
394
        })
395
    }
396
397
    pub fn artifact_rules(&self) -> impl Iterator<Item = &Rule> {
398
        self.artifacts
399
            .rules
400
            .iter()
401
            .chain(&self.artifacts.extra_rules)
402
    }
403
404
    pub fn detect_patterns(&self) -> impl Iterator<Item = &str> {
405
        self.detect
406
            .commands
407
            .iter()
408
            .chain(&self.detect.extra)
409
            .map(String::as_str)
410
    }
411
}
412
413
fn merge_values(base: &mut toml::Value, overlay: toml::Value) {
414
    match overlay {
415
        toml::Value::Table(overlay) => {
416
            if let toml::Value::Table(base) = base {
417
                for (key, value) in overlay {
418
                    if let Some(existing) = base.get_mut(&key) {
419
                        merge_values(existing, value);
420
                    } else {
421
                        base.insert(key, value);
422
                    }
423
                }
424
            } else {
425
                *base = toml::Value::Table(overlay);
426
            }
427
        }
428
        value => *base = value,
429
    }
430
}
431
432
#[cfg(test)]
433
mod tests {
434
    use super::{Config, merge_values};
435
    use crate::units::Bytes;
436
437
    #[test]
438
    fn project_values_override_individual_defaults() {
439
        let mut base: toml::Value = toml::from_str(
440
            "[budget]\nram = \"60%\"\nram_reserve = \"2GiB\"\n[queue]\nelastic = false\n",
441
        )
442
        .expect("parse global config");
443
        let project: toml::Value =
444
            toml::from_str("[budget]\nram_reserve = \"8GiB\"\n").expect("parse project config");
445
        merge_values(&mut base, project);
446
        let config: Config = base.try_into().expect("decode merged config");
447
        assert_eq!(config.budget.ram_reserve, Bytes::gib(8));
448
        assert_eq!(config.budget.ram, "60%".parse().expect("parse amount"));
449
        assert!(!config.queue.elastic);
450
    }
451
}