| 1 | |
| 2 | |
| 3 | use std::fmt; |
| 4 | use std::fs::OpenOptions; |
| 5 | use std::io::Write; |
| 6 | use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; |
| 7 | use std::path::{Path, PathBuf}; |
| 8 | |
| 9 | use serde::{Deserialize, Serialize}; |
| 10 | |
| 11 | pub use crate::sandbox::idmap::UsernsMode; |
| 12 | |
| 13 | |
| 14 | #[derive(Debug, thiserror::Error)] |
| 15 | pub 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 | |
| 33 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 34 | #[serde(deny_unknown_fields)] |
| 35 | pub struct Limits { |
| 36 | pub memory: String, |
| 37 | pub cpus: String, |
| 38 | pub pids: u32, |
| 39 | |
| 40 | #[serde(default = "default_image_max_gb")] |
| 41 | pub image_max_gb: u64, |
| 42 | } |
| 43 | |
| 44 | impl 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 | |
| 56 | |
| 57 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 58 | #[serde(rename_all = "lowercase")] |
| 59 | pub enum ExecutorKind { |
| 60 | #[default] |
| 61 | Builtin, |
| 62 | Podman, |
| 63 | } |
| 64 | |
| 65 | |
| 66 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 67 | #[serde(deny_unknown_fields)] |
| 68 | pub struct ImagesConfig { |
| 69 | #[serde(default = "default_keep_days")] |
| 70 | pub keep_days: u32, |
| 71 | } |
| 72 | |
| 73 | impl Default for ImagesConfig { |
| 74 | fn default() -> Self { |
| 75 | Self { |
| 76 | keep_days: default_keep_days(), |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | |
| 82 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 83 | #[serde(deny_unknown_fields)] |
| 84 | pub struct SandboxConfig { |
| 85 | #[serde(default)] |
| 86 | pub userns: UsernsMode, |
| 87 | } |
| 88 | |
| 89 | |
| 90 | |
| 91 | #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 92 | #[serde(deny_unknown_fields)] |
| 93 | pub struct RegistryConfig { |
| 94 | pub host: String, |
| 95 | pub username: String, |
| 96 | pub password: String, |
| 97 | } |
| 98 | |
| 99 | impl 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 | |
| 110 | |
| 111 | #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 112 | #[serde(deny_unknown_fields)] |
| 113 | pub 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 | |
| 135 | fn default_concurrency() -> usize { |
| 136 | 1 |
| 137 | } |
| 138 | |
| 139 | fn default_keep_days() -> u32 { |
| 140 | 7 |
| 141 | } |
| 142 | |
| 143 | |
| 144 | pub fn default_image_max_gb() -> u64 { |
| 145 | 20 |
| 146 | } |
| 147 | |
| 148 | impl 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 | |
| 166 | impl RunnerConfig { |
| 167 | |
| 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 | |
| 205 | pub 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 | |
| 211 | pub 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 | |
| 217 | pub 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 | |
| 228 | pub 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 | |
| 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)] |
| 254 | mod 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 | |
| 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 | } |