worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // Boot configuration. Read once, validated once, failing loudly — a bad value must |
| 2 | // stop the process rather than surface as a confusing 500 on the first request. |
| 3 | |
| 4 | use crate::brand; |
| 5 | use anyhow::{anyhow, bail, Context, Result}; |
| 6 | use std::net::SocketAddr; |
| 7 | use std::path::PathBuf; |
| 8 | use std::time::Duration; |
| 9 | |
| 10 | /// Deployment shape. `Standalone` and `Tenant` share the request-handling path and |
| 11 | /// differ in how `Identity` resolves and how CI is isolated (SPEC §1.1). |
| 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 13 | pub enum Mode { |
| 14 | Standalone, |
| 15 | Control, |
| 16 | Tenant, |
| 17 | } |
| 18 | |
| 19 | impl std::str::FromStr for Mode { |
| 20 | type Err = anyhow::Error; |
| 21 | |
| 22 | fn from_str(s: &str) -> Result<Self> { |
| 23 | match s { |
| 24 | "standalone" => Ok(Mode::Standalone), |
| 25 | "control" => Ok(Mode::Control), |
| 26 | "tenant" => Ok(Mode::Tenant), |
| 27 | other => bail!( |
| 28 | "unknown {} {other:?} (standalone | control | tenant)", |
| 29 | brand::env_name("MODE") |
| 30 | ), |
| 31 | } |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | impl Mode { |
| 36 | pub fn as_str(self) -> &'static str { |
| 37 | match self { |
| 38 | Mode::Standalone => "standalone", |
| 39 | Mode::Control => "control", |
| 40 | Mode::Tenant => "tenant", |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | #[derive(Debug, Clone)] |
| 46 | pub struct Config { |
| 47 | pub bind: SocketAddr, |
| 48 | pub mode: Mode, |
| 49 | pub data_dir: PathBuf, |
| 50 | /// Opt-in hosted auth. Deliberately has no default: a self-hoster who configures |
| 51 | /// nothing must not get a binary that phones Worklyn to authenticate and 502s |
| 52 | /// when it cannot reach it (SPEC §10). |
| 53 | pub me_url: Option<String>, |
| 54 | pub ci_enabled: bool, |
| 55 | /// Writes are refused with 507 below this, because receive-pack interrupted by |
| 56 | /// ENOSPC leaves a partial pack (SPEC §4.9). |
| 57 | pub disk_reserve_bytes: u64, |
| 58 | pub max_body_bytes: u64, |
| 59 | /// Largest pack accepted on a push. |
| 60 | pub max_pack_bytes: u64, |
| 61 | /// Largest blob served inline by the read API. |
| 62 | pub max_blob_bytes: u64, |
| 63 | /// Ceiling on concurrent `git` subprocesses. |
| 64 | pub max_concurrent_git: usize, |
| 65 | pub default_page_limit: usize, |
| 66 | pub max_page_limit: usize, |
| 67 | /// How long a connection may go without sending a request line. |
| 68 | pub header_timeout: Duration, |
| 69 | pub ssh_idle_timeout: Duration, |
| 70 | /// Default token lifetime, in days. |
| 71 | pub token_ttl_days: u64, |
| 72 | /// How far a path-filtered log may walk before reporting truncation. |
| 73 | pub log_walk_budget: usize, |
| 74 | /// Repositories one account may own. `0` disables the check. |
| 75 | pub max_repos_per_account: u64, |
| 76 | /// Bytes one repository may occupy. `0` disables the check. |
| 77 | pub max_repo_bytes: u64, |
| 78 | /// Bytes one account may occupy across all repositories. `0` disables. |
| 79 | pub max_account_bytes: u64, |
| 80 | /// Requests per minute per credential. `0` disables rate limiting. |
| 81 | pub rate_per_minute: u32, |
| 82 | /// How often the jobs process runs maintenance. |
| 83 | pub gc_interval: Duration, |
| 84 | /// Passed to `git prune --expire`. |
| 85 | pub gc_prune_grace: String, |
| 86 | /// How long a soft-deleted repository is retained. |
| 87 | pub deleted_retention: Duration, |
| 88 | /// Ceiling on a single run's wall clock. A spec may lower it, never raise it. |
| 89 | pub ci_timeout: Duration, |
| 90 | /// Runs executing at once on this host. |
| 91 | pub ci_max_concurrent: usize, |
| 92 | /// Cap on one run's captured output. |
| 93 | pub ci_log_bytes: u64, |
| 94 | /// `RLIMIT_AS` for a step. `0` leaves address space unlimited. |
| 95 | pub ci_memory_bytes: u64, |
| 96 | /// Largest file a CI step may write. 0 disables the limit. |
| 97 | pub ci_file_bytes: u64, |
| 98 | /// `RLIMIT_NPROC` for a step. `0` disables it, which is the default — the limit |
| 99 | /// is per-UID and only means anything with a dedicated runner uid. |
| 100 | pub ci_max_processes: u64, |
| 101 | /// Runs retained per repository. |
| 102 | pub ci_keep_runs: usize, |
| 103 | /// Whether repositories may be imported from a remote URL. |
| 104 | pub import_enabled: bool, |
| 105 | /// Ceiling on how long an import may take. |
| 106 | pub import_timeout: Duration, |
| 107 | /// Control plane: Ed25519 seed used to sign tenant assertions, base64. |
| 108 | /// |
| 109 | /// Generated on first boot if absent. Never leaves the control plane. |
| 110 | pub control_key_file: PathBuf, |
| 111 | /// Tenant: base64 public keys that may sign assertions. More than one so the |
| 112 | /// control plane's key can be rotated without a flag day. |
| 113 | pub control_public_keys: Vec<String>, |
| 114 | /// Control plane: Incus image for a new tenant. |
| 115 | pub incus_image: String, |
| 116 | /// Control plane: Incus network a tenant joins. |
| 117 | pub incus_network: String, |
| 118 | /// Port a tenant listens on inside its container. |
| 119 | pub tenant_port: u16, |
| 120 | /// Control plane: path to the binary pushed into a tenant. |
| 121 | pub tenant_binary: String, |
| 122 | /// Control plane: ceiling on one proxied request. |
| 123 | pub proxy_timeout: Duration, |
| 124 | /// Permit importing from private and loopback addresses. |
| 125 | /// |
| 126 | /// Needed on an internal network; a hole on a public one, which is why it is |
| 127 | /// off by default and logged loudly when on. |
| 128 | pub import_allow_private: bool, |
| 129 | /// SSH listener. `None` disables the SSH transport. |
| 130 | pub ssh_bind: Option<SocketAddr>, |
| 131 | /// Ed25519 host key. Generated on first boot if absent. |
| 132 | pub ssh_host_key: PathBuf, |
| 133 | } |
| 134 | |
| 135 | impl Config { |
| 136 | pub fn from_env() -> Result<Self> { |
| 137 | let bind_raw = env_or("BIND", DEFAULT_BIND); |
| 138 | let bind: SocketAddr = bind_raw.parse().with_context(|| { |
| 139 | format!( |
| 140 | "{} {bind_raw:?} is not a socket address", |
| 141 | brand::env_name("BIND") |
| 142 | ) |
| 143 | })?; |
| 144 | |
| 145 | let mode: Mode = env_or("MODE", DEFAULT_MODE).parse()?; |
| 146 | |
| 147 | let data_dir = PathBuf::from(env_or("DATA_DIR", brand::default_data_dir())); |
| 148 | if data_dir.is_relative() { |
| 149 | bail!( |
| 150 | "{} must be absolute, got {}", |
| 151 | brand::env_name("DATA_DIR"), |
| 152 | data_dir.display() |
| 153 | ); |
| 154 | } |
| 155 | |
| 156 | let me_url = brand::env("ME_URL").and_then(|v| { |
| 157 | let trimmed = v.trim_end_matches('/').to_string(); |
| 158 | if trimmed.is_empty() { |
| 159 | None |
| 160 | } else { |
| 161 | Some(trimmed) |
| 162 | } |
| 163 | }); |
| 164 | if let Some(url) = &me_url { |
| 165 | if !url.starts_with("https://") && !url.starts_with("http://127.0.0.1") { |
| 166 | bail!( |
| 167 | "{} must be https, or http on loopback for development", |
| 168 | brand::env_name("ME_URL") |
| 169 | ); |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | let ci_enabled = env_bool("CI_ENABLED", false)?; |
| 174 | |
| 175 | let disk_reserve_bytes = env_mb("DISK_RESERVE_MB", DEFAULT_DISK_RESERVE_MB)?; |
| 176 | let max_body_bytes = env_mb("MAX_BODY_MB", DEFAULT_MAX_BODY_MB)?; |
| 177 | let max_pack_bytes = env_mb("MAX_PACK_MB", DEFAULT_MAX_PACK_MB)?; |
| 178 | let max_blob_bytes = env_mb("MAX_BLOB_MB", DEFAULT_MAX_BLOB_MB)?; |
| 179 | |
| 180 | let default_page_limit = env_u64("PAGE_LIMIT", DEFAULT_PAGE_LIMIT)? as usize; |
| 181 | let max_page_limit = env_u64("MAX_PAGE_LIMIT", DEFAULT_MAX_PAGE_LIMIT)? as usize; |
| 182 | if default_page_limit == 0 || default_page_limit > max_page_limit { |
| 183 | bail!( |
| 184 | "{} must be between 1 and {}", |
| 185 | brand::env_name("PAGE_LIMIT"), |
| 186 | brand::env_name("MAX_PAGE_LIMIT") |
| 187 | ); |
| 188 | } |
| 189 | |
| 190 | let header_timeout = env_secs("HEADER_TIMEOUT_SECS", DEFAULT_HEADER_TIMEOUT_SECS)?; |
| 191 | let ssh_idle_timeout = env_secs("SSH_IDLE_SECS", DEFAULT_SSH_IDLE_SECS)?; |
| 192 | let token_ttl_days = env_u64("TOKEN_TTL_DAYS", DEFAULT_TOKEN_TTL_DAYS)?; |
| 193 | let log_walk_budget = env_u64("LOG_WALK_BUDGET", DEFAULT_LOG_WALK_BUDGET)? as usize; |
| 194 | |
| 195 | let max_repos_per_account = env_u64("MAX_REPOS_PER_ACCOUNT", DEFAULT_MAX_REPOS)?; |
| 196 | let max_repo_bytes = env_mb("MAX_REPO_MB", DEFAULT_MAX_REPO_MB)?; |
| 197 | let max_account_bytes = env_mb("MAX_ACCOUNT_MB", DEFAULT_MAX_ACCOUNT_MB)?; |
| 198 | if max_repo_bytes > 0 && max_account_bytes > 0 && max_repo_bytes > max_account_bytes { |
| 199 | bail!( |
| 200 | "{} must not exceed {}", |
| 201 | brand::env_name("MAX_REPO_MB"), |
| 202 | brand::env_name("MAX_ACCOUNT_MB") |
| 203 | ); |
| 204 | } |
| 205 | |
| 206 | let rate_per_minute = env_u64("RATE_PER_MINUTE", DEFAULT_RATE_PER_MINUTE)? as u32; |
| 207 | let gc_interval = env_secs("GC_INTERVAL_SECS", DEFAULT_GC_INTERVAL_SECS)?; |
| 208 | let gc_prune_grace = env_or("GC_PRUNE_GRACE", DEFAULT_GC_PRUNE_GRACE); |
| 209 | let deleted_retention = Duration::from_secs( |
| 210 | env_u64("DELETED_RETENTION_DAYS", DEFAULT_DELETED_RETENTION_DAYS)? * 24 * 60 * 60, |
| 211 | ); |
| 212 | |
| 213 | let import_enabled = env_bool("IMPORT_ENABLED", true)?; |
| 214 | // Off by default. A self-hoster whose git server is on a private network |
| 215 | // genuinely needs this; on a public host it re-opens SSRF, so it is opt-in |
| 216 | // and announced at boot rather than quietly available. |
| 217 | let import_allow_private = env_bool("IMPORT_ALLOW_PRIVATE", false)?; |
| 218 | let import_timeout = env_secs("IMPORT_TIMEOUT_SECS", DEFAULT_IMPORT_TIMEOUT_SECS)?; |
| 219 | |
| 220 | let control_key_file = data_dir.join("control_ed25519_seed"); |
| 221 | let control_public_keys = brand::env("CONTROL_PUBLIC_KEYS") |
| 222 | .map(|v| { |
| 223 | v.split(',') |
| 224 | .map(str::trim) |
| 225 | .filter(|s| !s.is_empty()) |
| 226 | .map(String::from) |
| 227 | .collect::<Vec<_>>() |
| 228 | }) |
| 229 | .unwrap_or_default(); |
| 230 | if mode == Mode::Tenant && control_public_keys.is_empty() { |
| 231 | bail!( |
| 232 | "{} is required in tenant mode: a tenant must know which key may \ |
| 233 | vouch for a caller", |
| 234 | brand::env_name("CONTROL_PUBLIC_KEYS") |
| 235 | ); |
| 236 | } |
| 237 | |
| 238 | let incus_image = env_or("INCUS_IMAGE", DEFAULT_INCUS_IMAGE); |
| 239 | let incus_network = env_or("INCUS_NETWORK", DEFAULT_INCUS_NETWORK); |
| 240 | let tenant_port = env_u64("TENANT_PORT", DEFAULT_TENANT_PORT)? as u16; |
| 241 | let tenant_binary = env_or( |
| 242 | "TENANT_BINARY", |
| 243 | &std::env::current_exe() |
| 244 | .map(|p| p.display().to_string()) |
| 245 | .unwrap_or_else(|_| format!("/usr/local/bin/{}", brand::NAME)), |
| 246 | ); |
| 247 | let proxy_timeout = env_secs("PROXY_TIMEOUT_SECS", DEFAULT_PROXY_TIMEOUT_SECS)?; |
| 248 | |
| 249 | let ci_timeout = env_secs("CI_TIMEOUT_SECS", DEFAULT_CI_TIMEOUT_SECS)?; |
| 250 | let ci_max_concurrent = env_u64("CI_MAX_CONCURRENT", DEFAULT_CI_MAX_CONCURRENT)? as usize; |
| 251 | let ci_log_bytes = env_mb("CI_LOG_MB", DEFAULT_CI_LOG_MB)?; |
| 252 | let ci_memory_bytes = env_mb("CI_MEMORY_MB", DEFAULT_CI_MEMORY_MB)?; |
| 253 | let ci_file_bytes = env_mb("CI_FILE_MB", DEFAULT_CI_FILE_MB)?; |
| 254 | let ci_max_processes = env_u64("CI_MAX_PROCESSES", DEFAULT_CI_MAX_PROCESSES)?; |
| 255 | let ci_keep_runs = env_u64("CI_KEEP_RUNS", DEFAULT_CI_KEEP_RUNS)? as usize; |
| 256 | if ci_enabled && ci_max_concurrent == 0 { |
| 257 | bail!( |
| 258 | "{} must be at least 1", |
| 259 | brand::env_name("CI_MAX_CONCURRENT") |
| 260 | ); |
| 261 | } |
| 262 | |
| 263 | let max_concurrent_git = |
| 264 | env_u64("MAX_CONCURRENT_GIT", DEFAULT_MAX_CONCURRENT_GIT)? as usize; |
| 265 | if max_concurrent_git == 0 { |
| 266 | bail!( |
| 267 | "{} must be at least 1", |
| 268 | brand::env_name("MAX_CONCURRENT_GIT") |
| 269 | ); |
| 270 | } |
| 271 | |
| 272 | let ssh_bind = match brand::env("SSH_BIND").ok_or(()) { |
| 273 | Err(()) => Some( |
| 274 | DEFAULT_SSH_BIND |
| 275 | .parse() |
| 276 | .expect("literal is a valid address"), |
| 277 | ), |
| 278 | Ok(v) if v.is_empty() || v == "off" => None, |
| 279 | Ok(v) => Some(v.parse().with_context(|| { |
| 280 | format!( |
| 281 | "{} {v:?} is not a socket address", |
| 282 | brand::env_name("SSH_BIND") |
| 283 | ) |
| 284 | })?), |
| 285 | }; |
| 286 | let ssh_host_key = data_dir.join("ssh_host_ed25519_key"); |
| 287 | |
| 288 | Ok(Config { |
| 289 | bind, |
| 290 | mode, |
| 291 | data_dir, |
| 292 | me_url, |
| 293 | ci_enabled, |
| 294 | disk_reserve_bytes, |
| 295 | max_body_bytes, |
| 296 | max_pack_bytes, |
| 297 | max_blob_bytes, |
| 298 | max_concurrent_git, |
| 299 | default_page_limit, |
| 300 | max_page_limit, |
| 301 | header_timeout, |
| 302 | ssh_idle_timeout, |
| 303 | token_ttl_days, |
| 304 | log_walk_budget, |
| 305 | max_repos_per_account, |
| 306 | max_repo_bytes, |
| 307 | max_account_bytes, |
| 308 | rate_per_minute, |
| 309 | gc_interval, |
| 310 | gc_prune_grace, |
| 311 | deleted_retention, |
| 312 | ci_timeout, |
| 313 | ci_max_concurrent, |
| 314 | ci_log_bytes, |
| 315 | ci_memory_bytes, |
| 316 | ci_file_bytes, |
| 317 | ci_max_processes, |
| 318 | ci_keep_runs, |
| 319 | import_enabled, |
| 320 | import_timeout, |
| 321 | import_allow_private, |
| 322 | control_key_file, |
| 323 | control_public_keys, |
| 324 | incus_image, |
| 325 | incus_network, |
| 326 | tenant_port, |
| 327 | tenant_binary, |
| 328 | proxy_timeout, |
| 329 | ssh_bind, |
| 330 | ssh_host_key, |
| 331 | }) |
| 332 | } |
| 333 | |
| 334 | pub fn tokens_file(&self) -> PathBuf { |
| 335 | self.data_dir.join("tokens.json") |
| 336 | } |
| 337 | |
| 338 | pub fn meta_dir(&self) -> PathBuf { |
| 339 | self.data_dir.join("meta") |
| 340 | } |
| 341 | |
| 342 | /// Environment a git child must pass down so our hooks can read configuration. |
| 343 | /// |
| 344 | /// Both spawn sites clear the environment — deliberately, so a client cannot |
| 345 | /// inject one — which also means a hook inherits nothing. Without this the |
| 346 | /// `post-receive` hook resolves the default data directory and silently queues |
| 347 | /// runs into the wrong place, or nowhere. |
| 348 | pub fn hook_env(&self) -> Vec<(String, String)> { |
| 349 | vec![ |
| 350 | ( |
| 351 | brand::env_name("DATA_DIR"), |
| 352 | self.data_dir.display().to_string(), |
| 353 | ), |
| 354 | ( |
| 355 | brand::env_name("CI_ENABLED"), |
| 356 | if self.ci_enabled { "1" } else { "0" }.to_string(), |
| 357 | ), |
| 358 | ] |
| 359 | } |
| 360 | |
| 361 | /// Read-path bounds, as `git::discover` needs them. |
| 362 | /// |
| 363 | /// A projection of configuration, so both facades bound reads identically |
| 364 | /// rather than one reaching into the other for it. |
| 365 | pub fn read_limits(&self) -> crate::git::discover::Limits { |
| 366 | crate::git::discover::Limits { |
| 367 | max_blob_bytes: self.max_blob_bytes, |
| 368 | log_walk_budget: self.log_walk_budget, |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | /// Validate a requested page size against the configured bounds. |
| 373 | /// |
| 374 | /// Shared by REST and MCP so a caller gets the same answer whichever surface |
| 375 | /// they ask through. |
| 376 | pub fn page_limit(&self, requested: Option<usize>) -> crate::error::Result<usize> { |
| 377 | match requested { |
| 378 | None => Ok(self.default_page_limit), |
| 379 | Some(n) if n == 0 || n > self.max_page_limit => Err(crate::error::Error::invalid( |
| 380 | "invalid-query", |
| 381 | format!("limit must be between 1 and {}", self.max_page_limit), |
| 382 | )), |
| 383 | Some(n) => Ok(n), |
| 384 | } |
| 385 | } |
| 386 | |
| 387 | pub fn accounts_dir(&self) -> PathBuf { |
| 388 | self.data_dir.join("accounts") |
| 389 | } |
| 390 | |
| 391 | pub fn runs_dir(&self) -> PathBuf { |
| 392 | self.data_dir.join("runs") |
| 393 | } |
| 394 | |
| 395 | pub fn deleted_dir(&self) -> PathBuf { |
| 396 | self.data_dir.join("tmp").join("deleted") |
| 397 | } |
| 398 | |
| 399 | pub fn keys_file(&self) -> PathBuf { |
| 400 | self.data_dir.join("ssh_keys.json") |
| 401 | } |
| 402 | |
| 403 | /// The repository featured at `/` in the browser, as `(account, repo)`. |
| 404 | /// |
| 405 | /// Optional: a host that names none serves an explanation at `/` rather than a |
| 406 | /// 404. Visibility still applies — naming a private repository here does not |
| 407 | /// publish it, it just means anonymous visitors see nothing. |
| 408 | pub fn home_repo(&self) -> Option<(String, String)> { |
| 409 | let value = std::env::var(crate::brand::env_name("HOME_REPO")).ok()?; |
| 410 | let (account, repo) = value.split_once('/')?; |
| 411 | if account.is_empty() || repo.is_empty() { |
| 412 | return None; |
| 413 | } |
| 414 | Some((account.to_string(), repo.to_string())) |
| 415 | } |
| 416 | |
| 417 | /// Base URL advertised in clone URLs. An agent cannot clone a bare path, so |
| 418 | /// this must be an origin even when nothing is configured. |
| 419 | pub fn public_host(&self) -> String { |
| 420 | brand::env("PUBLIC_URL") |
| 421 | .map(|v| v.trim_end_matches('/').to_string()) |
| 422 | .unwrap_or_else(|| format!("http://{}", self.bind)) |
| 423 | } |
| 424 | |
| 425 | /// Base advertised for SSH clones, as an `ssh://` URL. |
| 426 | /// |
| 427 | /// The scheme form rather than scp-style `host:path`, because the port has to |
| 428 | /// be expressible and `host:2222:alice/site.git` is not a thing git parses. |
| 429 | pub fn public_ssh_base(&self) -> Option<String> { |
| 430 | if let Some(configured) = brand::env("PUBLIC_SSH") { |
| 431 | return Some(configured.trim_end_matches('/').to_string()); |
| 432 | } |
| 433 | self.ssh_bind.map(|addr| format!("ssh://git@{addr}")) |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | /// Defaults, in one block so they are reviewable without reading the parser. |
| 438 | const DEFAULT_BIND: &str = "127.0.0.1:8790"; |
| 439 | const DEFAULT_SSH_BIND: &str = "127.0.0.1:2222"; |
| 440 | const DEFAULT_MODE: &str = "standalone"; |
| 441 | const DEFAULT_DISK_RESERVE_MB: u64 = 2048; |
| 442 | const DEFAULT_MAX_BODY_MB: u64 = 10; |
| 443 | const DEFAULT_MAX_PACK_MB: u64 = 512; |
| 444 | const DEFAULT_MAX_BLOB_MB: u64 = 32; |
| 445 | const DEFAULT_MAX_CONCURRENT_GIT: u64 = 8; |
| 446 | const DEFAULT_PAGE_LIMIT: u64 = 50; |
| 447 | const DEFAULT_MAX_PAGE_LIMIT: u64 = 200; |
| 448 | const DEFAULT_HEADER_TIMEOUT_SECS: u64 = 15; |
| 449 | const DEFAULT_SSH_IDLE_SECS: u64 = 600; |
| 450 | const DEFAULT_TOKEN_TTL_DAYS: u64 = 90; |
| 451 | const DEFAULT_LOG_WALK_BUDGET: u64 = 20_000; |
| 452 | const DEFAULT_MAX_REPOS: u64 = 100; |
| 453 | const DEFAULT_MAX_REPO_MB: u64 = 2048; |
| 454 | const DEFAULT_MAX_ACCOUNT_MB: u64 = 10240; |
| 455 | const DEFAULT_RATE_PER_MINUTE: u64 = 600; |
| 456 | const DEFAULT_GC_INTERVAL_SECS: u64 = 6 * 60 * 60; |
| 457 | /// Objects younger than this are never pruned. |
| 458 | /// |
| 459 | /// This grace window is what makes scheduled GC safe against a concurrent clone: |
| 460 | /// `upload-pack` may reference an object between resolving it and streaming it, and |
| 461 | /// pruning inside that window is the classic way to hand a client a corrupt clone. |
| 462 | /// Two weeks is git's own default for the same reason. |
| 463 | const DEFAULT_GC_PRUNE_GRACE: &str = "2.weeks.ago"; |
| 464 | /// How long a soft-deleted repository stays recoverable. |
| 465 | const DEFAULT_DELETED_RETENTION_DAYS: u64 = 7; |
| 466 | const DEFAULT_CI_TIMEOUT_SECS: u64 = 600; |
| 467 | const DEFAULT_CI_MAX_CONCURRENT: u64 = 2; |
| 468 | const DEFAULT_CI_LOG_MB: u64 = 8; |
| 469 | const DEFAULT_CI_MEMORY_MB: u64 = 2048; |
| 470 | /// Generous, because this bounds a single file and a linker writing a binary with |
| 471 | /// debug information routinely passes a few hundred megabytes. It is here to stop a |
| 472 | /// step filling the disk, not to be tight. |
| 473 | const DEFAULT_CI_FILE_MB: u64 = 2048; |
| 474 | |
| 475 | // The file limit and the memory limit were once one value: RLIMIT_FSIZE was derived |
| 476 | // as max(log_bytes * 4, memory). Turning the memory limit off — needed so a |
| 477 | // JavaScript toolchain can reserve its address space — shrank the file limit to 32MB |
| 478 | // and the linker was killed by SIGXFSZ mid-build, an error naming neither limit. |
| 479 | // They are separate settings now, and neither may be computed from the other. These |
| 480 | // hold at compile time, so a bad default cannot reach a host. |
| 481 | const _: () = assert!(DEFAULT_CI_FILE_MB > 0); |
| 482 | const _: () = assert!( |
| 483 | DEFAULT_CI_FILE_MB >= DEFAULT_CI_LOG_MB, |
| 484 | "a step must be able to write its own log" |
| 485 | ); |
| 486 | /// Off by default, and this is not timidity. |
| 487 | /// |
| 488 | /// `RLIMIT_NPROC` is per-UID, not per-process. With the runner sharing a uid with |
| 489 | /// the service — which is the standalone case — the limit counts every process the |
| 490 | /// service user already owns, so a small value makes `fork` fail immediately and a |
| 491 | /// large one bounds nothing. It is only meaningful once the operator gives the |
| 492 | /// runner its own uid, which is also what makes it safe. |
| 493 | const DEFAULT_CI_MAX_PROCESSES: u64 = 0; |
| 494 | /// Runs kept per repository. Older ones are swept with their logs. |
| 495 | const DEFAULT_CI_KEEP_RUNS: u64 = 50; |
| 496 | const DEFAULT_IMPORT_TIMEOUT_SECS: u64 = 300; |
| 497 | const DEFAULT_INCUS_IMAGE: &str = "images:debian/12"; |
| 498 | const DEFAULT_INCUS_NETWORK: &str = "incusbr0"; |
| 499 | const DEFAULT_TENANT_PORT: u64 = 8790; |
| 500 | const DEFAULT_PROXY_TIMEOUT_SECS: u64 = 300; |
| 501 | |
| 502 | fn env_or(key: &str, default: &str) -> String { |
| 503 | brand::env(key).unwrap_or_else(|| default.to_string()) |
| 504 | } |
| 505 | |
| 506 | fn env_bool(key: &str, default: bool) -> Result<bool> { |
| 507 | match brand::env(key) { |
| 508 | None => Ok(default), |
| 509 | Some(v) => match v.as_str() { |
| 510 | "1" | "true" | "yes" => Ok(true), |
| 511 | "0" | "false" | "no" => Ok(false), |
| 512 | other => Err(anyhow!( |
| 513 | "{} must be a boolean, got {other:?}", |
| 514 | brand::env_name(key) |
| 515 | )), |
| 516 | }, |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | fn env_u64(key: &str, default: u64) -> Result<u64> { |
| 521 | match brand::env(key) { |
| 522 | None => Ok(default), |
| 523 | Some(v) => v.parse().with_context(|| { |
| 524 | format!( |
| 525 | "{} must be a non-negative integer, got {v:?}", |
| 526 | brand::env_name(key) |
| 527 | ) |
| 528 | }), |
| 529 | } |
| 530 | } |
| 531 | |
| 532 | /// A byte count given in megabytes. |
| 533 | fn env_mb(key: &str, default_mb: u64) -> Result<u64> { |
| 534 | Ok(env_u64(key, default_mb)? * 1024 * 1024) |
| 535 | } |
| 536 | |
| 537 | fn env_secs(key: &str, default: u64) -> Result<Duration> { |
| 538 | Ok(Duration::from_secs(env_u64(key, default)?)) |
| 539 | } |
| 540 | |
| 541 | #[cfg(test)] |
| 542 | mod tests { |
| 543 | use super::*; |
| 544 | |
| 545 | #[test] |
| 546 | fn mode_parses_the_three_documented_values_and_rejects_others() { |
| 547 | assert_eq!("standalone".parse::<Mode>().unwrap(), Mode::Standalone); |
| 548 | assert_eq!("control".parse::<Mode>().unwrap(), Mode::Control); |
| 549 | assert_eq!("tenant".parse::<Mode>().unwrap(), Mode::Tenant); |
| 550 | assert!("production".parse::<Mode>().is_err()); |
| 551 | } |
| 552 | |
| 553 | #[test] |
| 554 | fn env_bool_accepts_documented_spellings_and_rejects_ambiguity() { |
| 555 | std::env::set_var(brand::env_name("TEST_BOOL"), "yes"); |
| 556 | assert!(env_bool("TEST_BOOL", false).unwrap()); |
| 557 | std::env::set_var(brand::env_name("TEST_BOOL"), "0"); |
| 558 | assert!(!env_bool("TEST_BOOL", true).unwrap()); |
| 559 | std::env::set_var(brand::env_name("TEST_BOOL"), "maybe"); |
| 560 | assert!(env_bool("TEST_BOOL", false).is_err()); |
| 561 | std::env::remove_var(brand::env_name("TEST_BOOL")); |
| 562 | } |
| 563 | |
| 564 | #[test] |
| 565 | fn env_u64_rejects_a_non_numeric_value_rather_than_falling_back() { |
| 566 | std::env::set_var(brand::env_name("TEST_U64"), "lots"); |
| 567 | assert!(env_u64("TEST_U64", 1).is_err()); |
| 568 | std::env::remove_var(brand::env_name("TEST_U64")); |
| 569 | } |
| 570 | |
| 571 | #[test] |
| 572 | fn derived_paths_all_hang_off_the_data_dir() { |
| 573 | let config = Config { |
| 574 | bind: "127.0.0.1:8790".parse().unwrap(), |
| 575 | mode: Mode::Standalone, |
| 576 | data_dir: PathBuf::from("/srv/data"), |
| 577 | me_url: None, |
| 578 | ci_enabled: false, |
| 579 | disk_reserve_bytes: 0, |
| 580 | max_body_bytes: 0, |
| 581 | max_pack_bytes: 0, |
| 582 | max_blob_bytes: 0, |
| 583 | max_concurrent_git: 1, |
| 584 | default_page_limit: 50, |
| 585 | max_page_limit: 200, |
| 586 | header_timeout: Duration::from_secs(15), |
| 587 | ssh_idle_timeout: Duration::from_secs(600), |
| 588 | token_ttl_days: 90, |
| 589 | log_walk_budget: 1000, |
| 590 | max_repos_per_account: 100, |
| 591 | max_repo_bytes: 0, |
| 592 | max_account_bytes: 0, |
| 593 | rate_per_minute: 0, |
| 594 | gc_interval: Duration::from_secs(3600), |
| 595 | gc_prune_grace: "2.weeks.ago".into(), |
| 596 | deleted_retention: Duration::from_secs(604_800), |
| 597 | ci_timeout: Duration::from_secs(600), |
| 598 | ci_max_concurrent: 2, |
| 599 | ci_log_bytes: 1024 * 1024, |
| 600 | ci_memory_bytes: 0, |
| 601 | ci_file_bytes: 0, |
| 602 | ci_max_processes: 0, |
| 603 | ci_keep_runs: 50, |
| 604 | import_enabled: true, |
| 605 | import_timeout: Duration::from_secs(300), |
| 606 | import_allow_private: false, |
| 607 | control_key_file: PathBuf::from("/srv/data/control_ed25519_seed"), |
| 608 | control_public_keys: Vec::new(), |
| 609 | incus_image: "images:debian/12".into(), |
| 610 | incus_network: "incusbr0".into(), |
| 611 | tenant_port: 8790, |
| 612 | tenant_binary: format!("/usr/local/bin/{}", brand::NAME), |
| 613 | proxy_timeout: Duration::from_secs(300), |
| 614 | ssh_bind: None, |
| 615 | ssh_host_key: PathBuf::from("/srv/data/ssh_host_ed25519_key"), |
| 616 | }; |
| 617 | assert_eq!(config.tokens_file(), PathBuf::from("/srv/data/tokens.json")); |
| 618 | assert_eq!(config.meta_dir(), PathBuf::from("/srv/data/meta")); |
| 619 | } |
| 620 | } |