zuka
zuka/src/config.rs

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
zuka/src/config.rs
RSconfig.rs22.2 KBDownload
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
4use crate::brand;
5use anyhow::{anyhow, bail, Context, Result};
6use std::net::SocketAddr;
7use std::path::PathBuf;
8use 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)]
13pub enum Mode {
14 Standalone,
15 Control,
16 Tenant,
17}
18
19impl 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
35impl 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)]
46pub 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 /// `RLIMIT_NPROC` for a step. `0` disables it, which is the default — the limit
97 /// is per-UID and only means anything with a dedicated runner uid.
98 pub ci_max_processes: u64,
99 /// Runs retained per repository.
100 pub ci_keep_runs: usize,
101 /// Whether repositories may be imported from a remote URL.
102 pub import_enabled: bool,
103 /// Ceiling on how long an import may take.
104 pub import_timeout: Duration,
105 /// Control plane: Ed25519 seed used to sign tenant assertions, base64.
106 ///
107 /// Generated on first boot if absent. Never leaves the control plane.
108 pub control_key_file: PathBuf,
109 /// Tenant: base64 public keys that may sign assertions. More than one so the
110 /// control plane's key can be rotated without a flag day.
111 pub control_public_keys: Vec<String>,
112 /// Control plane: Incus image for a new tenant.
113 pub incus_image: String,
114 /// Control plane: Incus network a tenant joins.
115 pub incus_network: String,
116 /// Port a tenant listens on inside its container.
117 pub tenant_port: u16,
118 /// Control plane: path to the binary pushed into a tenant.
119 pub tenant_binary: String,
120 /// Control plane: ceiling on one proxied request.
121 pub proxy_timeout: Duration,
122 /// Permit importing from private and loopback addresses.
123 ///
124 /// Needed on an internal network; a hole on a public one, which is why it is
125 /// off by default and logged loudly when on.
126 pub import_allow_private: bool,
127 /// SSH listener. `None` disables the SSH transport.
128 pub ssh_bind: Option<SocketAddr>,
129 /// Ed25519 host key. Generated on first boot if absent.
130 pub ssh_host_key: PathBuf,
131}
132
133impl Config {
134 pub fn from_env() -> Result<Self> {
135 let bind_raw = env_or("BIND", DEFAULT_BIND);
136 let bind: SocketAddr = bind_raw.parse().with_context(|| {
137 format!(
138 "{} {bind_raw:?} is not a socket address",
139 brand::env_name("BIND")
140 )
141 })?;
142
143 let mode: Mode = env_or("MODE", DEFAULT_MODE).parse()?;
144
145 let data_dir = PathBuf::from(env_or("DATA_DIR", brand::default_data_dir()));
146 if data_dir.is_relative() {
147 bail!(
148 "{} must be absolute, got {}",
149 brand::env_name("DATA_DIR"),
150 data_dir.display()
151 );
152 }
153
154 let me_url = brand::env("ME_URL").and_then(|v| {
155 let trimmed = v.trim_end_matches('/').to_string();
156 if trimmed.is_empty() {
157 None
158 } else {
159 Some(trimmed)
160 }
161 });
162 if let Some(url) = &me_url {
163 if !url.starts_with("https://") && !url.starts_with("http://127.0.0.1") {
164 bail!(
165 "{} must be https, or http on loopback for development",
166 brand::env_name("ME_URL")
167 );
168 }
169 }
170
171 let ci_enabled = env_bool("CI_ENABLED", false)?;
172
173 let disk_reserve_bytes = env_mb("DISK_RESERVE_MB", DEFAULT_DISK_RESERVE_MB)?;
174 let max_body_bytes = env_mb("MAX_BODY_MB", DEFAULT_MAX_BODY_MB)?;
175 let max_pack_bytes = env_mb("MAX_PACK_MB", DEFAULT_MAX_PACK_MB)?;
176 let max_blob_bytes = env_mb("MAX_BLOB_MB", DEFAULT_MAX_BLOB_MB)?;
177
178 let default_page_limit = env_u64("PAGE_LIMIT", DEFAULT_PAGE_LIMIT)? as usize;
179 let max_page_limit = env_u64("MAX_PAGE_LIMIT", DEFAULT_MAX_PAGE_LIMIT)? as usize;
180 if default_page_limit == 0 || default_page_limit > max_page_limit {
181 bail!(
182 "{} must be between 1 and {}",
183 brand::env_name("PAGE_LIMIT"),
184 brand::env_name("MAX_PAGE_LIMIT")
185 );
186 }
187
188 let header_timeout = env_secs("HEADER_TIMEOUT_SECS", DEFAULT_HEADER_TIMEOUT_SECS)?;
189 let ssh_idle_timeout = env_secs("SSH_IDLE_SECS", DEFAULT_SSH_IDLE_SECS)?;
190 let token_ttl_days = env_u64("TOKEN_TTL_DAYS", DEFAULT_TOKEN_TTL_DAYS)?;
191 let log_walk_budget = env_u64("LOG_WALK_BUDGET", DEFAULT_LOG_WALK_BUDGET)? as usize;
192
193 let max_repos_per_account = env_u64("MAX_REPOS_PER_ACCOUNT", DEFAULT_MAX_REPOS)?;
194 let max_repo_bytes = env_mb("MAX_REPO_MB", DEFAULT_MAX_REPO_MB)?;
195 let max_account_bytes = env_mb("MAX_ACCOUNT_MB", DEFAULT_MAX_ACCOUNT_MB)?;
196 if max_repo_bytes > 0 && max_account_bytes > 0 && max_repo_bytes > max_account_bytes {
197 bail!(
198 "{} must not exceed {}",
199 brand::env_name("MAX_REPO_MB"),
200 brand::env_name("MAX_ACCOUNT_MB")
201 );
202 }
203
204 let rate_per_minute = env_u64("RATE_PER_MINUTE", DEFAULT_RATE_PER_MINUTE)? as u32;
205 let gc_interval = env_secs("GC_INTERVAL_SECS", DEFAULT_GC_INTERVAL_SECS)?;
206 let gc_prune_grace = env_or("GC_PRUNE_GRACE", DEFAULT_GC_PRUNE_GRACE);
207 let deleted_retention = Duration::from_secs(
208 env_u64("DELETED_RETENTION_DAYS", DEFAULT_DELETED_RETENTION_DAYS)? * 24 * 60 * 60,
209 );
210
211 let import_enabled = env_bool("IMPORT_ENABLED", true)?;
212 // Off by default. A self-hoster whose git server is on a private network
213 // genuinely needs this; on a public host it re-opens SSRF, so it is opt-in
214 // and announced at boot rather than quietly available.
215 let import_allow_private = env_bool("IMPORT_ALLOW_PRIVATE", false)?;
216 let import_timeout = env_secs("IMPORT_TIMEOUT_SECS", DEFAULT_IMPORT_TIMEOUT_SECS)?;
217
218 let control_key_file = data_dir.join("control_ed25519_seed");
219 let control_public_keys = brand::env("CONTROL_PUBLIC_KEYS")
220 .map(|v| {
221 v.split(',')
222 .map(str::trim)
223 .filter(|s| !s.is_empty())
224 .map(String::from)
225 .collect::<Vec<_>>()
226 })
227 .unwrap_or_default();
228 if mode == Mode::Tenant && control_public_keys.is_empty() {
229 bail!(
230 "{} is required in tenant mode: a tenant must know which key may \
231 vouch for a caller",
232 brand::env_name("CONTROL_PUBLIC_KEYS")
233 );
234 }
235
236 let incus_image = env_or("INCUS_IMAGE", DEFAULT_INCUS_IMAGE);
237 let incus_network = env_or("INCUS_NETWORK", DEFAULT_INCUS_NETWORK);
238 let tenant_port = env_u64("TENANT_PORT", DEFAULT_TENANT_PORT)? as u16;
239 let tenant_binary = env_or(
240 "TENANT_BINARY",
241 &std::env::current_exe()
242 .map(|p| p.display().to_string())
243 .unwrap_or_else(|_| format!("/usr/local/bin/{}", brand::NAME)),
244 );
245 let proxy_timeout = env_secs("PROXY_TIMEOUT_SECS", DEFAULT_PROXY_TIMEOUT_SECS)?;
246
247 let ci_timeout = env_secs("CI_TIMEOUT_SECS", DEFAULT_CI_TIMEOUT_SECS)?;
248 let ci_max_concurrent = env_u64("CI_MAX_CONCURRENT", DEFAULT_CI_MAX_CONCURRENT)? as usize;
249 let ci_log_bytes = env_mb("CI_LOG_MB", DEFAULT_CI_LOG_MB)?;
250 let ci_memory_bytes = env_mb("CI_MEMORY_MB", DEFAULT_CI_MEMORY_MB)?;
251 let ci_max_processes = env_u64("CI_MAX_PROCESSES", DEFAULT_CI_MAX_PROCESSES)?;
252 let ci_keep_runs = env_u64("CI_KEEP_RUNS", DEFAULT_CI_KEEP_RUNS)? as usize;
253 if ci_enabled && ci_max_concurrent == 0 {
254 bail!(
255 "{} must be at least 1",
256 brand::env_name("CI_MAX_CONCURRENT")
257 );
258 }
259
260 let max_concurrent_git =
261 env_u64("MAX_CONCURRENT_GIT", DEFAULT_MAX_CONCURRENT_GIT)? as usize;
262 if max_concurrent_git == 0 {
263 bail!(
264 "{} must be at least 1",
265 brand::env_name("MAX_CONCURRENT_GIT")
266 );
267 }
268
269 let ssh_bind = match brand::env("SSH_BIND").ok_or(()) {
270 Err(()) => Some(
271 DEFAULT_SSH_BIND
272 .parse()
273 .expect("literal is a valid address"),
274 ),
275 Ok(v) if v.is_empty() || v == "off" => None,
276 Ok(v) => Some(v.parse().with_context(|| {
277 format!(
278 "{} {v:?} is not a socket address",
279 brand::env_name("SSH_BIND")
280 )
281 })?),
282 };
283 let ssh_host_key = data_dir.join("ssh_host_ed25519_key");
284
285 Ok(Config {
286 bind,
287 mode,
288 data_dir,
289 me_url,
290 ci_enabled,
291 disk_reserve_bytes,
292 max_body_bytes,
293 max_pack_bytes,
294 max_blob_bytes,
295 max_concurrent_git,
296 default_page_limit,
297 max_page_limit,
298 header_timeout,
299 ssh_idle_timeout,
300 token_ttl_days,
301 log_walk_budget,
302 max_repos_per_account,
303 max_repo_bytes,
304 max_account_bytes,
305 rate_per_minute,
306 gc_interval,
307 gc_prune_grace,
308 deleted_retention,
309 ci_timeout,
310 ci_max_concurrent,
311 ci_log_bytes,
312 ci_memory_bytes,
313 ci_max_processes,
314 ci_keep_runs,
315 import_enabled,
316 import_timeout,
317 import_allow_private,
318 control_key_file,
319 control_public_keys,
320 incus_image,
321 incus_network,
322 tenant_port,
323 tenant_binary,
324 proxy_timeout,
325 ssh_bind,
326 ssh_host_key,
327 })
328 }
329
330 pub fn tokens_file(&self) -> PathBuf {
331 self.data_dir.join("tokens.json")
332 }
333
334 pub fn meta_dir(&self) -> PathBuf {
335 self.data_dir.join("meta")
336 }
337
338 /// Environment a git child must pass down so our hooks can read configuration.
339 ///
340 /// Both spawn sites clear the environment — deliberately, so a client cannot
341 /// inject one — which also means a hook inherits nothing. Without this the
342 /// `post-receive` hook resolves the default data directory and silently queues
343 /// runs into the wrong place, or nowhere.
344 pub fn hook_env(&self) -> Vec<(String, String)> {
345 vec![
346 (
347 brand::env_name("DATA_DIR"),
348 self.data_dir.display().to_string(),
349 ),
350 (
351 brand::env_name("CI_ENABLED"),
352 if self.ci_enabled { "1" } else { "0" }.to_string(),
353 ),
354 ]
355 }
356
357 /// Read-path bounds, as `git::discover` needs them.
358 ///
359 /// A projection of configuration, so both facades bound reads identically
360 /// rather than one reaching into the other for it.
361 pub fn read_limits(&self) -> crate::git::discover::Limits {
362 crate::git::discover::Limits {
363 max_blob_bytes: self.max_blob_bytes,
364 log_walk_budget: self.log_walk_budget,
365 }
366 }
367
368 /// Validate a requested page size against the configured bounds.
369 ///
370 /// Shared by REST and MCP so a caller gets the same answer whichever surface
371 /// they ask through.
372 pub fn page_limit(&self, requested: Option<usize>) -> crate::error::Result<usize> {
373 match requested {
374 None => Ok(self.default_page_limit),
375 Some(n) if n == 0 || n > self.max_page_limit => Err(crate::error::Error::invalid(
376 "invalid-query",
377 format!("limit must be between 1 and {}", self.max_page_limit),
378 )),
379 Some(n) => Ok(n),
380 }
381 }
382
383 pub fn accounts_dir(&self) -> PathBuf {
384 self.data_dir.join("accounts")
385 }
386
387 pub fn runs_dir(&self) -> PathBuf {
388 self.data_dir.join("runs")
389 }
390
391 pub fn deleted_dir(&self) -> PathBuf {
392 self.data_dir.join("tmp").join("deleted")
393 }
394
395 pub fn keys_file(&self) -> PathBuf {
396 self.data_dir.join("ssh_keys.json")
397 }
398
399 /// Base URL advertised in clone URLs. An agent cannot clone a bare path, so
400 /// this must be an origin even when nothing is configured.
401 pub fn public_host(&self) -> String {
402 brand::env("PUBLIC_URL")
403 .map(|v| v.trim_end_matches('/').to_string())
404 .unwrap_or_else(|| format!("http://{}", self.bind))
405 }
406
407 /// Base advertised for SSH clones, as an `ssh://` URL.
408 ///
409 /// The scheme form rather than scp-style `host:path`, because the port has to
410 /// be expressible and `host:2222:alice/site.git` is not a thing git parses.
411 pub fn public_ssh_base(&self) -> Option<String> {
412 if let Some(configured) = brand::env("PUBLIC_SSH") {
413 return Some(configured.trim_end_matches('/').to_string());
414 }
415 self.ssh_bind.map(|addr| format!("ssh://git@{addr}"))
416 }
417}
418
419/// Defaults, in one block so they are reviewable without reading the parser.
420const DEFAULT_BIND: &str = "127.0.0.1:8790";
421const DEFAULT_SSH_BIND: &str = "127.0.0.1:2222";
422const DEFAULT_MODE: &str = "standalone";
423const DEFAULT_DISK_RESERVE_MB: u64 = 2048;
424const DEFAULT_MAX_BODY_MB: u64 = 10;
425const DEFAULT_MAX_PACK_MB: u64 = 512;
426const DEFAULT_MAX_BLOB_MB: u64 = 32;
427const DEFAULT_MAX_CONCURRENT_GIT: u64 = 8;
428const DEFAULT_PAGE_LIMIT: u64 = 50;
429const DEFAULT_MAX_PAGE_LIMIT: u64 = 200;
430const DEFAULT_HEADER_TIMEOUT_SECS: u64 = 15;
431const DEFAULT_SSH_IDLE_SECS: u64 = 600;
432const DEFAULT_TOKEN_TTL_DAYS: u64 = 90;
433const DEFAULT_LOG_WALK_BUDGET: u64 = 20_000;
434const DEFAULT_MAX_REPOS: u64 = 100;
435const DEFAULT_MAX_REPO_MB: u64 = 2048;
436const DEFAULT_MAX_ACCOUNT_MB: u64 = 10240;
437const DEFAULT_RATE_PER_MINUTE: u64 = 600;
438const DEFAULT_GC_INTERVAL_SECS: u64 = 6 * 60 * 60;
439/// Objects younger than this are never pruned.
440///
441/// This grace window is what makes scheduled GC safe against a concurrent clone:
442/// `upload-pack` may reference an object between resolving it and streaming it, and
443/// pruning inside that window is the classic way to hand a client a corrupt clone.
444/// Two weeks is git's own default for the same reason.
445const DEFAULT_GC_PRUNE_GRACE: &str = "2.weeks.ago";
446/// How long a soft-deleted repository stays recoverable.
447const DEFAULT_DELETED_RETENTION_DAYS: u64 = 7;
448const DEFAULT_CI_TIMEOUT_SECS: u64 = 600;
449const DEFAULT_CI_MAX_CONCURRENT: u64 = 2;
450const DEFAULT_CI_LOG_MB: u64 = 8;
451const DEFAULT_CI_MEMORY_MB: u64 = 2048;
452/// Off by default, and this is not timidity.
453///
454/// `RLIMIT_NPROC` is per-UID, not per-process. With the runner sharing a uid with
455/// the service — which is the standalone case — the limit counts every process the
456/// service user already owns, so a small value makes `fork` fail immediately and a
457/// large one bounds nothing. It is only meaningful once the operator gives the
458/// runner its own uid, which is also what makes it safe.
459const DEFAULT_CI_MAX_PROCESSES: u64 = 0;
460/// Runs kept per repository. Older ones are swept with their logs.
461const DEFAULT_CI_KEEP_RUNS: u64 = 50;
462const DEFAULT_IMPORT_TIMEOUT_SECS: u64 = 300;
463const DEFAULT_INCUS_IMAGE: &str = "images:debian/12";
464const DEFAULT_INCUS_NETWORK: &str = "incusbr0";
465const DEFAULT_TENANT_PORT: u64 = 8790;
466const DEFAULT_PROXY_TIMEOUT_SECS: u64 = 300;
467
468fn env_or(key: &str, default: &str) -> String {
469 brand::env(key).unwrap_or_else(|| default.to_string())
470}
471
472fn env_bool(key: &str, default: bool) -> Result<bool> {
473 match brand::env(key) {
474 None => Ok(default),
475 Some(v) => match v.as_str() {
476 "1" | "true" | "yes" => Ok(true),
477 "0" | "false" | "no" => Ok(false),
478 other => Err(anyhow!(
479 "{} must be a boolean, got {other:?}",
480 brand::env_name(key)
481 )),
482 },
483 }
484}
485
486fn env_u64(key: &str, default: u64) -> Result<u64> {
487 match brand::env(key) {
488 None => Ok(default),
489 Some(v) => v.parse().with_context(|| {
490 format!(
491 "{} must be a non-negative integer, got {v:?}",
492 brand::env_name(key)
493 )
494 }),
495 }
496}
497
498/// A byte count given in megabytes.
499fn env_mb(key: &str, default_mb: u64) -> Result<u64> {
500 Ok(env_u64(key, default_mb)? * 1024 * 1024)
501}
502
503fn env_secs(key: &str, default: u64) -> Result<Duration> {
504 Ok(Duration::from_secs(env_u64(key, default)?))
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn mode_parses_the_three_documented_values_and_rejects_others() {
513 assert_eq!("standalone".parse::<Mode>().unwrap(), Mode::Standalone);
514 assert_eq!("control".parse::<Mode>().unwrap(), Mode::Control);
515 assert_eq!("tenant".parse::<Mode>().unwrap(), Mode::Tenant);
516 assert!("production".parse::<Mode>().is_err());
517 }
518
519 #[test]
520 fn env_bool_accepts_documented_spellings_and_rejects_ambiguity() {
521 std::env::set_var(brand::env_name("TEST_BOOL"), "yes");
522 assert!(env_bool("TEST_BOOL", false).unwrap());
523 std::env::set_var(brand::env_name("TEST_BOOL"), "0");
524 assert!(!env_bool("TEST_BOOL", true).unwrap());
525 std::env::set_var(brand::env_name("TEST_BOOL"), "maybe");
526 assert!(env_bool("TEST_BOOL", false).is_err());
527 std::env::remove_var(brand::env_name("TEST_BOOL"));
528 }
529
530 #[test]
531 fn env_u64_rejects_a_non_numeric_value_rather_than_falling_back() {
532 std::env::set_var(brand::env_name("TEST_U64"), "lots");
533 assert!(env_u64("TEST_U64", 1).is_err());
534 std::env::remove_var(brand::env_name("TEST_U64"));
535 }
536
537 #[test]
538 fn derived_paths_all_hang_off_the_data_dir() {
539 let config = Config {
540 bind: "127.0.0.1:8790".parse().unwrap(),
541 mode: Mode::Standalone,
542 data_dir: PathBuf::from("/srv/data"),
543 me_url: None,
544 ci_enabled: false,
545 disk_reserve_bytes: 0,
546 max_body_bytes: 0,
547 max_pack_bytes: 0,
548 max_blob_bytes: 0,
549 max_concurrent_git: 1,
550 default_page_limit: 50,
551 max_page_limit: 200,
552 header_timeout: Duration::from_secs(15),
553 ssh_idle_timeout: Duration::from_secs(600),
554 token_ttl_days: 90,
555 log_walk_budget: 1000,
556 max_repos_per_account: 100,
557 max_repo_bytes: 0,
558 max_account_bytes: 0,
559 rate_per_minute: 0,
560 gc_interval: Duration::from_secs(3600),
561 gc_prune_grace: "2.weeks.ago".into(),
562 deleted_retention: Duration::from_secs(604_800),
563 ci_timeout: Duration::from_secs(600),
564 ci_max_concurrent: 2,
565 ci_log_bytes: 1024 * 1024,
566 ci_memory_bytes: 0,
567 ci_max_processes: 0,
568 ci_keep_runs: 50,
569 import_enabled: true,
570 import_timeout: Duration::from_secs(300),
571 import_allow_private: false,
572 control_key_file: PathBuf::from("/srv/data/control_ed25519_seed"),
573 control_public_keys: Vec::new(),
574 incus_image: "images:debian/12".into(),
575 incus_network: "incusbr0".into(),
576 tenant_port: 8790,
577 tenant_binary: format!("/usr/local/bin/{}", brand::NAME),
578 proxy_timeout: Duration::from_secs(300),
579 ssh_bind: None,
580 ssh_host_key: PathBuf::from("/srv/data/ssh_host_ed25519_key"),
581 };
582 assert_eq!(config.tokens_file(), PathBuf::from("/srv/data/tokens.json"));
583 assert_eq!(config.meta_dir(), PathBuf::from("/srv/data/meta"));
584 }
585}