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 /// Periodic background job settings.
33 pub periodic: PeriodicConfig,
34}
35
36/// CI configuration: job sandbox limits and the single-repo redeploy webhook.
37///
38/// On a successful CI run of [`deploy_branch`](CiConfig::deploy_branch) in the
39/// single repository named by [`deploy_repo`](CiConfig::deploy_repo), anvil
40/// POSTs to [`deploy_webhook`](CiConfig::deploy_webhook). This is deliberately
41/// scoped to **one** repository — no other repo can trigger the deploy, even
42/// with its own passing CI.
43#[derive(Clone, Debug, Deserialize, Serialize)]
44#[serde(default)]
45pub struct CiConfig {
46 /// The one repository (`owner/name`) permitted to trigger the deploy
47 /// webhook. Empty disables deploys entirely.
48 pub deploy_repo: String,
49 /// URL POSTed to when `deploy_repo`'s `deploy_branch` goes green. Should be
50 /// a host-local plaintext HTTP endpoint (a small deploy-script receiver);
51 /// HTTPS is intentionally unsupported to keep the build TLS-free.
52 pub deploy_webhook: String,
53 /// Shared secret sent as the `X-Anvil-Deploy-Secret` header so the receiver
54 /// can authenticate the call. Empty sends no header.
55 pub deploy_secret: String,
56 /// Branch whose successful run triggers a deploy. Defaults to `main`.
57 pub deploy_branch: String,
58 /// Images a pipeline may run in. Empty allows any image. An entry without a
59 /// tag (e.g. `rust`) allows every tag of that image; an entry with a tag
60 /// (e.g. `rust:1.95-bookworm`) allows exactly that image.
61 pub allowed_images: Vec<String>,
62 /// Memory cap for a job container, in MiB (swap is capped to the same
63 /// value). `0` means unlimited. Defaults to 2048.
64 pub memory_mb: i64,
65 /// CPU cap for a job container, in (possibly fractional) CPUs. `0` means
66 /// unlimited. Defaults to 2.
67 pub cpus: f64,
68 /// Process-count cap inside a job container. `0` means unlimited.
69 /// Defaults to 512.
70 pub pids_limit: i64,
71 /// Wall-clock timeout for a job, in seconds; on expiry the container is
72 /// force-removed and the run errors. `0` disables the timeout. Defaults to
73 /// 1800 (30 minutes).
74 pub timeout_secs: u64,
75 /// Whether job containers get network access (the default Docker network).
76 /// Most builds need it to fetch dependencies; disable for stricter
77 /// isolation. Defaults to `true`.
78 pub network: bool,
79 /// User to run the job as inside the container (`uid[:gid]` or a name known
80 /// to the image). Empty keeps the image's default user. Note many base
81 /// images assume root for e.g. `apt-get`.
82 pub run_as: String,
83 /// Size cap for a single artifact, in MiB; larger artifacts are skipped
84 /// (with a log note), never failing the run. `0` means unlimited.
85 /// Defaults to 256.
86 pub artifact_max_mb: i64,
87 /// Combined size cap for one run's artifacts, in MiB. Artifacts that would
88 /// push the run over it are skipped. `0` means unlimited. Defaults to 512.
89 pub artifact_run_max_mb: i64,
90 /// Combined artifact budget per repository, in MiB. After each run, oldest
91 /// commits' artifacts are deleted until the repo fits (branch-head commits
92 /// are pinned). `0` means unlimited. Defaults to 4096.
93 pub artifact_quota_mb: i64,
94}
95
96#[derive(Clone, Debug, Deserialize, Serialize)]
97#[serde(default)]
98pub struct HttpConfig {
99 /// Address the HTTP server binds to, e.g. `127.0.0.1:3000`.
100 pub listen: String,
101 /// Externally visible base URL, used when constructing clone URLs.
102 pub base_url: String,
103 /// Memory budget, in MiB, for the cache of syntax-highlighted file views
104 /// (rendered HTML keyed by blob oid). Highlighting large files is the most
105 /// CPU-expensive page render, so repeat views are served from this cache.
106 /// `0` disables it — lowest memory, every view re-highlights. Defaults
107 /// to 16.
108 pub highlight_cache_mb: usize,
109 /// Maximum size, in MiB, of a single uploaded attachment (e.g. an image
110 /// pasted into the file editor). Uploads over this are rejected. Defaults
111 /// to 16.
112 pub attachment_max_mb: usize,
113 /// Per-repository cap, in MiB, on total stored attachments. A new upload
114 /// that would push a repo over this is rejected (re-uploading existing,
115 /// deduped content is always free). `0` means unlimited. Defaults to 0.
116 pub attachment_quota_mb: usize,
117}
118
119#[derive(Clone, Debug, Deserialize, Serialize)]
120#[serde(default)]
121pub struct SshConfig {
122 /// Whether the SSH git transport is enabled.
123 pub enabled: bool,
124 /// Address the SSH server binds to internally, e.g. `0.0.0.0:2222`. Under
125 /// Docker this is the in-container bind, which may differ from the
126 /// externally forwarded port — see the `clone_*` fields below.
127 pub listen: String,
128 /// Hostname shown in SSH clone URLs (what users actually connect to).
129 pub clone_host: String,
130 /// Port shown in SSH clone URLs. Set this to the *externally forwarded*
131 /// port when it differs from the internal bind (e.g. Docker `-p 2200:2222`).
132 pub clone_port: u16,
133 /// Username shown in SSH clone URLs (conventionally `git`).
134 pub clone_user: String,
135}
136
137#[derive(Clone, Debug, Deserialize, Serialize)]
138#[serde(default)]
139pub struct PeriodicConfig {
140 /// Interval (seconds) between repository language-detection scans.
141 /// Defaults to 3600 (1 hour).
142 pub language_detection_interval_secs: u64,
143 /// Interval (seconds) between repository preview-image extractions from README.
144 /// Defaults to 3600 (1 hour).
145 pub preview_image_interval_secs: u64,
146 /// Interval (seconds) between disk-usage cache refreshes.
147 /// Defaults to 3600 (1 hour).
148 pub disk_usage_interval_secs: u64,
149}
150
151impl Default for Config {
152 fn default() -> Self {
153 Self {
154 data_dir: PathBuf::from("data"),
155 http: HttpConfig::default(),
156 ssh: SshConfig::default(),
157 ci: CiConfig::default(),
158 periodic: PeriodicConfig::default(),
159 }
160 }
161}
162
163impl Default for CiConfig {
164 fn default() -> Self {
165 Self {
166 deploy_repo: String::new(),
167 deploy_webhook: String::new(),
168 deploy_secret: String::new(),
169 deploy_branch: "main".to_string(),
170 allowed_images: Vec::new(),
171 memory_mb: 2048,
172 cpus: 2.0,
173 pids_limit: 512,
174 timeout_secs: 1800,
175 network: true,
176 run_as: String::new(),
177 artifact_max_mb: 256,
178 artifact_run_max_mb: 512,
179 artifact_quota_mb: 4096,
180 }
181 }
182}
183
184impl CiConfig {
185 /// Whether `owner/name` on `branch` is the configured deploy target.
186 pub fn is_deploy_target(&self, owner: &str, name: &str, branch: &str) -> bool {
187 !self.deploy_repo.is_empty()
188 && !self.deploy_webhook.is_empty()
189 && self.deploy_repo == format!("{owner}/{name}")
190 && self.deploy_branch == branch
191 }
192
193 /// Whether `image` passes [`allowed_images`](CiConfig::allowed_images).
194 /// An empty allowlist permits any image; a tagless entry permits every tag
195 /// of that image; a tagged entry permits exactly itself.
196 pub fn image_allowed(&self, image: &str) -> bool {
197 self.allowed_images.is_empty()
198 || self.allowed_images.iter().any(|allowed| {
199 image == allowed
200 || (!allowed.contains(':')
201 && image
202 .strip_prefix(allowed.as_str())
203 .is_some_and(|rest| rest.starts_with(':')))
204 })
205 }
206}
207
208impl Default for HttpConfig {
209 fn default() -> Self {
210 Self {
211 listen: "127.0.0.1:3000".to_string(),
212 base_url: "http://localhost:3000".to_string(),
213 highlight_cache_mb: 16,
214 attachment_max_mb: 16,
215 attachment_quota_mb: 0,
216 }
217 }
218}
219
220impl Default for SshConfig {
221 fn default() -> Self {
222 Self {
223 enabled: false,
224 listen: "127.0.0.1:2222".to_string(),
225 clone_host: "localhost".to_string(),
226 clone_port: 2222,
227 clone_user: "git".to_string(),
228 }
229 }
230}
231
232impl Default for PeriodicConfig {
233 fn default() -> Self {
234 Self {
235 language_detection_interval_secs: 3600,
236 preview_image_interval_secs: 3600,
237 disk_usage_interval_secs: 3600,
238 }
239 }
240}
241
242impl Config {
243 /// Load configuration from a TOML file. Missing fields fall back to defaults.
244 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
245 let path = path.as_ref();
246 let text = std::fs::read_to_string(path)
247 .map_err(|e| Error::Config(format!("reading {}: {e}", path.display())))?;
248 toml::from_str(&text).map_err(|e| Error::Config(format!("parsing {}: {e}", path.display())))
249 }
250
251 /// Load from `path` if it exists, otherwise return defaults. Environment
252 /// overrides are applied either way — see [`Config::apply_env`].
253 pub fn load_or_default(path: impl AsRef<Path>) -> Result<Self> {
254 let path = path.as_ref();
255 let mut config = if path.exists() {
256 Self::load(path)?
257 } else {
258 Self::default()
259 };
260 config.apply_env(|key| std::env::var(key).ok());
261 Ok(config)
262 }
263
264 /// Overlay environment variables onto a loaded config, so a supervisor can
265 /// place anvil wherever it likes without a config file.
266 ///
267 /// - `ANVIL_LISTEN`, or `HOST`/`PORT` — the bind address. `PORT` (with
268 /// `HOST` defaulting to `127.0.0.1`) is the convention process managers
269 /// and local proxies use; portless, for one, hands the app a free port in
270 /// 4000-4999 and reverse-proxies a `.localhost` name to it.
271 /// - `ANVIL_BASE_URL`, or `PORTLESS_URL` — the externally visible URL that
272 /// clone commands and links are built from. Getting this right is what
273 /// makes the UI usable behind a proxy: the bind port is an implementation
274 /// detail, `https://anvil.localhost` is the address users see.
275 ///
276 /// Explicit `ANVIL_*` wins over the generic name, and both win over the
277 /// file, on the usual "closest to the invocation" principle.
278 pub fn apply_env(&mut self, env: impl Fn(&str) -> Option<String>) {
279 if let Some(listen) = env("ANVIL_LISTEN") {
280 self.http.listen = listen;
281 } else if let Some(port) = env("PORT").filter(|p| p.parse::<u16>().is_ok()) {
282 let host = env("HOST").unwrap_or_else(|| "127.0.0.1".to_string());
283 self.http.listen = format!("{host}:{port}");
284 }
285 if let Some(base) = env("ANVIL_BASE_URL").or_else(|| env("PORTLESS_URL")) {
286 self.http.base_url = base.trim_end_matches('/').to_string();
287 }
288 if let Some(dir) = env("ANVIL_DATA_DIR") {
289 self.data_dir = dir.into();
290 }
291 }
292
293 /// Filesystem path to the SQLite database file.
294 pub fn database_path(&self) -> PathBuf {
295 self.data_dir.join("anvil.db")
296 }
297
298 /// Root directory under which bare repositories are stored.
299 pub fn repositories_dir(&self) -> PathBuf {
300 self.data_dir.join("repositories")
301 }
302
303 /// Root directory under which CI artifacts are stored
304 /// (`artifacts/{repo_id}/{commit}/…` — see `docs/ci-artifacts.md`).
305 pub fn artifacts_dir(&self) -> PathBuf {
306 self.data_dir.join("artifacts")
307 }
308
309 /// Root directory under which uploaded attachments are stored
310 /// (`attachments/{repo_id}/{hash}`). Kept out of `repositories/` so the
311 /// files are never git objects.
312 pub fn attachments_dir(&self) -> PathBuf {
313 self.data_dir.join("attachments")
314 }
315
316 /// Whether session cookies should carry the `Secure` attribute (HTTPS-only).
317 /// Derived from the public base URL's scheme, so local plaintext dev still
318 /// works while production behind TLS gets `Secure` automatically.
319 pub fn secure_cookies(&self) -> bool {
320 self.http.base_url.starts_with("https://")
321 }
322
323 /// The HTTP clone URL for `<owner>/<name>`, e.g.
324 /// `http://localhost:3000/alice/hello.git`.
325 pub fn http_clone_url(&self, owner: &str, name: &str) -> String {
326 format!(
327 "{}/{owner}/{name}.git",
328 self.http.base_url.trim_end_matches('/')
329 )
330 }
331
332 /// The SSH clone URL for `<owner>/<name>`, using the externally advertised
333 /// host/port/user (which may differ from the internal bind under Docker).
334 /// The port is omitted when it is the SSH default (22).
335 pub fn ssh_clone_url(&self, owner: &str, name: &str) -> String {
336 let ssh = &self.ssh;
337 if ssh.clone_port == 22 {
338 format!(
339 "ssh://{}@{}/{owner}/{name}.git",
340 ssh.clone_user, ssh.clone_host
341 )
342 } else {
343 format!(
344 "ssh://{}@{}:{}/{owner}/{name}.git",
345 ssh.clone_user, ssh.clone_host, ssh.clone_port
346 )
347 }
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 /// Look up from a fixed list, standing in for the process environment.
356 fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
357 move |key| {
358 pairs
359 .iter()
360 .find(|(k, _)| *k == key)
361 .map(|(_, v)| v.to_string())
362 }
363 }
364
365 #[test]
366 fn port_and_host_set_the_bind_address() {
367 let mut config = Config::default();
368 config.apply_env(env_of(&[("PORT", "4738")]));
369 assert_eq!(config.http.listen, "127.0.0.1:4738");
370
371 let mut config = Config::default();
372 config.apply_env(env_of(&[("PORT", "4738"), ("HOST", "0.0.0.0")]));
373 assert_eq!(config.http.listen, "0.0.0.0:4738");
374 }
375
376 #[test]
377 fn anvil_listen_wins_over_port() {
378 let mut config = Config::default();
379 config.apply_env(env_of(&[
380 ("PORT", "4738"),
381 ("ANVIL_LISTEN", "0.0.0.0:9000"),
382 ]));
383 assert_eq!(config.http.listen, "0.0.0.0:9000");
384 }
385
386 #[test]
387 fn a_nonsense_port_leaves_the_configured_address_alone() {
388 let mut config = Config::default();
389 config.apply_env(env_of(&[("PORT", "not-a-port")]));
390 assert_eq!(config.http.listen, "127.0.0.1:3000");
391 }
392
393 #[test]
394 fn proxy_url_becomes_the_base_url() {
395 let mut config = Config::default();
396 config.apply_env(env_of(&[("PORTLESS_URL", "https://anvil.localhost/")]));
397 assert_eq!(config.http.base_url, "https://anvil.localhost");
398 // …and drives the Secure cookie attribute, since it is https.
399 assert!(config.secure_cookies());
400
401 let mut config = Config::default();
402 config.apply_env(env_of(&[
403 ("PORTLESS_URL", "https://anvil.localhost"),
404 ("ANVIL_BASE_URL", "https://forge.example.com"),
405 ]));
406 assert_eq!(config.http.base_url, "https://forge.example.com");
407 }
408
409 #[test]
410 fn an_empty_environment_changes_nothing() {
411 let mut config = Config::default();
412 config.apply_env(env_of(&[]));
413 assert_eq!(config.http.listen, HttpConfig::default().listen);
414 assert_eq!(config.http.base_url, HttpConfig::default().base_url);
415 }
416
417 #[test]
418 fn image_allowlist_semantics() {
419 let mut ci = CiConfig::default();
420 assert!(ci.image_allowed("anything:latest"), "empty list allows all");
421
422 ci.allowed_images = vec!["rust".to_string(), "alpine:3.20".to_string()];
423 assert!(ci.image_allowed("rust"), "tagless entry, tagless image");
424 assert!(
425 ci.image_allowed("rust:1.95-bookworm"),
426 "tagless entry allows any tag"
427 );
428 assert!(ci.image_allowed("alpine:3.20"), "tagged entry, exact match");
429 assert!(!ci.image_allowed("alpine:3.21"), "tagged entry, other tag");
430 assert!(!ci.image_allowed("alpine"), "tagged entry, tagless image");
431 assert!(
432 !ci.image_allowed("rustlang/rust:nightly"),
433 "no prefix bleed"
434 );
435 assert!(!ci.image_allowed("rusty:latest"), "no name-prefix bleed");
436 }
437}