From 9dead35ece3dd5391fce686d578fcd2715e56f7a Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Tue, 12 May 2026 16:30:46 -0400 Subject: [PATCH] fix(relay): auto-create git_repo_path so kind:30617 init can't silently fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The git smart-HTTP transport and the kind:30617 side-effect handler both canonicalize `config.git_repo_path` before any disk work. If that directory doesn't exist on the relay host: - The transport returns HTTP 500 'git service misconfigured' (transport.rs:237) to every clone/push. - The side-effect handler that initializes the bare repo from a repo announcement fails at canonicalize, and the ingest pipeline only logs side-effect failures at warn! level (ingest.rs:1530) — so the announcement event is stored but no repo is ever created on disk. The relay looks like it accepted the repo creation, but pushes 500 forever. This previously required operators to mkdir the directory out of band. Since the relay already owns the data layout below that root (`{owner}/{repo}.git`, `.names/`), it should self-provision the root the same way. Changes: - `config.rs`: `create_dir_all(git_repo_path)` during config load. Fails loudly with ConfigError::InvalidValue if the path can't be created (e.g. permissions, path-under-file), instead of letting the relay come up healthy with a broken git service. - `side_effects.rs::handle_git_repo_announcement`: defensive `create_dir_all(git_repo_root)` before canonicalize, in case the directory is removed at runtime or the deployment skipped config bootstrap somehow. Returns a clear error rather than the cryptic 'failed to canonicalize repo root' from a missing dir. Tests: - `git_repo_path_is_created_if_missing`: config load against a non-existent nested path succeeds and creates the directory. - `git_repo_path_unwritable_returns_error` (unix): config load against a path under `/dev/null` returns ConfigError::InvalidValue. Post-deploy note: existing kind:30617 announcements published against a broken-root relay won't auto-heal — their side effect already ran (and was swallowed). Owners need to re-publish the announcement to trigger init. --- crates/sprout-relay/src/config.rs | 59 +++++++++++++++++++ .../sprout-relay/src/handlers/side_effects.rs | 14 +++++ 2 files changed, 73 insertions(+) diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 7458bd98a..9aa00aa7d 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -287,6 +287,19 @@ impl Config { let git_repo_path: std::path::PathBuf = std::env::var("SPROUT_GIT_REPO_PATH") .unwrap_or_else(|_| "./repos".to_string()) .into(); + // Ensure the git repo root exists. The smart-HTTP transport and the + // kind:30617 side-effect handler both canonicalize this path; if it's + // missing, all git operations 500 with "git service misconfigured" and + // repo announcements silently fail to create their bare repo on disk. + // Bootstrapping here makes the relay self-provision its own data dir + // (matches how we treat other relay-owned paths) rather than requiring + // ops to mkdir it out of band. + if let Err(e) = std::fs::create_dir_all(&git_repo_path) { + return Err(ConfigError::InvalidValue(format!( + "SPROUT_GIT_REPO_PATH={} could not be created: {e}", + git_repo_path.display() + ))); + } let git_max_pack_bytes: u64 = std::env::var("SPROUT_GIT_MAX_PACK_BYTES") .ok() .and_then(|v| v.parse().ok()) @@ -440,6 +453,52 @@ mod tests { ); } + #[test] + fn git_repo_path_is_created_if_missing() { + let _guard = ENV_MUTEX.lock().unwrap(); + // Pick a path under temp_dir that definitely doesn't exist yet. + let base = std::env::temp_dir().join(format!( + "sprout-test-git-repo-path-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let nested = base.join("nested").join("repos"); + assert!(!nested.exists(), "test precondition: path must not exist"); + + std::env::set_var("SPROUT_GIT_REPO_PATH", &nested); + let result = Config::from_env(); + std::env::remove_var("SPROUT_GIT_REPO_PATH"); + + let config = result.expect("config should self-bootstrap missing git_repo_path"); + assert_eq!(config.git_repo_path, nested); + assert!( + nested.is_dir(), + "git_repo_path should exist after config load" + ); + + // Cleanup. + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + #[cfg(unix)] + fn git_repo_path_unwritable_returns_error() { + let _guard = ENV_MUTEX.lock().unwrap(); + // Try to create a path under a regular file — must fail. + // Using /dev/null as the parent guarantees create_dir_all fails on unix. + let bogus = std::path::PathBuf::from("/dev/null/cannot-create-here"); + std::env::set_var("SPROUT_GIT_REPO_PATH", &bogus); + let result = Config::from_env(); + std::env::remove_var("SPROUT_GIT_REPO_PATH"); + assert!( + matches!(result, Err(ConfigError::InvalidValue(ref msg)) if msg.contains("SPROUT_GIT_REPO_PATH")), + "expected InvalidValue mentioning SPROUT_GIT_REPO_PATH, got {result:?}" + ); + } + #[test] fn server_domain_explicit_override_wins() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index 4e7d3109e..c26c331fe 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -1620,6 +1620,20 @@ async fn handle_git_repo_announcement(event: &Event, state: &Arc) -> a // Resolve repo path. let git_repo_root = &state.config.git_repo_path; + + // Defensive: ensure the configured root exists. Config bootstrap creates + // this at startup, but a misconfigured deployment or out-of-band deletion + // would otherwise cause every canonicalize() below to fail and the + // side-effect to be silently swallowed by the ingest pipeline, leaving the + // repo announcement stored but no bare repo on disk (push then 500s with + // "git service misconfigured"). + if let Err(e) = std::fs::create_dir_all(git_repo_root) { + return Err(anyhow::anyhow!( + "failed to ensure git_repo_path {} exists: {e}", + git_repo_root.display() + )); + } + let repo_dir = git_repo_root .join(&owner_hex) .join(format!("{repo_id}.git"));