forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
14.4 KiB
1//! The runner's `runner.toml`: the forge URL, the runner identity and secret, and local limits.
2
3use std::fmt;
4use std::fs::OpenOptions;
5use std::io::Write;
6use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11pub use crate::sandbox::idmap::UsernsMode;
12
13/// Errors from reading or writing the config file.
14#[derive(Debug, thiserror::Error)]
15pub enum ConfigError {
16 #[error("config file {path}: {source}")]
17 Io {
18 path: PathBuf,
19 #[source]
20 source: std::io::Error,
21 },
22 #[error("config file is not valid TOML: {0}")]
23 Parse(#[from] toml::de::Error),
24 #[error("cannot serialize config: {0}")]
25 Serialize(#[from] toml::ser::Error),
26 #[error("invalid config: {0}")]
27 Invalid(String),
28 #[error("HOME is not set, pass --config explicitly")]
29 NoHome,
30}
31
32/// Per-job resource limits, applied through the job's cgroup.
33#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct Limits {
36 pub memory: String,
37 pub cpus: String,
38 pub pids: u32,
39 /// The cap on the unpacked size of one image, in GB. Used by the builtin executor.
40 #[serde(default = "default_image_max_gb")]
41 pub image_max_gb: u64,
42}
43
44impl Default for Limits {
45 fn default() -> Self {
46 Self {
47 memory: "4g".to_owned(),
48 cpus: "2".to_owned(),
49 pids: 4096,
50 image_max_gb: default_image_max_gb(),
51 }
52 }
53}
54
55/// Which executor runs jobs. `builtin` needs no external tool. `podman` runs them with rootless
56/// podman.
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum ExecutorKind {
60 #[default]
61 Builtin,
62 Podman,
63}
64
65/// The `[images]` table: how long unused images are kept.
66#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct ImagesConfig {
69 #[serde(default = "default_keep_days")]
70 pub keep_days: u32,
71}
72
73impl Default for ImagesConfig {
74 fn default() -> Self {
75 Self {
76 keep_days: default_keep_days(),
77 }
78 }
79}
80
81/// The `[sandbox]` table: how job ids are mapped. See [`UsernsMode`].
82#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct SandboxConfig {
85 #[serde(default)]
86 pub userns: UsernsMode,
87}
88
89/// A `[[registry]]` login, matched to an image by its registry host. The password is only in the
90/// 0600 runner.toml and is never printed.
91#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct RegistryConfig {
94 pub host: String,
95 pub username: String,
96 pub password: String,
97}
98
99impl fmt::Debug for RegistryConfig {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 f.debug_struct("RegistryConfig")
102 .field("host", &self.host)
103 .field("username", &self.username)
104 .field("password", &"<redacted>")
105 .finish()
106 }
107}
108
109/// The contents of `runner.toml`. The file is written with mode 0600 because it holds the
110/// runner secret and registry passwords.
111#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct RunnerConfig {
114 pub url: String,
115 pub runner_id: String,
116 pub runner_secret: String,
117 pub name: String,
118 #[serde(default)]
119 pub labels: Vec<String>,
120 pub work_dir: PathBuf,
121 #[serde(default = "default_concurrency")]
122 pub concurrency: usize,
123 #[serde(default)]
124 pub limits: Limits,
125 #[serde(default)]
126 pub executor: ExecutorKind,
127 #[serde(default)]
128 pub images: ImagesConfig,
129 #[serde(default)]
130 pub sandbox: SandboxConfig,
131 #[serde(default, rename = "registry")]
132 pub registries: Vec<RegistryConfig>,
133 /// When true (the default), an idle runner replaces itself with the forge's binary when the
134 /// forge advertises a different one on the claim response. The runner then exits with code
135 /// 75 and systemd restarts it. Set `auto_update = false` to pin the binary by hand.
136 #[serde(default = "default_auto_update")]
137 pub auto_update: bool,
138}
139
140fn default_concurrency() -> usize {
141 1
142}
143
144fn default_auto_update() -> bool {
145 true
146}
147
148fn default_keep_days() -> u32 {
149 7
150}
151
152/// The default cap on one unpacked image, in GB.
153pub fn default_image_max_gb() -> u64 {
154 20
155}
156
157impl fmt::Debug for RunnerConfig {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 f.debug_struct("RunnerConfig")
160 .field("url", &self.url)
161 .field("runner_id", &self.runner_id)
162 .field("runner_secret", &"<redacted>")
163 .field("name", &self.name)
164 .field("labels", &self.labels)
165 .field("work_dir", &self.work_dir)
166 .field("concurrency", &self.concurrency)
167 .field("limits", &self.limits)
168 .field("executor", &self.executor)
169 .field("images", &self.images)
170 .field("registries", &self.registries)
171 .field("auto_update", &self.auto_update)
172 .finish()
173 }
174}
175
176impl RunnerConfig {
177 /// Checks the values that the runner cannot run without.
178 pub fn validate(&self) -> Result<(), ConfigError> {
179 if !(self.url.starts_with("https://") || self.url.starts_with("http://")) {
180 return Err(ConfigError::Invalid(
181 "url must start with http:// or https://".into(),
182 ));
183 }
184 if self.concurrency == 0 || self.concurrency > 64 {
185 return Err(ConfigError::Invalid(
186 "concurrency must be between 1 and 64".into(),
187 ));
188 }
189 if self.runner_secret.is_empty() || self.runner_id.is_empty() {
190 return Err(ConfigError::Invalid(
191 "runner_id and runner_secret are required".into(),
192 ));
193 }
194 if self.limits.image_max_gb == 0 {
195 return Err(ConfigError::Invalid(
196 "image_max_gb must be at least 1".into(),
197 ));
198 }
199 for registry in &self.registries {
200 let host_ok = !registry.host.is_empty()
201 && !registry
202 .host
203 .contains(|c: char| c.is_whitespace() || c == '/');
204 if !host_ok || registry.username.is_empty() || registry.password.is_empty() {
205 return Err(ConfigError::Invalid(
206 "each [[registry]] needs a host, username and password".into(),
207 ));
208 }
209 }
210 Ok(())
211 }
212}
213
214/// The default config path: `$HOME/.config/forge-runner/runner.toml`.
215pub fn default_config_path() -> Result<PathBuf, ConfigError> {
216 let home = std::env::var_os("HOME").ok_or(ConfigError::NoHome)?;
217 Ok(PathBuf::from(home).join(".config/forge-runner/runner.toml"))
218}
219
220/// The default work directory: `$HOME/.cache/forge-runner/work`.
221pub fn default_work_dir() -> Result<PathBuf, ConfigError> {
222 let home = std::env::var_os("HOME").ok_or(ConfigError::NoHome)?;
223 Ok(PathBuf::from(home).join(".cache/forge-runner/work"))
224}
225
226/// Reads and validates the config file.
227pub fn load(path: &Path) -> Result<RunnerConfig, ConfigError> {
228 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
229 path: path.to_owned(),
230 source,
231 })?;
232 let config: RunnerConfig = toml::from_str(&text)?;
233 config.validate()?;
234 Ok(config)
235}
236
237/// Writes the config file with mode 0600, creating parent directories as needed.
238pub fn save(path: &Path, config: &RunnerConfig) -> Result<(), ConfigError> {
239 config.validate()?;
240 let io_err = |source| ConfigError::Io {
241 path: path.to_owned(),
242 source,
243 };
244 if let Some(parent) = path.parent() {
245 std::fs::create_dir_all(parent).map_err(io_err)?;
246 }
247 let text = toml::to_string_pretty(config)?;
248 let mut file = OpenOptions::new()
249 .write(true)
250 .create(true)
251 .truncate(true)
252 .mode(0o600)
253 .open(path)
254 .map_err(io_err)?;
255 // `mode` only applies when the file is created; tighten a pre-existing file too.
256 file.set_permissions(std::fs::Permissions::from_mode(0o600))
257 .map_err(io_err)?;
258 file.write_all(text.as_bytes()).map_err(io_err)?;
259 file.sync_all().map_err(io_err)?;
260 Ok(())
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 fn sample(dir: &Path) -> RunnerConfig {
268 RunnerConfig {
269 url: "https://forge.example".to_owned(),
270 runner_id: "r_01".to_owned(),
271 runner_secret: "s3cret-value".to_owned(),
272 name: "box-1".to_owned(),
273 labels: vec!["linux".to_owned()],
274 work_dir: dir.join("work"),
275 concurrency: 1,
276 limits: Limits::default(),
277 executor: ExecutorKind::Builtin,
278 images: ImagesConfig::default(),
279 sandbox: SandboxConfig::default(),
280 registries: Vec::new(),
281 auto_update: true,
282 }
283 }
284
285 #[test]
286 fn sandbox_userns_defaults_to_auto_and_parses_the_three_modes() {
287 let base = "url = \"https://forge.example\"\nrunner_id = \"r\"\nrunner_secret = \"s\"\n\
288 name = \"n\"\nwork_dir = \"/w\"\n";
289 let parse = |extra: &str| -> RunnerConfig {
290 toml::from_str(&format!("{base}{extra}")).expect("parse")
291 };
292 assert_eq!(parse("").sandbox.userns, UsernsMode::Auto);
293 assert_eq!(
294 parse("[sandbox]\nuserns = \"single\"\n").sandbox.userns,
295 UsernsMode::Single
296 );
297 assert_eq!(
298 parse("[sandbox]\nuserns = \"subuid\"\n").sandbox.userns,
299 UsernsMode::Subuid
300 );
301 let bad = format!("{base}[sandbox]\nuserns = \"root\"\n");
302 assert!(toml::from_str::<RunnerConfig>(&bad).is_err());
303 }
304
305 #[test]
306 fn round_trip_and_mode_0600() {
307 let dir = tempfile::tempdir().expect("tempdir");
308 let path = dir.path().join("nested/runner.toml");
309 let config = sample(dir.path());
310 save(&path, &config).expect("save");
311 let loaded = load(&path).expect("load");
312 assert_eq!(loaded, config);
313
314 let mode = std::fs::metadata(&path)
315 .expect("metadata")
316 .permissions()
317 .mode()
318 & 0o777;
319 assert_eq!(mode, 0o600);
320 }
321
322 #[test]
323 fn save_tightens_existing_file() {
324 let dir = tempfile::tempdir().expect("tempdir");
325 let path = dir.path().join("runner.toml");
326 std::fs::write(&path, "old").expect("write");
327 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
328 save(&path, &sample(dir.path())).expect("save");
329 let mode = std::fs::metadata(&path)
330 .expect("metadata")
331 .permissions()
332 .mode()
333 & 0o777;
334 assert_eq!(mode, 0o600);
335 }
336
337 #[test]
338 fn defaults_apply_when_optional_keys_are_missing() {
339 let dir = tempfile::tempdir().expect("tempdir");
340 let path = dir.path().join("runner.toml");
341 std::fs::write(
342 &path,
343 "url = \"https://f\"\nrunner_id = \"r\"\nrunner_secret = \"s\"\nname = \"n\"\nwork_dir = \"/w\"\n",
344 )
345 .expect("write");
346 let config = load(&path).expect("load");
347 assert_eq!(config.concurrency, 1);
348 assert_eq!(config.limits, Limits::default());
349 assert!(config.labels.is_empty());
350 assert_eq!(config.executor, ExecutorKind::Builtin);
351 assert_eq!(config.images.keep_days, 7);
352 assert_eq!(config.limits.image_max_gb, 20);
353 assert!(config.registries.is_empty());
354 assert!(config.auto_update);
355 }
356
357 #[test]
358 fn auto_update_can_be_turned_off() {
359 let dir = tempfile::tempdir().expect("tempdir");
360 let path = dir.path().join("runner.toml");
361 std::fs::write(
362 &path,
363 "url = \"https://f\"\nrunner_id = \"r\"\nrunner_secret = \"s\"\nname = \"n\"\n\
364 work_dir = \"/w\"\nauto_update = false\n",
365 )
366 .expect("write");
367 assert!(!load(&path).expect("load").auto_update);
368 }
369
370 #[test]
371 fn executor_images_limits_and_registries_are_read() {
372 let dir = tempfile::tempdir().expect("tempdir");
373 let path = dir.path().join("runner.toml");
374 std::fs::write(
375 &path,
376 concat!(
377 "url = \"https://f\"\nrunner_id = \"r\"\nrunner_secret = \"s\"\nname = \"n\"\n",
378 "work_dir = \"/w\"\nexecutor = \"podman\"\n",
379 "[limits]\nmemory = \"1g\"\ncpus = \"1\"\npids = 100\nimage_max_gb = 5\n",
380 "[images]\nkeep_days = 3\n",
381 "[[registry]]\nhost = \"ghcr.io\"\nusername = \"bot\"\npassword = \"tok-value\"\n",
382 ),
383 )
384 .expect("write");
385 let config = load(&path).expect("load");
386 assert_eq!(config.executor, ExecutorKind::Podman);
387 assert_eq!(config.limits.image_max_gb, 5);
388 assert_eq!(config.limits.memory, "1g");
389 assert_eq!(config.images.keep_days, 3);
390 assert_eq!(config.registries.len(), 1);
391 assert_eq!(config.registries[0].host, "ghcr.io");
392
393 // A save keeps the image cap and the registry, and the file still loads.
394 save(&path, &config).expect("save");
395 assert_eq!(load(&path).expect("reload"), config);
396 }
397
398 #[test]
399 fn invalid_values_are_rejected() {
400 let dir = tempfile::tempdir().expect("tempdir");
401 let mut config = sample(dir.path());
402 config.url = "ftp://x".to_owned();
403 assert!(config.validate().is_err());
404 config.url = "https://x".to_owned();
405 config.concurrency = 0;
406 assert!(config.validate().is_err());
407 config.concurrency = 1;
408 config.limits.image_max_gb = 0;
409 assert!(config.validate().is_err());
410 config.limits.image_max_gb = 20;
411 config.registries.push(RegistryConfig {
412 host: "ghcr.io/bad".to_owned(),
413 username: "u".to_owned(),
414 password: "p".to_owned(),
415 });
416 assert!(config.validate().is_err());
417 }
418
419 #[test]
420 fn debug_hides_the_secret() {
421 let dir = tempfile::tempdir().expect("tempdir");
422 let mut config = sample(dir.path());
423 config.registries.push(RegistryConfig {
424 host: "ghcr.io".to_owned(),
425 username: "bot".to_owned(),
426 password: "registry-pass-value".to_owned(),
427 });
428 let text = format!("{config:?}");
429 assert!(!text.contains("s3cret-value"));
430 assert!(!text.contains("registry-pass-value"));
431 assert!(text.contains("ghcr.io"));
432 }
433}