fabrica

hanna/fabrica

fix(config): tolerate a not-yet-generated auth.secret_file

dad15c0 · hanna committed on 2026-07-26

The example config sets `auth.secret_file` and documents it as "generated on
first run", but config load hard-read the file and errored when it was missing —
so a fresh container (empty volume) crash-looped with "failed to read secret
file" before `ensure_secret` could create it. Treat a NotFound secret_file as
absent (the server generates and persists one); a present-but-unreadable file
stays a hard error, and a missing mail password_file still errors.

Also create /var/lib/fabrica owned by the fabrica user in the image so first-run
generation (signing secret, SQLite database, repos) can actually write to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
2 files changed · +30 −7UnifiedSplit
Dockerfile +5 −1
@@ -39,12 +39,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
39 && rm -rf /var/lib/apt/lists/* \39 && rm -rf /var/lib/apt/lists/* \
40 && git --version40 && git --version
4141
42RUN useradd --uid 10001 --system --home-dir /var/lib/fabrica --shell /usr/sbin/nologin fabrica42RUN useradd --uid 10001 --system --home-dir /var/lib/fabrica --shell /usr/sbin/nologin fabrica \
43 && install -d -o fabrica -g fabrica -m 0750 /var/lib/fabrica
4344
44COPY --from=builder /build/target/release/fabrica /usr/local/bin/fabrica45COPY --from=builder /build/target/release/fabrica /usr/local/bin/fabrica
45RUN fabrica --version && git --version46RUN fabrica --version && git --version
4647
47USER fabrica48USER fabrica
49# The data directory is created and owned by the fabrica user above so first-run
50# generation (signing secret, SQLite database, repos) can write to it. A bind
51# mount overrides this ownership — chown it to uid 10001 on the host.
48VOLUME /var/lib/fabrica52VOLUME /var/lib/fabrica
49EXPOSE 8080 222253EXPOSE 8080 2222
5054
crates/config/src/lib.rs +25 −6
@@ -139,7 +139,16 @@ fn xdg_config_path() -> Option<PathBuf> {
139/// Read any `*_file` secrets and fold them onto their inline siblings.139/// Read any `*_file` secrets and fold them onto their inline siblings.
140fn resolve_secret_files(config: &mut Config) -> Result<(), ConfigError> {140fn resolve_secret_files(config: &mut Config) -> Result<(), ConfigError> {
141 if let Some(path) = &config.auth.secret_file {141 if let Some(path) = &config.auth.secret_file {
142 config.auth.secret = Some(read_secret(path)?);142 // The HS256 signing key is generated on first run (see the CLI's
143 // `ensure_secret`), so a not-yet-created file is expected and fine — the
144 // inline secret stays `None` and the server writes one. Only a file that
145 // exists but cannot be read (e.g. permissions) is a hard error.
146 match read_secret(path) {
147 Ok(secret) => config.auth.secret = Some(secret),
148 Err(ConfigError::SecretFile { source, .. })
149 if source.kind() == std::io::ErrorKind::NotFound => {}
150 Err(e) => return Err(e),
151 }
143 }152 }
144 if let Some(path) = &config.mail.password_file {153 if let Some(path) = &config.mail.password_file {
145 config.mail.password = Some(read_secret(path)?);154 config.mail.password = Some(read_secret(path)?);
@@ -453,12 +462,22 @@ mod tests {
453 }462 }
454463
455 #[test]464 #[test]
456 fn missing_secret_file_errors() {465 fn missing_auth_secret_file_is_tolerated() {
457 let err = from_toml("[auth]\nsecret_file = \"/no/such/fabrica/secret\"\n").unwrap_err();466 // The signing key is generated on first run, so a not-yet-created
467 // secret_file must not fail config load — the inline secret stays unset
468 // and the server writes one.
469 let loaded = from_toml("[auth]\nsecret_file = \"/no/such/fabrica/secret\"\n").unwrap();
470 assert!(loaded.config.auth.secret.is_none());
471 }
472
473 #[test]
474 fn missing_mail_password_file_errors() {
475 // A mail password file is operator-provided, not generated, so a missing
476 // one is a real misconfiguration.
477 let err =
478 from_toml("[mail]\npassword_file = \"/no/such/fabrica/mail-pass\"\n").unwrap_err();
458 match err {479 match err {
459 ConfigError::SecretFile { path, .. } => {480 ConfigError::SecretFile { path, .. } => assert!(path.ends_with("mail-pass")),
460 assert!(path.ends_with("secret"));
461 }
462 other => panic!("expected SecretFile, got {other:?}"),481 other => panic!("expected SecretFile, got {other:?}"),
463 }482 }
464 }483 }