anvilsign in

collin/anvil

1//! Server configuration: loaded from a TOML file with sensible defaults.
2
3use std::path::{
4 Path,
5 PathBuf,
6};
7
8use serde::{
9 Deserialize,
10 Serialize,
11};
12
13use 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)]
23pub 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`.
44pub 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)]
53pub 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.
82pub 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)]
93pub 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)]
138pub 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)]
221pub 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)]
244pub 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)]
262pub 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
274impl 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
288impl 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
301impl 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
336impl 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
352impl 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
376impl 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
424impl 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
436impl 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
448impl 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
458impl 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)]
601mod 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}