//! The runner's `runner.toml`: the forge URL, the runner identity and secret, and local limits. use std::fmt; use std::fs::OpenOptions; use std::io::Write; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; pub use crate::sandbox::idmap::UsernsMode; /// Errors from reading or writing the config file. #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("config file {path}: {source}")] Io { path: PathBuf, #[source] source: std::io::Error, }, #[error("config file is not valid TOML: {0}")] Parse(#[from] toml::de::Error), #[error("cannot serialize config: {0}")] Serialize(#[from] toml::ser::Error), #[error("invalid config: {0}")] Invalid(String), #[error("HOME is not set, pass --config explicitly")] NoHome, } /// Per-job resource limits, applied through the job's cgroup. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Limits { pub memory: String, pub cpus: String, pub pids: u32, /// The cap on the unpacked size of one image, in GB. Used by the builtin executor. #[serde(default = "default_image_max_gb")] pub image_max_gb: u64, } impl Default for Limits { fn default() -> Self { Self { memory: "4g".to_owned(), cpus: "2".to_owned(), pids: 4096, image_max_gb: default_image_max_gb(), } } } /// Which executor runs jobs. `builtin` needs no external tool. `podman` runs them with rootless /// podman. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ExecutorKind { #[default] Builtin, Podman, } /// The `[images]` table: how long unused images are kept. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ImagesConfig { #[serde(default = "default_keep_days")] pub keep_days: u32, } impl Default for ImagesConfig { fn default() -> Self { Self { keep_days: default_keep_days(), } } } /// The `[sandbox]` table: how job ids are mapped. See [`UsernsMode`]. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SandboxConfig { #[serde(default)] pub userns: UsernsMode, } /// A `[[registry]]` login, matched to an image by its registry host. The password is only in the /// 0600 runner.toml and is never printed. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RegistryConfig { pub host: String, pub username: String, pub password: String, } impl fmt::Debug for RegistryConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("RegistryConfig") .field("host", &self.host) .field("username", &self.username) .field("password", &"") .finish() } } /// The contents of `runner.toml`. The file is written with mode 0600 because it holds the /// runner secret and registry passwords. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RunnerConfig { pub url: String, pub runner_id: String, pub runner_secret: String, pub name: String, #[serde(default)] pub labels: Vec, pub work_dir: PathBuf, #[serde(default = "default_concurrency")] pub concurrency: usize, #[serde(default)] pub limits: Limits, #[serde(default)] pub executor: ExecutorKind, #[serde(default)] pub images: ImagesConfig, #[serde(default)] pub sandbox: SandboxConfig, #[serde(default, rename = "registry")] pub registries: Vec, } fn default_concurrency() -> usize { 1 } fn default_keep_days() -> u32 { 7 } /// The default cap on one unpacked image, in GB. pub fn default_image_max_gb() -> u64 { 20 } impl fmt::Debug for RunnerConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("RunnerConfig") .field("url", &self.url) .field("runner_id", &self.runner_id) .field("runner_secret", &"") .field("name", &self.name) .field("labels", &self.labels) .field("work_dir", &self.work_dir) .field("concurrency", &self.concurrency) .field("limits", &self.limits) .field("executor", &self.executor) .field("images", &self.images) .field("registries", &self.registries) .finish() } } impl RunnerConfig { /// Checks the values that the runner cannot run without. pub fn validate(&self) -> Result<(), ConfigError> { if !(self.url.starts_with("https://") || self.url.starts_with("http://")) { return Err(ConfigError::Invalid( "url must start with http:// or https://".into(), )); } if self.concurrency == 0 || self.concurrency > 64 { return Err(ConfigError::Invalid( "concurrency must be between 1 and 64".into(), )); } if self.runner_secret.is_empty() || self.runner_id.is_empty() { return Err(ConfigError::Invalid( "runner_id and runner_secret are required".into(), )); } if self.limits.image_max_gb == 0 { return Err(ConfigError::Invalid( "image_max_gb must be at least 1".into(), )); } for registry in &self.registries { let host_ok = !registry.host.is_empty() && !registry .host .contains(|c: char| c.is_whitespace() || c == '/'); if !host_ok || registry.username.is_empty() || registry.password.is_empty() { return Err(ConfigError::Invalid( "each [[registry]] needs a host, username and password".into(), )); } } Ok(()) } } /// The default config path: `$HOME/.config/forge-runner/runner.toml`. pub fn default_config_path() -> Result { let home = std::env::var_os("HOME").ok_or(ConfigError::NoHome)?; Ok(PathBuf::from(home).join(".config/forge-runner/runner.toml")) } /// The default work directory: `$HOME/.cache/forge-runner/work`. pub fn default_work_dir() -> Result { let home = std::env::var_os("HOME").ok_or(ConfigError::NoHome)?; Ok(PathBuf::from(home).join(".cache/forge-runner/work")) } /// Reads and validates the config file. pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io { path: path.to_owned(), source, })?; let config: RunnerConfig = toml::from_str(&text)?; config.validate()?; Ok(config) } /// Writes the config file with mode 0600, creating parent directories as needed. pub fn save(path: &Path, config: &RunnerConfig) -> Result<(), ConfigError> { config.validate()?; let io_err = |source| ConfigError::Io { path: path.to_owned(), source, }; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(io_err)?; } let text = toml::to_string_pretty(config)?; let mut file = OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(path) .map_err(io_err)?; // `mode` only applies when the file is created; tighten a pre-existing file too. file.set_permissions(std::fs::Permissions::from_mode(0o600)) .map_err(io_err)?; file.write_all(text.as_bytes()).map_err(io_err)?; file.sync_all().map_err(io_err)?; Ok(()) } #[cfg(test)] mod tests { use super::*; fn sample(dir: &Path) -> RunnerConfig { RunnerConfig { url: "https://forge.example".to_owned(), runner_id: "r_01".to_owned(), runner_secret: "s3cret-value".to_owned(), name: "box-1".to_owned(), labels: vec!["linux".to_owned()], work_dir: dir.join("work"), concurrency: 1, limits: Limits::default(), executor: ExecutorKind::Builtin, images: ImagesConfig::default(), sandbox: SandboxConfig::default(), registries: Vec::new(), } } #[test] fn sandbox_userns_defaults_to_auto_and_parses_the_three_modes() { let base = "url = \"https://forge.example\"\nrunner_id = \"r\"\nrunner_secret = \"s\"\n\ name = \"n\"\nwork_dir = \"/w\"\n"; let parse = |extra: &str| -> RunnerConfig { toml::from_str(&format!("{base}{extra}")).expect("parse") }; assert_eq!(parse("").sandbox.userns, UsernsMode::Auto); assert_eq!( parse("[sandbox]\nuserns = \"single\"\n").sandbox.userns, UsernsMode::Single ); assert_eq!( parse("[sandbox]\nuserns = \"subuid\"\n").sandbox.userns, UsernsMode::Subuid ); let bad = format!("{base}[sandbox]\nuserns = \"root\"\n"); assert!(toml::from_str::(&bad).is_err()); } #[test] fn round_trip_and_mode_0600() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("nested/runner.toml"); let config = sample(dir.path()); save(&path, &config).expect("save"); let loaded = load(&path).expect("load"); assert_eq!(loaded, config); let mode = std::fs::metadata(&path) .expect("metadata") .permissions() .mode() & 0o777; assert_eq!(mode, 0o600); } #[test] fn save_tightens_existing_file() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("runner.toml"); std::fs::write(&path, "old").expect("write"); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod"); save(&path, &sample(dir.path())).expect("save"); let mode = std::fs::metadata(&path) .expect("metadata") .permissions() .mode() & 0o777; assert_eq!(mode, 0o600); } #[test] fn defaults_apply_when_optional_keys_are_missing() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("runner.toml"); std::fs::write( &path, "url = \"https://f\"\nrunner_id = \"r\"\nrunner_secret = \"s\"\nname = \"n\"\nwork_dir = \"/w\"\n", ) .expect("write"); let config = load(&path).expect("load"); assert_eq!(config.concurrency, 1); assert_eq!(config.limits, Limits::default()); assert!(config.labels.is_empty()); assert_eq!(config.executor, ExecutorKind::Builtin); assert_eq!(config.images.keep_days, 7); assert_eq!(config.limits.image_max_gb, 20); assert!(config.registries.is_empty()); } #[test] fn executor_images_limits_and_registries_are_read() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("runner.toml"); std::fs::write( &path, concat!( "url = \"https://f\"\nrunner_id = \"r\"\nrunner_secret = \"s\"\nname = \"n\"\n", "work_dir = \"/w\"\nexecutor = \"podman\"\n", "[limits]\nmemory = \"1g\"\ncpus = \"1\"\npids = 100\nimage_max_gb = 5\n", "[images]\nkeep_days = 3\n", "[[registry]]\nhost = \"ghcr.io\"\nusername = \"bot\"\npassword = \"tok-value\"\n", ), ) .expect("write"); let config = load(&path).expect("load"); assert_eq!(config.executor, ExecutorKind::Podman); assert_eq!(config.limits.image_max_gb, 5); assert_eq!(config.limits.memory, "1g"); assert_eq!(config.images.keep_days, 3); assert_eq!(config.registries.len(), 1); assert_eq!(config.registries[0].host, "ghcr.io"); // A save keeps the image cap and the registry, and the file still loads. save(&path, &config).expect("save"); assert_eq!(load(&path).expect("reload"), config); } #[test] fn invalid_values_are_rejected() { let dir = tempfile::tempdir().expect("tempdir"); let mut config = sample(dir.path()); config.url = "ftp://x".to_owned(); assert!(config.validate().is_err()); config.url = "https://x".to_owned(); config.concurrency = 0; assert!(config.validate().is_err()); config.concurrency = 1; config.limits.image_max_gb = 0; assert!(config.validate().is_err()); config.limits.image_max_gb = 20; config.registries.push(RegistryConfig { host: "ghcr.io/bad".to_owned(), username: "u".to_owned(), password: "p".to_owned(), }); assert!(config.validate().is_err()); } #[test] fn debug_hides_the_secret() { let dir = tempfile::tempdir().expect("tempdir"); let mut config = sample(dir.path()); config.registries.push(RegistryConfig { host: "ghcr.io".to_owned(), username: "bot".to_owned(), password: "registry-pass-value".to_owned(), }); let text = format!("{config:?}"); assert!(!text.contains("s3cret-value")); assert!(!text.contains("registry-pass-value")); assert!(text.contains("ghcr.io")); } }