forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
13.5 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}
134
135fn default_concurrency() -> usize {
136 1
137}
138
139fn default_keep_days() -> u32 {
140 7
141}
142
143/// The default cap on one unpacked image, in GB.
144pub fn default_image_max_gb() -> u64 {
145 20
146}
147
148impl fmt::Debug for RunnerConfig {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150 f.debug_struct("RunnerConfig")
151 .field("url", &self.url)
152 .field("runner_id", &self.runner_id)
153 .field("runner_secret", &"<redacted>")
154 .field("name", &self.name)
155 .field("labels", &self.labels)
156 .field("work_dir", &self.work_dir)
157 .field("concurrency", &self.concurrency)
158 .field("limits", &self.limits)
159 .field("executor", &self.executor)
160 .field("images", &self.images)
161 .field("registries", &self.registries)
162 .finish()
163 }
164}
165
166impl RunnerConfig {
167 /// Checks the values that the runner cannot run without.
168 pub fn validate(&self) -> Result<(), ConfigError> {
169 if !(self.url.starts_with("https://") || self.url.starts_with("http://")) {
170 return Err(ConfigError::Invalid(
171 "url must start with http:// or https://".into(),
172 ));
173 }
174 if self.concurrency == 0 || self.concurrency > 64 {
175 return Err(ConfigError::Invalid(
176 "concurrency must be between 1 and 64".into(),
177 ));
178 }
179 if self.runner_secret.is_empty() || self.runner_id.is_empty() {
180 return Err(ConfigError::Invalid(
181 "runner_id and runner_secret are required".into(),
182 ));
183 }
184 if self.limits.image_max_gb == 0 {
185 return Err(ConfigError::Invalid(
186 "image_max_gb must be at least 1".into(),
187 ));
188 }
189 for registry in &self.registries {
190 let host_ok = !registry.host.is_empty()
191 && !registry
192 .host
193 .contains(|c: char| c.is_whitespace() || c == '/');
194 if !host_ok || registry.username.is_empty() || registry.password.is_empty() {
195 return Err(ConfigError::Invalid(
196 "each [[registry]] needs a host, username and password".into(),
197 ));
198 }
199 }
200 Ok(())
201 }
202}
203
204/// The default config path: `$HOME/.config/forge-runner/runner.toml`.
205pub fn default_config_path() -> Result<PathBuf, ConfigError> {
206 let home = std::env::var_os("HOME").ok_or(ConfigError::NoHome)?;
207 Ok(PathBuf::from(home).join(".config/forge-runner/runner.toml"))
208}
209
210/// The default work directory: `$HOME/.cache/forge-runner/work`.
211pub fn default_work_dir() -> Result<PathBuf, ConfigError> {
212 let home = std::env::var_os("HOME").ok_or(ConfigError::NoHome)?;
213 Ok(PathBuf::from(home).join(".cache/forge-runner/work"))
214}
215
216/// Reads and validates the config file.
217pub fn load(path: &Path) -> Result<RunnerConfig, ConfigError> {
218 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
219 path: path.to_owned(),
220 source,
221 })?;
222 let config: RunnerConfig = toml::from_str(&text)?;
223 config.validate()?;
224 Ok(config)
225}
226
227/// Writes the config file with mode 0600, creating parent directories as needed.
228pub fn save(path: &Path, config: &RunnerConfig) -> Result<(), ConfigError> {
229 config.validate()?;
230 let io_err = |source| ConfigError::Io {
231 path: path.to_owned(),
232 source,
233 };
234 if let Some(parent) = path.parent() {
235 std::fs::create_dir_all(parent).map_err(io_err)?;
236 }
237 let text = toml::to_string_pretty(config)?;
238 let mut file = OpenOptions::new()
239 .write(true)
240 .create(true)
241 .truncate(true)
242 .mode(0o600)
243 .open(path)
244 .map_err(io_err)?;
245 // `mode` only applies when the file is created; tighten a pre-existing file too.
246 file.set_permissions(std::fs::Permissions::from_mode(0o600))
247 .map_err(io_err)?;
248 file.write_all(text.as_bytes()).map_err(io_err)?;
249 file.sync_all().map_err(io_err)?;
250 Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 fn sample(dir: &Path) -> RunnerConfig {
258 RunnerConfig {
259 url: "https://forge.example".to_owned(),
260 runner_id: "r_01".to_owned(),
261 runner_secret: "s3cret-value".to_owned(),
262 name: "box-1".to_owned(),
263 labels: vec!["linux".to_owned()],
264 work_dir: dir.join("work"),
265 concurrency: 1,
266 limits: Limits::default(),
267 executor: ExecutorKind::Builtin,
268 images: ImagesConfig::default(),
269 sandbox: SandboxConfig::default(),
270 registries: Vec::new(),
271 }
272 }
273
274 #[test]
275 fn sandbox_userns_defaults_to_auto_and_parses_the_three_modes() {
276 let base = "url = \"https://forge.example\"\nrunner_id = \"r\"\nrunner_secret = \"s\"\n\
277 name = \"n\"\nwork_dir = \"/w\"\n";
278 let parse = |extra: &str| -> RunnerConfig {
279 toml::from_str(&format!("{base}{extra}")).expect("parse")
280 };
281 assert_eq!(parse("").sandbox.userns, UsernsMode::Auto);
282 assert_eq!(
283 parse("[sandbox]\nuserns = \"single\"\n").sandbox.userns,
284 UsernsMode::Single
285 );
286 assert_eq!(
287 parse("[sandbox]\nuserns = \"subuid\"\n").sandbox.userns,
288 UsernsMode::Subuid
289 );
290 let bad = format!("{base}[sandbox]\nuserns = \"root\"\n");
291 assert!(toml::from_str::<RunnerConfig>(&bad).is_err());
292 }
293
294 #[test]
295 fn round_trip_and_mode_0600() {
296 let dir = tempfile::tempdir().expect("tempdir");
297 let path = dir.path().join("nested/runner.toml");
298 let config = sample(dir.path());
299 save(&path, &config).expect("save");
300 let loaded = load(&path).expect("load");
301 assert_eq!(loaded, config);
302
303 let mode = std::fs::metadata(&path)
304 .expect("metadata")
305 .permissions()
306 .mode()
307 & 0o777;
308 assert_eq!(mode, 0o600);
309 }
310
311 #[test]
312 fn save_tightens_existing_file() {
313 let dir = tempfile::tempdir().expect("tempdir");
314 let path = dir.path().join("runner.toml");
315 std::fs::write(&path, "old").expect("write");
316 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
317 save(&path, &sample(dir.path())).expect("save");
318 let mode = std::fs::metadata(&path)
319 .expect("metadata")
320 .permissions()
321 .mode()
322 & 0o777;
323 assert_eq!(mode, 0o600);
324 }
325
326 #[test]
327 fn defaults_apply_when_optional_keys_are_missing() {
328 let dir = tempfile::tempdir().expect("tempdir");
329 let path = dir.path().join("runner.toml");
330 std::fs::write(
331 &path,
332 "url = \"https://f\"\nrunner_id = \"r\"\nrunner_secret = \"s\"\nname = \"n\"\nwork_dir = \"/w\"\n",
333 )
334 .expect("write");
335 let config = load(&path).expect("load");
336 assert_eq!(config.concurrency, 1);
337 assert_eq!(config.limits, Limits::default());
338 assert!(config.labels.is_empty());
339 assert_eq!(config.executor, ExecutorKind::Builtin);
340 assert_eq!(config.images.keep_days, 7);
341 assert_eq!(config.limits.image_max_gb, 20);
342 assert!(config.registries.is_empty());
343 }
344
345 #[test]
346 fn executor_images_limits_and_registries_are_read() {
347 let dir = tempfile::tempdir().expect("tempdir");
348 let path = dir.path().join("runner.toml");
349 std::fs::write(
350 &path,
351 concat!(
352 "url = \"https://f\"\nrunner_id = \"r\"\nrunner_secret = \"s\"\nname = \"n\"\n",
353 "work_dir = \"/w\"\nexecutor = \"podman\"\n",
354 "[limits]\nmemory = \"1g\"\ncpus = \"1\"\npids = 100\nimage_max_gb = 5\n",
355 "[images]\nkeep_days = 3\n",
356 "[[registry]]\nhost = \"ghcr.io\"\nusername = \"bot\"\npassword = \"tok-value\"\n",
357 ),
358 )
359 .expect("write");
360 let config = load(&path).expect("load");
361 assert_eq!(config.executor, ExecutorKind::Podman);
362 assert_eq!(config.limits.image_max_gb, 5);
363 assert_eq!(config.limits.memory, "1g");
364 assert_eq!(config.images.keep_days, 3);
365 assert_eq!(config.registries.len(), 1);
366 assert_eq!(config.registries[0].host, "ghcr.io");
367
368 // A save keeps the image cap and the registry, and the file still loads.
369 save(&path, &config).expect("save");
370 assert_eq!(load(&path).expect("reload"), config);
371 }
372
373 #[test]
374 fn invalid_values_are_rejected() {
375 let dir = tempfile::tempdir().expect("tempdir");
376 let mut config = sample(dir.path());
377 config.url = "ftp://x".to_owned();
378 assert!(config.validate().is_err());
379 config.url = "https://x".to_owned();
380 config.concurrency = 0;
381 assert!(config.validate().is_err());
382 config.concurrency = 1;
383 config.limits.image_max_gb = 0;
384 assert!(config.validate().is_err());
385 config.limits.image_max_gb = 20;
386 config.registries.push(RegistryConfig {
387 host: "ghcr.io/bad".to_owned(),
388 username: "u".to_owned(),
389 password: "p".to_owned(),
390 });
391 assert!(config.validate().is_err());
392 }
393
394 #[test]
395 fn debug_hides_the_secret() {
396 let dir = tempfile::tempdir().expect("tempdir");
397 let mut config = sample(dir.path());
398 config.registries.push(RegistryConfig {
399 host: "ghcr.io".to_owned(),
400 username: "bot".to_owned(),
401 password: "registry-pass-value".to_owned(),
402 });
403 let text = format!("{config:?}");
404 assert!(!text.contains("s3cret-value"));
405 assert!(!text.contains("registry-pass-value"));
406 assert!(text.contains("ghcr.io"));
407 }
408}