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}
33
34/// CI configuration: job sandbox limits and the single-repo redeploy webhook.
35///
36/// On a successful CI run of [`deploy_branch`](CiConfig::deploy_branch) in the
37/// single repository named by [`deploy_repo`](CiConfig::deploy_repo), anvil
38/// POSTs to [`deploy_webhook`](CiConfig::deploy_webhook). This is deliberately
39/// scoped to **one** repository — no other repo can trigger the deploy, even
40/// with its own passing CI.
41#[derive(Clone, Debug, Deserialize, Serialize)]
42#[serde(default)]
43pub struct CiConfig {
44 /// The one repository (`owner/name`) permitted to trigger the deploy
45 /// webhook. Empty disables deploys entirely.
46 pub deploy_repo: String,
47 /// URL POSTed to when `deploy_repo`'s `deploy_branch` goes green. Should be
48 /// a host-local plaintext HTTP endpoint (a small deploy-script receiver);
49 /// HTTPS is intentionally unsupported to keep the build TLS-free.
50 pub deploy_webhook: String,
51 /// Shared secret sent as the `X-Anvil-Deploy-Secret` header so the receiver
52 /// can authenticate the call. Empty sends no header.
53 pub deploy_secret: String,
54 /// Branch whose successful run triggers a deploy. Defaults to `main`.
55 pub deploy_branch: String,
56 /// Images a pipeline may run in. Empty allows any image. An entry without a
57 /// tag (e.g. `rust`) allows every tag of that image; an entry with a tag
58 /// (e.g. `rust:1.95-bookworm`) allows exactly that image.
59 pub allowed_images: Vec<String>,
60 /// Memory cap for a job container, in MiB (swap is capped to the same
61 /// value). `0` means unlimited. Defaults to 2048.
62 pub memory_mb: i64,
63 /// CPU cap for a job container, in (possibly fractional) CPUs. `0` means
64 /// unlimited. Defaults to 2.
65 pub cpus: f64,
66 /// Process-count cap inside a job container. `0` means unlimited.
67 /// Defaults to 512.
68 pub pids_limit: i64,
69 /// Wall-clock timeout for a job, in seconds; on expiry the container is
70 /// force-removed and the run errors. `0` disables the timeout. Defaults to
71 /// 1800 (30 minutes).
72 pub timeout_secs: u64,
73 /// Whether job containers get network access (the default Docker network).
74 /// Most builds need it to fetch dependencies; disable for stricter
75 /// isolation. Defaults to `true`.
76 pub network: bool,
77 /// User to run the job as inside the container (`uid[:gid]` or a name known
78 /// to the image). Empty keeps the image's default user. Note many base
79 /// images assume root for e.g. `apt-get`.
80 pub run_as: String,
81 /// Size cap for a single artifact, in MiB; larger artifacts are skipped
82 /// (with a log note), never failing the run. `0` means unlimited.
83 /// Defaults to 256.
84 pub artifact_max_mb: i64,
85 /// Combined size cap for one run's artifacts, in MiB. Artifacts that would
86 /// push the run over it are skipped. `0` means unlimited. Defaults to 512.
87 pub artifact_run_max_mb: i64,
88 /// Combined artifact budget per repository, in MiB. After each run, oldest
89 /// commits' artifacts are deleted until the repo fits (branch-head commits
90 /// are pinned). `0` means unlimited. Defaults to 4096.
91 pub artifact_quota_mb: i64,
92}
93
94#[derive(Clone, Debug, Deserialize, Serialize)]
95#[serde(default)]
96pub struct HttpConfig {
97 /// Address the HTTP server binds to, e.g. `127.0.0.1:3000`.
98 pub listen: String,
99 /// Externally visible base URL, used when constructing clone URLs.
100 pub base_url: String,
101 /// Memory budget, in MiB, for the cache of syntax-highlighted file views
102 /// (rendered HTML keyed by blob oid). Highlighting large files is the most
103 /// CPU-expensive page render, so repeat views are served from this cache.
104 /// `0` disables it — lowest memory, every view re-highlights. Defaults
105 /// to 16.
106 pub highlight_cache_mb: usize,
107}
108
109#[derive(Clone, Debug, Deserialize, Serialize)]
110#[serde(default)]
111pub struct SshConfig {
112 /// Whether the SSH git transport is enabled.
113 pub enabled: bool,
114 /// Address the SSH server binds to internally, e.g. `0.0.0.0:2222`. Under
115 /// Docker this is the in-container bind, which may differ from the
116 /// externally forwarded port — see the `clone_*` fields below.
117 pub listen: String,
118 /// Hostname shown in SSH clone URLs (what users actually connect to).
119 pub clone_host: String,
120 /// Port shown in SSH clone URLs. Set this to the *externally forwarded*
121 /// port when it differs from the internal bind (e.g. Docker `-p 2200:2222`).
122 pub clone_port: u16,
123 /// Username shown in SSH clone URLs (conventionally `git`).
124 pub clone_user: String,
125}
126
127impl Default for Config {
128 fn default() -> Self {
129 Self {
130 data_dir: PathBuf::from("data"),
131 http: HttpConfig::default(),
132 ssh: SshConfig::default(),
133 ci: CiConfig::default(),
134 }
135 }
136}
137
138impl Default for CiConfig {
139 fn default() -> Self {
140 Self {
141 deploy_repo: String::new(),
142 deploy_webhook: String::new(),
143 deploy_secret: String::new(),
144 deploy_branch: "main".to_string(),
145 allowed_images: Vec::new(),
146 memory_mb: 2048,
147 cpus: 2.0,
148 pids_limit: 512,
149 timeout_secs: 1800,
150 network: true,
151 run_as: String::new(),
152 artifact_max_mb: 256,
153 artifact_run_max_mb: 512,
154 artifact_quota_mb: 4096,
155 }
156 }
157}
158
159impl CiConfig {
160 /// Whether `owner/name` on `branch` is the configured deploy target.
161 pub fn is_deploy_target(&self, owner: &str, name: &str, branch: &str) -> bool {
162 !self.deploy_repo.is_empty()
163 && !self.deploy_webhook.is_empty()
164 && self.deploy_repo == format!("{owner}/{name}")
165 && self.deploy_branch == branch
166 }
167
168 /// Whether `image` passes [`allowed_images`](CiConfig::allowed_images).
169 /// An empty allowlist permits any image; a tagless entry permits every tag
170 /// of that image; a tagged entry permits exactly itself.
171 pub fn image_allowed(&self, image: &str) -> bool {
172 self.allowed_images.is_empty()
173 || self.allowed_images.iter().any(|allowed| {
174 image == allowed
175 || (!allowed.contains(':')
176 && image
177 .strip_prefix(allowed.as_str())
178 .is_some_and(|rest| rest.starts_with(':')))
179 })
180 }
181}
182
183impl Default for HttpConfig {
184 fn default() -> Self {
185 Self {
186 listen: "127.0.0.1:3000".to_string(),
187 base_url: "http://localhost:3000".to_string(),
188 highlight_cache_mb: 16,
189 }
190 }
191}
192
193impl Default for SshConfig {
194 fn default() -> Self {
195 Self {
196 enabled: false,
197 listen: "127.0.0.1:2222".to_string(),
198 clone_host: "localhost".to_string(),
199 clone_port: 2222,
200 clone_user: "git".to_string(),
201 }
202 }
203}
204
205impl Config {
206 /// Load configuration from a TOML file. Missing fields fall back to defaults.
207 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
208 let path = path.as_ref();
209 let text = std::fs::read_to_string(path)
210 .map_err(|e| Error::Config(format!("reading {}: {e}", path.display())))?;
211 toml::from_str(&text).map_err(|e| Error::Config(format!("parsing {}: {e}", path.display())))
212 }
213
214 /// Load from `path` if it exists, otherwise return defaults.
215 pub fn load_or_default(path: impl AsRef<Path>) -> Result<Self> {
216 let path = path.as_ref();
217 if path.exists() {
218 Self::load(path)
219 } else {
220 Ok(Self::default())
221 }
222 }
223
224 /// Filesystem path to the SQLite database file.
225 pub fn database_path(&self) -> PathBuf {
226 self.data_dir.join("anvil.db")
227 }
228
229 /// Root directory under which bare repositories are stored.
230 pub fn repositories_dir(&self) -> PathBuf {
231 self.data_dir.join("repositories")
232 }
233
234 /// Root directory under which CI artifacts are stored
235 /// (`artifacts/{repo_id}/{commit}/…` — see `docs/ci-artifacts.md`).
236 pub fn artifacts_dir(&self) -> PathBuf {
237 self.data_dir.join("artifacts")
238 }
239
240 /// Whether session cookies should carry the `Secure` attribute (HTTPS-only).
241 /// Derived from the public base URL's scheme, so local plaintext dev still
242 /// works while production behind TLS gets `Secure` automatically.
243 pub fn secure_cookies(&self) -> bool {
244 self.http.base_url.starts_with("https://")
245 }
246
247 /// The HTTP clone URL for `<owner>/<name>`, e.g.
248 /// `http://localhost:3000/alice/hello.git`.
249 pub fn http_clone_url(&self, owner: &str, name: &str) -> String {
250 format!(
251 "{}/{owner}/{name}.git",
252 self.http.base_url.trim_end_matches('/')
253 )
254 }
255
256 /// The SSH clone URL for `<owner>/<name>`, using the externally advertised
257 /// host/port/user (which may differ from the internal bind under Docker).
258 /// The port is omitted when it is the SSH default (22).
259 pub fn ssh_clone_url(&self, owner: &str, name: &str) -> String {
260 let ssh = &self.ssh;
261 if ssh.clone_port == 22 {
262 format!(
263 "ssh://{}@{}/{owner}/{name}.git",
264 ssh.clone_user, ssh.clone_host
265 )
266 } else {
267 format!(
268 "ssh://{}@{}:{}/{owner}/{name}.git",
269 ssh.clone_user, ssh.clone_host, ssh.clone_port
270 )
271 }
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn image_allowlist_semantics() {
281 let mut ci = CiConfig::default();
282 assert!(ci.image_allowed("anything:latest"), "empty list allows all");
283
284 ci.allowed_images = vec!["rust".to_string(), "alpine:3.20".to_string()];
285 assert!(ci.image_allowed("rust"), "tagless entry, tagless image");
286 assert!(
287 ci.image_allowed("rust:1.95-bookworm"),
288 "tagless entry allows any tag"
289 );
290 assert!(ci.image_allowed("alpine:3.20"), "tagged entry, exact match");
291 assert!(!ci.image_allowed("alpine:3.21"), "tagged entry, other tag");
292 assert!(!ci.image_allowed("alpine"), "tagged entry, tagless image");
293 assert!(
294 !ci.image_allowed("rustlang/rust:nightly"),
295 "no prefix bleed"
296 );
297 assert!(!ci.image_allowed("rusty:latest"), "no name-prefix bleed");
298 }
299}