| 1 | //! Server configuration: loaded from a TOML file with sensible defaults. |
| 2 | |
| 3 | use std::path::{ |
| 4 | Path, |
| 5 | PathBuf, |
| 6 | }; |
| 7 | |
| 8 | use serde::{ |
| 9 | Deserialize, |
| 10 | Serialize, |
| 11 | }; |
| 12 | |
| 13 | use crate::error::{ |
| 14 | Error, |
| 15 | Result, |
| 16 | }; |
| 17 | |
| 18 | /// Top-level anvil configuration. |
| 19 | /// |
| 20 | /// Load with [`Config::load`] (from a TOML file) or [`Config::default`]. |
| 21 | #[derive(Clone, Debug, Deserialize, Serialize)] |
| 22 | #[serde(default)] |
| 23 | pub struct Config { |
| 24 | /// Root directory holding all server state (database + repositories). |
| 25 | pub data_dir: PathBuf, |
| 26 | /// HTTP server settings. |
| 27 | pub http: HttpConfig, |
| 28 | /// SSH server settings. |
| 29 | pub ssh: SshConfig, |
| 30 | /// Continuous-deployment settings (the single-repo redeploy webhook). |
| 31 | pub ci: CiConfig, |
| 32 | /// Agent sessions (tmux + an agent CLI in a container, attachable from the |
| 33 | /// browser). Off unless `agent.enabled` is set. |
| 34 | pub agent: AgentConfig, |
| 35 | /// Periodic background job settings. |
| 36 | pub periodic: PeriodicConfig, |
| 37 | /// Single sign-on against an OpenID Connect provider. |
| 38 | pub oidc: OidcConfig, |
| 39 | } |
| 40 | |
| 41 | /// Path the provider redirects back to after an authorization. Registered at |
| 42 | /// the provider as this app's redirect URI, and matched there character for |
| 43 | /// character — see `docs/oidc.md`. |
| 44 | pub const OIDC_CALLBACK_PATH: &str = "/-/oidc/callback"; |
| 45 | |
| 46 | /// Sign-in delegated to an OpenID Connect provider (authorization code flow |
| 47 | /// with PKCE). Off unless [`issuer`](OidcConfig::issuer) is set, so an |
| 48 | /// unconfigured instance behaves exactly as it did before: local passwords |
| 49 | /// only. When on, it is *additional* — existing accounts keep their passwords, |
| 50 | /// and the two are reconciled on the `sub` claim. |
| 51 | #[derive(Clone, Debug, Deserialize, Serialize)] |
| 52 | #[serde(default)] |
| 53 | pub struct OidcConfig { |
| 54 | /// Issuer URL, e.g. `https://login.richardscollin.com`. Empty disables |
| 55 | /// single sign-on entirely. Discovery, and the `iss` claim every id token |
| 56 | /// is checked against, both come from this. |
| 57 | pub issuer: String, |
| 58 | /// Client id registered at the provider. Defaults to `anvil`. |
| 59 | pub client_id: String, |
| 60 | /// Client secret. Empty for a client registered as public — PKCE protects |
| 61 | /// the code either way. |
| 62 | pub client_secret: String, |
| 63 | /// Overrides the redirect URI, which otherwise is |
| 64 | /// `base_url` + [`OIDC_CALLBACK_PATH`]. Must match the provider's |
| 65 | /// allowlist exactly. |
| 66 | pub redirect_uri: String, |
| 67 | /// What the sign-in button says, after `Sign in with `. Defaults to the |
| 68 | /// issuer's hostname. |
| 69 | pub label: String, |
| 70 | /// Whether signing out of anvil also ends the provider's session (an |
| 71 | /// RP-initiated logout). Needs a post-logout URI registered for this |
| 72 | /// client, or the provider drops the user on its own page instead of |
| 73 | /// bringing them back. Defaults to `true`. |
| 74 | pub sso_logout: bool, |
| 75 | } |
| 76 | |
| 77 | /// The image both CI jobs and agent sessions default to: anvil's own runner, |
| 78 | /// built by `deploy/runner/build.sh` from `deploy/runner/Dockerfile`. It lives |
| 79 | /// only in the host's local Docker image store — there is no registry to pull |
| 80 | /// it from, so anything that starts a container from it must treat a failed |
| 81 | /// pull as non-fatal when the image is already present locally. |
| 82 | pub const DEFAULT_RUNNER_IMAGE: &str = "anvil-runner:latest"; |
| 83 | |
| 84 | /// Agent sessions: a long-lived container per session running tmux plus an |
| 85 | /// agent CLI, attachable from the browser (see `docs/agent-sessions.md`). |
| 86 | /// |
| 87 | /// Deliberately separate from [`CiConfig`] despite sharing the image and the |
| 88 | /// sandbox shape: sessions are long-lived and interactive where CI jobs are |
| 89 | /// short and headless, so the limits that matter differ (idle timeout and a |
| 90 | /// concurrency cap here; a wall-clock job timeout there). |
| 91 | #[derive(Clone, Debug, Deserialize, Serialize)] |
| 92 | #[serde(default)] |
| 93 | pub struct AgentConfig { |
| 94 | /// Whether agent sessions can be started at all. Off by default: a session |
| 95 | /// runs a model that reads repository content as instructions, which is a |
| 96 | /// different exposure from CI running code the pusher wrote. See |
| 97 | /// `docs/untrusted-mode.md`. |
| 98 | pub enabled: bool, |
| 99 | /// Image sessions run in. Defaults to [`DEFAULT_RUNNER_IMAGE`]. |
| 100 | pub image: String, |
| 101 | /// Directory on the anvil host holding the agent CLI's credentials and |
| 102 | /// settings (a `~/.claude` for Claude Code). Its contents are uploaded into |
| 103 | /// each session container as a tar, exactly as the checkout is — no bind |
| 104 | /// mount, so the container still cannot reach anvil's data directory. |
| 105 | /// Empty means sessions start without credentials. |
| 106 | pub credentials_dir: PathBuf, |
| 107 | /// Memory cap per session container, in MiB (swap capped to the same). |
| 108 | /// `0` means unlimited. Defaults to 4096 — Claude Code asks for 4 GB, so |
| 109 | /// CI's 2048 is not enough. |
| 110 | pub memory_mb: i64, |
| 111 | /// CPU cap per session container. `0` means unlimited. Defaults to 2. |
| 112 | pub cpus: f64, |
| 113 | /// Process-count cap inside a session container. tmux plus an agent plus |
| 114 | /// its subprocesses needs more headroom than a CI job. `0` means |
| 115 | /// unlimited. Defaults to 1024. |
| 116 | pub pids_limit: i64, |
| 117 | /// Seconds a session may go without an attached viewer *and* without |
| 118 | /// producing output before it is reaped. `0` disables the idle sweep. |
| 119 | /// Defaults to 3600. |
| 120 | pub idle_timeout_secs: u64, |
| 121 | /// Hard wall-clock cap on a session, in seconds, regardless of activity. |
| 122 | /// `0` disables it. Defaults to 86400 (24 hours). |
| 123 | pub max_lifetime_secs: u64, |
| 124 | /// How many sessions may run at once across the whole instance. Each holds |
| 125 | /// a container open, so this is the real resource bound. Defaults to 4. |
| 126 | pub max_concurrent: usize, |
| 127 | } |
| 128 | |
| 129 | /// CI configuration: job sandbox limits and the single-repo redeploy webhook. |
| 130 | /// |
| 131 | /// On a successful CI run of [`deploy_branch`](CiConfig::deploy_branch) in the |
| 132 | /// single repository named by [`deploy_repo`](CiConfig::deploy_repo), anvil |
| 133 | /// POSTs to [`deploy_webhook`](CiConfig::deploy_webhook). This is deliberately |
| 134 | /// scoped to **one** repository — no other repo can trigger the deploy, even |
| 135 | /// with its own passing CI. |
| 136 | #[derive(Clone, Debug, Deserialize, Serialize)] |
| 137 | #[serde(default)] |
| 138 | pub struct CiConfig { |
| 139 | /// Shared secret a runner presents as `X-Anvil-Runner-Token` to claim and |
| 140 | /// report jobs (see `docs/remote-runners.md`). **Empty refuses every |
| 141 | /// runner**, which means no CI runs at all — anvil does not execute jobs |
| 142 | /// itself any more. |
| 143 | /// |
| 144 | /// A config-file secret rather than a credential type of its own, matching |
| 145 | /// [`deploy_secret`](CiConfig::deploy_secret): API tokens are read-only and |
| 146 | /// Bearer-only on GET/HEAD, and a runner must POST. Per-runner DB-backed |
| 147 | /// tokens are the right end state; one shared secret is enough for a |
| 148 | /// single-tenant forge and needs no token-management UI. |
| 149 | pub runner_token: String, |
| 150 | /// The one repository (`owner/name`) permitted to trigger the deploy |
| 151 | /// webhook. Empty disables deploys entirely. |
| 152 | pub deploy_repo: String, |
| 153 | /// URL POSTed to when `deploy_repo`'s `deploy_branch` goes green. Should be |
| 154 | /// a host-local plaintext HTTP endpoint (a small deploy-script receiver); |
| 155 | /// HTTPS is intentionally unsupported to keep the build TLS-free. |
| 156 | pub deploy_webhook: String, |
| 157 | /// Shared secret sent as the `X-Anvil-Deploy-Secret` header so the receiver |
| 158 | /// can authenticate the call. Empty sends no header. |
| 159 | pub deploy_secret: String, |
| 160 | /// Branch whose successful run triggers a deploy. Defaults to `main`. |
| 161 | pub deploy_branch: String, |
| 162 | /// Images a pipeline may run in. Empty allows any image. An entry without a |
| 163 | /// tag (e.g. `rust`) allows every tag of that image; an entry with a tag |
| 164 | /// (e.g. `anvil-runner:rust`) allows exactly that image. |
| 165 | /// |
| 166 | /// [`default_image`](CiConfig::default_image) is always permitted, whatever |
| 167 | /// this says — otherwise an allowlist would break every pipeline that |
| 168 | /// simply omits `image:`. |
| 169 | pub allowed_images: Vec<String>, |
| 170 | /// Image used by a pipeline that omits `image:`. This is the shared anvil |
| 171 | /// runner (`deploy/runner/Dockerfile`) — the same image agent sessions run |
| 172 | /// in, carrying tmux, git, fish and Claude Code. Built locally rather than |
| 173 | /// pulled, which is why [`resolve_image`](CiConfig::resolve_image)'s caller |
| 174 | /// must tolerate a failed pull. |
| 175 | pub default_image: String, |
| 176 | /// Platform (`os/arch`) for a pipeline that omits `platform:`. Empty runs |
| 177 | /// every job on whatever the claiming runner is native to, which is the |
| 178 | /// behaviour of an instance that never sets this. |
| 179 | /// |
| 180 | /// Worth setting to the architecture you *deploy* on, on an instance whose |
| 181 | /// runners are a different one: an M-series Mac resolves a multi-arch image |
| 182 | /// to arm64 and will happily test an architecture you never ship. See |
| 183 | /// `docs/remote-runners.md`. |
| 184 | pub platform: String, |
| 185 | /// Memory cap for a job container, in MiB (swap is capped to the same |
| 186 | /// value). `0` means unlimited. Defaults to 2048. |
| 187 | pub memory_mb: i64, |
| 188 | /// CPU cap for a job container, in (possibly fractional) CPUs. `0` means |
| 189 | /// unlimited. Defaults to 2. |
| 190 | pub cpus: f64, |
| 191 | /// Process-count cap inside a job container. `0` means unlimited. |
| 192 | /// Defaults to 512. |
| 193 | pub pids_limit: i64, |
| 194 | /// Wall-clock timeout for a job, in seconds; on expiry the container is |
| 195 | /// force-removed and the run errors. `0` disables the timeout. Defaults to |
| 196 | /// 1800 (30 minutes). |
| 197 | pub timeout_secs: u64, |
| 198 | /// Whether job containers get network access (the default Docker network). |
| 199 | /// Most builds need it to fetch dependencies; disable for stricter |
| 200 | /// isolation. Defaults to `true`. |
| 201 | pub network: bool, |
| 202 | /// User to run the job as inside the container (`uid[:gid]` or a name known |
| 203 | /// to the image). Empty keeps the image's default user. Note many base |
| 204 | /// images assume root for e.g. `apt-get`. |
| 205 | pub run_as: String, |
| 206 | /// Size cap for a single artifact, in MiB; larger artifacts are skipped |
| 207 | /// (with a log note), never failing the run. `0` means unlimited. |
| 208 | /// Defaults to 256. |
| 209 | pub artifact_max_mb: i64, |
| 210 | /// Combined size cap for one run's artifacts, in MiB. Artifacts that would |
| 211 | /// push the run over it are skipped. `0` means unlimited. Defaults to 512. |
| 212 | pub artifact_run_max_mb: i64, |
| 213 | /// Combined artifact budget per repository, in MiB. After each run, oldest |
| 214 | /// commits' artifacts are deleted until the repo fits (branch-head commits |
| 215 | /// are pinned). `0` means unlimited. Defaults to 4096. |
| 216 | pub artifact_quota_mb: i64, |
| 217 | } |
| 218 | |
| 219 | #[derive(Clone, Debug, Deserialize, Serialize)] |
| 220 | #[serde(default)] |
| 221 | pub struct HttpConfig { |
| 222 | /// Address the HTTP server binds to, e.g. `127.0.0.1:3000`. |
| 223 | pub listen: String, |
| 224 | /// Externally visible base URL, used when constructing clone URLs. |
| 225 | pub base_url: String, |
| 226 | /// Memory budget, in MiB, for the cache of syntax-highlighted file views |
| 227 | /// (rendered HTML keyed by blob oid). Highlighting large files is the most |
| 228 | /// CPU-expensive page render, so repeat views are served from this cache. |
| 229 | /// `0` disables it — lowest memory, every view re-highlights. Defaults |
| 230 | /// to 16. |
| 231 | pub highlight_cache_mb: usize, |
| 232 | /// Maximum size, in MiB, of a single uploaded attachment (e.g. an image |
| 233 | /// pasted into the file editor). Uploads over this are rejected. Defaults |
| 234 | /// to 16. |
| 235 | pub attachment_max_mb: usize, |
| 236 | /// Per-repository cap, in MiB, on total stored attachments. A new upload |
| 237 | /// that would push a repo over this is rejected (re-uploading existing, |
| 238 | /// deduped content is always free). `0` means unlimited. Defaults to 0. |
| 239 | pub attachment_quota_mb: usize, |
| 240 | } |
| 241 | |
| 242 | #[derive(Clone, Debug, Deserialize, Serialize)] |
| 243 | #[serde(default)] |
| 244 | pub struct SshConfig { |
| 245 | /// Whether the SSH git transport is enabled. |
| 246 | pub enabled: bool, |
| 247 | /// Address the SSH server binds to internally, e.g. `0.0.0.0:2222`. Under |
| 248 | /// Docker this is the in-container bind, which may differ from the |
| 249 | /// externally forwarded port — see the `clone_*` fields below. |
| 250 | pub listen: String, |
| 251 | /// Hostname shown in SSH clone URLs (what users actually connect to). |
| 252 | pub clone_host: String, |
| 253 | /// Port shown in SSH clone URLs. Set this to the *externally forwarded* |
| 254 | /// port when it differs from the internal bind (e.g. Docker `-p 2200:2222`). |
| 255 | pub clone_port: u16, |
| 256 | /// Username shown in SSH clone URLs (conventionally `git`). |
| 257 | pub clone_user: String, |
| 258 | } |
| 259 | |
| 260 | #[derive(Clone, Debug, Deserialize, Serialize)] |
| 261 | #[serde(default)] |
| 262 | pub struct PeriodicConfig { |
| 263 | /// Interval (seconds) between repository language-detection scans. |
| 264 | /// Defaults to 3600 (1 hour). |
| 265 | pub language_detection_interval_secs: u64, |
| 266 | /// Interval (seconds) between repository preview-image extractions from README. |
| 267 | /// Defaults to 3600 (1 hour). |
| 268 | pub preview_image_interval_secs: u64, |
| 269 | /// Interval (seconds) between disk-usage cache refreshes. |
| 270 | /// Defaults to 3600 (1 hour). |
| 271 | pub disk_usage_interval_secs: u64, |
| 272 | } |
| 273 | |
| 274 | impl Default for Config { |
| 275 | fn default() -> Self { |
| 276 | Self { |
| 277 | data_dir: PathBuf::from("data"), |
| 278 | http: HttpConfig::default(), |
| 279 | ssh: SshConfig::default(), |
| 280 | ci: CiConfig::default(), |
| 281 | agent: AgentConfig::default(), |
| 282 | periodic: PeriodicConfig::default(), |
| 283 | oidc: OidcConfig::default(), |
| 284 | } |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | impl Default for OidcConfig { |
| 289 | fn default() -> Self { |
| 290 | Self { |
| 291 | issuer: String::new(), |
| 292 | client_id: "anvil".to_string(), |
| 293 | client_secret: String::new(), |
| 294 | redirect_uri: String::new(), |
| 295 | label: String::new(), |
| 296 | sso_logout: true, |
| 297 | } |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | impl OidcConfig { |
| 302 | /// Whether single sign-on is configured at all. |
| 303 | pub fn enabled(&self) -> bool { |
| 304 | !self.issuer.is_empty() |
| 305 | } |
| 306 | |
| 307 | /// The issuer with any trailing slashes removed — the exact string the |
| 308 | /// `iss` claim must equal, and the prefix every endpoint is built from. |
| 309 | pub fn issuer(&self) -> &str { |
| 310 | self.issuer.trim_end_matches('/') |
| 311 | } |
| 312 | |
| 313 | /// Text for the sign-in button, after `Sign in with `. The configured |
| 314 | /// label wins; otherwise the issuer's host, with a leading `login.` peeled |
| 315 | /// off when a domain is left over — `login.richardscollin.com` reads |
| 316 | /// better as `richardscollin.com`, while `login.localhost` must keep its |
| 317 | /// prefix or it would collapse to a bare `localhost`. |
| 318 | pub fn label(&self) -> String { |
| 319 | if !self.label.is_empty() { |
| 320 | return self.label.clone(); |
| 321 | } |
| 322 | let host = self |
| 323 | .issuer() |
| 324 | .split_once("://") |
| 325 | .map_or(self.issuer(), |(_, rest)| rest) |
| 326 | .split(['/', ':']) |
| 327 | .next() |
| 328 | .unwrap_or_default(); |
| 329 | match host.strip_prefix("login.") { |
| 330 | Some(domain) if domain.contains('.') => domain.to_string(), |
| 331 | _ => host.to_string(), |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | impl Default for AgentConfig { |
| 337 | fn default() -> Self { |
| 338 | Self { |
| 339 | enabled: false, |
| 340 | image: DEFAULT_RUNNER_IMAGE.to_string(), |
| 341 | credentials_dir: PathBuf::new(), |
| 342 | memory_mb: 4096, |
| 343 | cpus: 2.0, |
| 344 | pids_limit: 1024, |
| 345 | idle_timeout_secs: 3600, |
| 346 | max_lifetime_secs: 86400, |
| 347 | max_concurrent: 4, |
| 348 | } |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | impl Default for CiConfig { |
| 353 | fn default() -> Self { |
| 354 | Self { |
| 355 | runner_token: String::new(), |
| 356 | deploy_repo: String::new(), |
| 357 | deploy_webhook: String::new(), |
| 358 | deploy_secret: String::new(), |
| 359 | deploy_branch: "main".to_string(), |
| 360 | allowed_images: Vec::new(), |
| 361 | default_image: DEFAULT_RUNNER_IMAGE.to_string(), |
| 362 | platform: String::new(), |
| 363 | memory_mb: 2048, |
| 364 | cpus: 2.0, |
| 365 | pids_limit: 512, |
| 366 | timeout_secs: 1800, |
| 367 | network: true, |
| 368 | run_as: String::new(), |
| 369 | artifact_max_mb: 256, |
| 370 | artifact_run_max_mb: 512, |
| 371 | artifact_quota_mb: 4096, |
| 372 | } |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | impl CiConfig { |
| 377 | /// Whether `owner/name` on `branch` is the configured deploy target. |
| 378 | pub fn is_deploy_target(&self, owner: &str, name: &str, branch: &str) -> bool { |
| 379 | !self.deploy_repo.is_empty() |
| 380 | && !self.deploy_webhook.is_empty() |
| 381 | && self.deploy_repo == format!("{owner}/{name}") |
| 382 | && self.deploy_branch == branch |
| 383 | } |
| 384 | |
| 385 | /// The image a pipeline runs in: what it asked for, or |
| 386 | /// [`default_image`](CiConfig::default_image) when it omitted `image:`. |
| 387 | pub fn resolve_image<'a>(&'a self, requested: &'a str) -> &'a str { |
| 388 | if requested.is_empty() { |
| 389 | &self.default_image |
| 390 | } else { |
| 391 | requested |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | /// The platform a pipeline runs on: what it asked for, then |
| 396 | /// [`platform`](CiConfig::platform), then `None` — meaning the claiming |
| 397 | /// runner's native architecture, and no constraint on which runner that is. |
| 398 | pub fn resolve_platform<'a>(&'a self, requested: &'a str) -> Option<&'a str> { |
| 399 | let platform = if requested.is_empty() { |
| 400 | self.platform.as_str() |
| 401 | } else { |
| 402 | requested |
| 403 | }; |
| 404 | (!platform.is_empty()).then_some(platform) |
| 405 | } |
| 406 | |
| 407 | /// Whether `image` passes [`allowed_images`](CiConfig::allowed_images). |
| 408 | /// An empty allowlist permits any image; a tagless entry permits every tag |
| 409 | /// of that image; a tagged entry permits exactly itself. The default image |
| 410 | /// is always permitted. |
| 411 | pub fn image_allowed(&self, image: &str) -> bool { |
| 412 | image == self.default_image |
| 413 | || self.allowed_images.is_empty() |
| 414 | || self.allowed_images.iter().any(|allowed| { |
| 415 | image == allowed |
| 416 | || (!allowed.contains(':') |
| 417 | && image |
| 418 | .strip_prefix(allowed.as_str()) |
| 419 | .is_some_and(|rest| rest.starts_with(':'))) |
| 420 | }) |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | impl Default for HttpConfig { |
| 425 | fn default() -> Self { |
| 426 | Self { |
| 427 | listen: "127.0.0.1:3000".to_string(), |
| 428 | base_url: "http://localhost:3000".to_string(), |
| 429 | highlight_cache_mb: 16, |
| 430 | attachment_max_mb: 16, |
| 431 | attachment_quota_mb: 0, |
| 432 | } |
| 433 | } |
| 434 | } |
| 435 | |
| 436 | impl Default for SshConfig { |
| 437 | fn default() -> Self { |
| 438 | Self { |
| 439 | enabled: false, |
| 440 | listen: "127.0.0.1:2222".to_string(), |
| 441 | clone_host: "localhost".to_string(), |
| 442 | clone_port: 2222, |
| 443 | clone_user: "git".to_string(), |
| 444 | } |
| 445 | } |
| 446 | } |
| 447 | |
| 448 | impl Default for PeriodicConfig { |
| 449 | fn default() -> Self { |
| 450 | Self { |
| 451 | language_detection_interval_secs: 3600, |
| 452 | preview_image_interval_secs: 3600, |
| 453 | disk_usage_interval_secs: 3600, |
| 454 | } |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | impl Config { |
| 459 | /// Load configuration from a TOML file. Missing fields fall back to defaults. |
| 460 | pub fn load(path: impl AsRef<Path>) -> Result<Self> { |
| 461 | let path = path.as_ref(); |
| 462 | let text = std::fs::read_to_string(path) |
| 463 | .map_err(|e| Error::Config(format!("reading {}: {e}", path.display())))?; |
| 464 | toml::from_str(&text).map_err(|e| Error::Config(format!("parsing {}: {e}", path.display()))) |
| 465 | } |
| 466 | |
| 467 | /// Load from `path` if it exists, otherwise return defaults. Environment |
| 468 | /// overrides are applied either way — see [`Config::apply_env`]. |
| 469 | pub fn load_or_default(path: impl AsRef<Path>) -> Result<Self> { |
| 470 | let path = path.as_ref(); |
| 471 | let mut config = if path.exists() { |
| 472 | Self::load(path)? |
| 473 | } else { |
| 474 | Self::default() |
| 475 | }; |
| 476 | config.apply_env(|key| std::env::var(key).ok()); |
| 477 | Ok(config) |
| 478 | } |
| 479 | |
| 480 | /// Overlay environment variables onto a loaded config, so a supervisor can |
| 481 | /// place anvil wherever it likes without a config file. |
| 482 | /// |
| 483 | /// - `ANVIL_LISTEN`, or `HOST`/`PORT` — the bind address. `PORT` (with |
| 484 | /// `HOST` defaulting to `127.0.0.1`) is the convention process managers |
| 485 | /// and local proxies use; portless, for one, hands the app a free port in |
| 486 | /// 4000-4999 and reverse-proxies a `.localhost` name to it. |
| 487 | /// - `ANVIL_BASE_URL`, or `PORTLESS_URL` — the externally visible URL that |
| 488 | /// clone commands and links are built from. Getting this right is what |
| 489 | /// makes the UI usable behind a proxy: the bind port is an implementation |
| 490 | /// detail, `https://anvil.localhost` is the address users see. |
| 491 | /// |
| 492 | /// Explicit `ANVIL_*` wins over the generic name, and both win over the |
| 493 | /// file, on the usual "closest to the invocation" principle. |
| 494 | pub fn apply_env(&mut self, env: impl Fn(&str) -> Option<String>) { |
| 495 | if let Some(listen) = env("ANVIL_LISTEN") { |
| 496 | self.http.listen = listen; |
| 497 | } else if let Some(port) = env("PORT").filter(|p| p.parse::<u16>().is_ok()) { |
| 498 | let host = env("HOST").unwrap_or_else(|| "127.0.0.1".to_string()); |
| 499 | self.http.listen = format!("{host}:{port}"); |
| 500 | } |
| 501 | if let Some(base) = env("ANVIL_BASE_URL").or_else(|| env("PORTLESS_URL")) { |
| 502 | self.http.base_url = base.trim_end_matches('/').to_string(); |
| 503 | } |
| 504 | if let Some(dir) = env("ANVIL_DATA_DIR") { |
| 505 | self.data_dir = dir.into(); |
| 506 | } |
| 507 | // Single sign-on. The secret especially wants an env var: config files |
| 508 | // get committed, and this one must not be. |
| 509 | if let Some(issuer) = env("ANVIL_OIDC_ISSUER") { |
| 510 | self.oidc.issuer = issuer.trim().trim_end_matches('/').to_string(); |
| 511 | } |
| 512 | if let Some(id) = env("ANVIL_OIDC_CLIENT_ID") { |
| 513 | self.oidc.client_id = id; |
| 514 | } |
| 515 | if let Some(secret) = env("ANVIL_OIDC_CLIENT_SECRET") { |
| 516 | self.oidc.client_secret = secret; |
| 517 | } |
| 518 | if let Some(uri) = env("ANVIL_OIDC_REDIRECT_URI") { |
| 519 | self.oidc.redirect_uri = uri; |
| 520 | } |
| 521 | } |
| 522 | |
| 523 | /// The redirect URI handed to the provider: the configured override, or |
| 524 | /// [`OIDC_CALLBACK_PATH`] on the public base URL. |
| 525 | pub fn oidc_redirect_uri(&self) -> String { |
| 526 | if !self.oidc.redirect_uri.is_empty() { |
| 527 | return self.oidc.redirect_uri.clone(); |
| 528 | } |
| 529 | format!( |
| 530 | "{}{OIDC_CALLBACK_PATH}", |
| 531 | self.http.base_url.trim_end_matches('/') |
| 532 | ) |
| 533 | } |
| 534 | |
| 535 | /// Filesystem path to the SQLite database file. |
| 536 | pub fn database_path(&self) -> PathBuf { |
| 537 | self.data_dir.join("anvil.db") |
| 538 | } |
| 539 | |
| 540 | /// Root directory under which bare repositories are stored. |
| 541 | pub fn repositories_dir(&self) -> PathBuf { |
| 542 | self.data_dir.join("repositories") |
| 543 | } |
| 544 | |
| 545 | /// Root directory under which CI artifacts are stored |
| 546 | /// (`artifacts/{repo_id}/{commit}/…` — see `docs/ci-artifacts.md`). |
| 547 | pub fn artifacts_dir(&self) -> PathBuf { |
| 548 | self.data_dir.join("artifacts") |
| 549 | } |
| 550 | |
| 551 | /// Root directory under which agent-session transcripts are stored |
| 552 | /// (`sessions/{session_id}.log`). On disk rather than in a column because a |
| 553 | /// terminal transcript grows continuously, and `CiRun.log` — the only |
| 554 | /// precedent — is rewritten whole on every append. |
| 555 | pub fn sessions_dir(&self) -> PathBuf { |
| 556 | self.data_dir.join("sessions") |
| 557 | } |
| 558 | |
| 559 | /// Root directory under which uploaded attachments are stored |
| 560 | /// (`attachments/{repo_id}/{hash}`). Kept out of `repositories/` so the |
| 561 | /// files are never git objects. |
| 562 | pub fn attachments_dir(&self) -> PathBuf { |
| 563 | self.data_dir.join("attachments") |
| 564 | } |
| 565 | |
| 566 | /// Whether session cookies should carry the `Secure` attribute (HTTPS-only). |
| 567 | /// Derived from the public base URL's scheme, so local plaintext dev still |
| 568 | /// works while production behind TLS gets `Secure` automatically. |
| 569 | pub fn secure_cookies(&self) -> bool { |
| 570 | self.http.base_url.starts_with("https://") |
| 571 | } |
| 572 | |
| 573 | /// The HTTP clone URL for `<owner>/<name>`, e.g. |
| 574 | /// `http://localhost:3000/alice/hello.git`. |
| 575 | pub fn http_clone_url(&self, owner: &str, name: &str) -> String { |
| 576 | format!( |
| 577 | "{}/{owner}/{name}.git", |
| 578 | self.http.base_url.trim_end_matches('/') |
| 579 | ) |
| 580 | } |
| 581 | |
| 582 | /// The SSH clone URL for `<owner>/<name>`, using the externally advertised |
| 583 | /// host/port/user (which may differ from the internal bind under Docker). |
| 584 | /// On the SSH default (22), this is the scp-like `user@host:path` form, |
| 585 | /// which needs no `ssh://` scheme or port; a non-default port can only be |
| 586 | /// expressed with the `ssh://` form, so that's used instead. |
| 587 | pub fn ssh_clone_url(&self, owner: &str, name: &str) -> String { |
| 588 | let ssh = &self.ssh; |
| 589 | if ssh.clone_port == 22 { |
| 590 | format!("{}@{}:{owner}/{name}.git", ssh.clone_user, ssh.clone_host) |
| 591 | } else { |
| 592 | format!( |
| 593 | "ssh://{}@{}:{}/{owner}/{name}.git", |
| 594 | ssh.clone_user, ssh.clone_host, ssh.clone_port |
| 595 | ) |
| 596 | } |
| 597 | } |
| 598 | } |
| 599 | |
| 600 | #[cfg(test)] |
| 601 | mod tests { |
| 602 | use super::*; |
| 603 | |
| 604 | /// Look up from a fixed list, standing in for the process environment. |
| 605 | fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a { |
| 606 | move |key| { |
| 607 | pairs |
| 608 | .iter() |
| 609 | .find(|(k, _)| *k == key) |
| 610 | .map(|(_, v)| v.to_string()) |
| 611 | } |
| 612 | } |
| 613 | |
| 614 | #[test] |
| 615 | fn an_omitted_image_resolves_to_the_shared_runner() { |
| 616 | let ci = CiConfig::default(); |
| 617 | assert_eq!(ci.resolve_image(""), DEFAULT_RUNNER_IMAGE); |
| 618 | assert_eq!(ci.resolve_image("anvil-runner:rust"), "anvil-runner:rust"); |
| 619 | } |
| 620 | |
| 621 | /// An allowlist must not lock out the default image: a pipeline that simply |
| 622 | /// omits `image:` never named anything for the operator to allow, and |
| 623 | /// failing those runs is the regression this whole path risks. |
| 624 | #[test] |
| 625 | fn the_default_image_is_allowed_even_under_an_allowlist() { |
| 626 | let ci = CiConfig { |
| 627 | allowed_images: vec!["alpine:3.20".to_string()], |
| 628 | ..CiConfig::default() |
| 629 | }; |
| 630 | assert!(ci.image_allowed(DEFAULT_RUNNER_IMAGE)); |
| 631 | assert!(ci.image_allowed("alpine:3.20")); |
| 632 | assert!(!ci.image_allowed("anvil-runner:rust")); |
| 633 | } |
| 634 | |
| 635 | /// Platform resolution has three levels, and the bottom one is "whatever |
| 636 | /// the runner is", not a hardcoded architecture — an instance that never |
| 637 | /// sets this behaves exactly as it did before platforms existed. |
| 638 | #[test] |
| 639 | fn a_platform_falls_back_from_pipeline_to_config_to_the_runner() { |
| 640 | let ci = CiConfig::default(); |
| 641 | assert_eq!(ci.resolve_platform(""), None); |
| 642 | assert_eq!(ci.resolve_platform("linux/arm64"), Some("linux/arm64")); |
| 643 | |
| 644 | let ci = CiConfig { |
| 645 | platform: "linux/amd64".to_string(), |
| 646 | ..CiConfig::default() |
| 647 | }; |
| 648 | assert_eq!(ci.resolve_platform(""), Some("linux/amd64")); |
| 649 | // A pipeline that names one still wins over the instance default. |
| 650 | assert_eq!(ci.resolve_platform("linux/arm64"), Some("linux/arm64")); |
| 651 | } |
| 652 | |
| 653 | /// Agent sessions are off unless the operator turns them on — they run a |
| 654 | /// model over repository content, which `docs/untrusted-mode.md` treats as |
| 655 | /// a different exposure from CI. |
| 656 | #[test] |
| 657 | fn agent_sessions_default_to_off_and_share_the_runner_image() { |
| 658 | let agent = AgentConfig::default(); |
| 659 | assert!(!agent.enabled); |
| 660 | assert_eq!(agent.image, DEFAULT_RUNNER_IMAGE); |
| 661 | assert_eq!(agent.image, CiConfig::default().default_image); |
| 662 | } |
| 663 | |
| 664 | #[test] |
| 665 | fn port_and_host_set_the_bind_address() { |
| 666 | let mut config = Config::default(); |
| 667 | config.apply_env(env_of(&[("PORT", "4738")])); |
| 668 | assert_eq!(config.http.listen, "127.0.0.1:4738"); |
| 669 | |
| 670 | let mut config = Config::default(); |
| 671 | config.apply_env(env_of(&[("PORT", "4738"), ("HOST", "0.0.0.0")])); |
| 672 | assert_eq!(config.http.listen, "0.0.0.0:4738"); |
| 673 | } |
| 674 | |
| 675 | #[test] |
| 676 | fn anvil_listen_wins_over_port() { |
| 677 | let mut config = Config::default(); |
| 678 | config.apply_env(env_of(&[ |
| 679 | ("PORT", "4738"), |
| 680 | ("ANVIL_LISTEN", "0.0.0.0:9000"), |
| 681 | ])); |
| 682 | assert_eq!(config.http.listen, "0.0.0.0:9000"); |
| 683 | } |
| 684 | |
| 685 | #[test] |
| 686 | fn a_nonsense_port_leaves_the_configured_address_alone() { |
| 687 | let mut config = Config::default(); |
| 688 | config.apply_env(env_of(&[("PORT", "not-a-port")])); |
| 689 | assert_eq!(config.http.listen, "127.0.0.1:3000"); |
| 690 | } |
| 691 | |
| 692 | #[test] |
| 693 | fn proxy_url_becomes_the_base_url() { |
| 694 | let mut config = Config::default(); |
| 695 | config.apply_env(env_of(&[("PORTLESS_URL", "https://anvil.localhost/")])); |
| 696 | assert_eq!(config.http.base_url, "https://anvil.localhost"); |
| 697 | // …and drives the Secure cookie attribute, since it is https. |
| 698 | assert!(config.secure_cookies()); |
| 699 | |
| 700 | let mut config = Config::default(); |
| 701 | config.apply_env(env_of(&[ |
| 702 | ("PORTLESS_URL", "https://anvil.localhost"), |
| 703 | ("ANVIL_BASE_URL", "https://forge.example.com"), |
| 704 | ])); |
| 705 | assert_eq!(config.http.base_url, "https://forge.example.com"); |
| 706 | } |
| 707 | |
| 708 | #[test] |
| 709 | fn an_empty_environment_changes_nothing() { |
| 710 | let mut config = Config::default(); |
| 711 | config.apply_env(env_of(&[])); |
| 712 | assert_eq!(config.http.listen, HttpConfig::default().listen); |
| 713 | assert_eq!(config.http.base_url, HttpConfig::default().base_url); |
| 714 | } |
| 715 | |
| 716 | #[test] |
| 717 | fn image_allowlist_semantics() { |
| 718 | let mut ci = CiConfig::default(); |
| 719 | assert!(ci.image_allowed("anything:latest"), "empty list allows all"); |
| 720 | |
| 721 | ci.allowed_images = vec!["rust".to_string(), "alpine:3.20".to_string()]; |
| 722 | assert!(ci.image_allowed("rust"), "tagless entry, tagless image"); |
| 723 | assert!( |
| 724 | ci.image_allowed("rust:1.98"), |
| 725 | "tagless entry allows any tag" |
| 726 | ); |
| 727 | assert!(ci.image_allowed("alpine:3.20"), "tagged entry, exact match"); |
| 728 | assert!(!ci.image_allowed("alpine:3.21"), "tagged entry, other tag"); |
| 729 | assert!(!ci.image_allowed("alpine"), "tagged entry, tagless image"); |
| 730 | assert!( |
| 731 | !ci.image_allowed("rustlang/rust:nightly"), |
| 732 | "no prefix bleed" |
| 733 | ); |
| 734 | assert!(!ci.image_allowed("rusty:latest"), "no name-prefix bleed"); |
| 735 | } |
| 736 | } |