fabrica

hanna/fabrica

feat(mirror): background sync scheduler and post-receive trigger

b63d883 · hanna committed on 2026-07-26

Add the `mirror` crate: sync_one runs one mirror (fetch for pull, push
with a branch-filter refspec for push) and records the outcome;
run_scheduler is the 60s background loop over due mirrors; and
trigger_push_mirrors propagates a received push to sync-on-push mirrors.
The server spawns the scheduler alongside web/ssh, and the post-receive
hook calls the trigger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
8 files changed · +206 −1UnifiedSplit
Cargo.lock +14 −0
@@ -777,6 +777,7 @@ dependencies = [
777 "config",777 "config",
778 "git",778 "git",
779 "mail",779 "mail",
780 "mirror",
780 "model",781 "model",
781 "rpassword",782 "rpassword",
782 "serde",783 "serde",
@@ -1673,6 +1674,7 @@ dependencies = [
1673 "cli",1674 "cli",
1674 "config",1675 "config",
1675 "git",1676 "git",
1677 "mirror",
1676 "ssh",1678 "ssh",
1677 "store",1679 "store",
1678 "tokio",1680 "tokio",
@@ -2929,6 +2931,17 @@ dependencies = [
2929]2931]
29302932
2931[[package]]2933[[package]]
2934name = "mirror"
2935version = "0.1.0"
2936dependencies = [
2937 "config",
2938 "git",
2939 "store",
2940 "tokio",
2941 "tracing",
2942]
2943
2944[[package]]
2932name = "ml-kem"2945name = "ml-kem"
2933version = "0.3.2"2946version = "0.3.2"
2934source = "registry+https://github.com/rust-lang/crates.io-index"2947source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5715,6 +5728,7 @@ dependencies = [
5715 "mail",5728 "mail",
5716 "maud",5729 "maud",
5717 "mime_guess",5730 "mime_guess",
5731 "mirror",
5718 "model",5732 "model",
5719 "reqwest",5733 "reqwest",
5720 "rust-embed",5734 "rust-embed",
Cargo.toml +2 −0
@@ -21,6 +21,7 @@ web = { path = "crates/web" }
21ssh = { path = "crates/ssh" }21ssh = { path = "crates/ssh" }
22config = { path = "crates/config" }22config = { path = "crates/config" }
23store = { path = "crates/store" }23store = { path = "crates/store" }
24mirror = { path = "crates/mirror" }
24# Direct, optional dep only to expose git's `vendored` feature at the binary25# Direct, optional dep only to expose git's `vendored` feature at the binary
25# (feature unification then applies it to the transitive `git` too).26# (feature unification then applies it to the transitive `git` too).
26git = { path = "crates/git", optional = true }27git = { path = "crates/git", optional = true }
@@ -62,6 +63,7 @@ ssh = { path = "crates/ssh" }
62web = { path = "crates/web" }63web = { path = "crates/web" }
63api = { path = "crates/api" }64api = { path = "crates/api" }
64cli = { path = "crates/cli" }65cli = { path = "crates/cli" }
66mirror = { path = "crates/mirror" }
6567
66# External dependencies. Versions are centralised here; crates opt in with68# External dependencies. Versions are centralised here; crates opt in with
67# `<dep> = { workspace = true }` so the whole workspace stays on one version.69# `<dep> = { workspace = true }` so the whole workspace stays on one version.
crates/cli/Cargo.toml +1 −0
@@ -14,6 +14,7 @@ config = { workspace = true }
14store = { workspace = true }14store = { workspace = true }
15auth = { workspace = true }15auth = { workspace = true }
16git = { workspace = true }16git = { workspace = true }
17mirror = { workspace = true }
17mail = { workspace = true }18mail = { workspace = true }
18model = { workspace = true }19model = { workspace = true }
19anyhow = { workspace = true }20anyhow = { workspace = true }
crates/cli/src/hook_cmd.rs +3 −1
@@ -65,8 +65,10 @@ fn post_receive() -> anyhow::Result<()> {
65 let size = dir_size(&git_dir);65 let size = dir_size(&git_dir);
6666
67 block_on(async move {67 block_on(async move {
68 let (_config, store) = open_store(config_path.as_deref()).await?;68 let (config, store) = open_store(config_path.as_deref()).await?;
69 store.record_push(&repo_id, size).await?;69 store.record_push(&repo_id, size).await?;
70 // Propagate the push to any "sync on push" mirrors (best-effort).
71 mirror::trigger_push_mirrors(&store, &config, &repo_id).await;
70 anyhow::Ok(())72 anyhow::Ok(())
71 })73 })
72}74}
crates/mirror/Cargo.toml +20 −0
@@ -0,0 +1,20 @@
1[package]
2name = "mirror"
3description = "Repository mirror sync: periodic push/pull to and from remotes."
4version.workspace = true
5edition.workspace = true
6rust-version.workspace = true
7license.workspace = true
8authors.workspace = true
9repository.workspace = true
10publish.workspace = true
11
12[dependencies]
13config = { workspace = true }
14store = { workspace = true }
15git = { workspace = true }
16tokio = { workspace = true }
17tracing = { workspace = true }
18
19[lints]
20workspace = true
crates/mirror/src/lib.rs +161 −0
@@ -0,0 +1,161 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Repository mirror sync.
6//!
7//! [`sync_one`] runs a single mirror (push out or pull in) and records the
8//! outcome. [`run_scheduler`] is the background loop the server spawns; it wakes
9//! periodically and syncs every mirror whose interval has elapsed.
10//! [`trigger_push_mirrors`] is called from the post-receive hook so a received
11//! push propagates immediately to any "sync on push" mirrors.
12
13use std::sync::Arc;
14use std::time::Duration;
15
16use config::Config;
17use git::mirror::RemoteCred;
18use store::{Mirror, MirrorDirection, Store};
19
20/// How often the scheduler wakes to look for due mirrors.
21const TICK: Duration = Duration::from_secs(60);
22
23/// The push refspecs for a branch filter. Empty/absent mirrors all branches;
24/// otherwise each comma- or whitespace-separated pattern becomes a heads
25/// refspec. Tags are always included.
26fn push_refspecs(branch_filter: Option<&str>) -> Vec<String> {
27 let patterns: Vec<&str> = branch_filter
28 .unwrap_or("")
29 .split([',', ' ', '\t', '\n'])
30 .map(str::trim)
31 .filter(|p| !p.is_empty())
32 .collect();
33 if patterns.is_empty() {
34 return git::mirror::all_refs();
35 }
36 let mut specs: Vec<String> = patterns
37 .iter()
38 .map(|p| format!("+refs/heads/{p}:refs/heads/{p}"))
39 .collect();
40 specs.push("+refs/tags/*:refs/tags/*".to_string());
41 specs
42}
43
44/// Run one mirror's sync and record its outcome on the mirror row.
45///
46/// # Errors
47///
48/// Returns the git error text (also stored as the mirror's `last_error`), or a
49/// message if the repository or its on-disk path could not be resolved.
50pub async fn sync_one(store: &Store, config: &Config, mirror: &Mirror) -> Result<(), String> {
51 let repo = store
52 .repo_by_id(&mirror.repo_id)
53 .await
54 .map_err(|e| e.to_string())?
55 .ok_or_else(|| "repository no longer exists".to_string())?;
56 let repo_dir = git::repo_path(&config.storage.repo_dir, &repo.id).map_err(|e| e.to_string())?;
57 let home = config.storage.data_dir.join("tmp");
58 let cred = RemoteCred {
59 username: mirror.username.as_deref(),
60 secret: mirror.secret.as_deref(),
61 };
62
63 let result = match mirror.direction {
64 MirrorDirection::Pull => {
65 git::mirror::fetch(
66 &config.git.binary,
67 &repo_dir,
68 &mirror.remote_url,
69 cred,
70 &home,
71 )
72 .await
73 }
74 MirrorDirection::Push => {
75 let refspecs = push_refspecs(mirror.branch_filter.as_deref());
76 git::mirror::push(
77 &config.git.binary,
78 &repo_dir,
79 &mirror.remote_url,
80 cred,
81 &refspecs,
82 &home,
83 )
84 .await
85 }
86 };
87 if let Err(err) = &result {
88 tracing::warn!(mirror.id = %mirror.id, error = %err, "mirror sync failed");
89 }
90 let _ = store
91 .mirror_synced(&mirror.id, result.as_ref().err().map(String::as_str))
92 .await;
93 result
94}
95
96/// Sync every push mirror on `repo_id` that is configured to sync after a push.
97/// Best-effort: failures are recorded on the mirror, never propagated.
98pub async fn trigger_push_mirrors(store: &Store, config: &Config, repo_id: &str) {
99 let mirrors = match store.push_mirrors_on_push(repo_id).await {
100 Ok(m) => m,
101 Err(e) => {
102 tracing::warn!(error = %e, "could not load push mirrors");
103 return;
104 }
105 };
106 for mirror in &mirrors {
107 let _ = sync_one(store, config, mirror).await;
108 }
109}
110
111/// The background scheduler loop: every [`TICK`], sync each mirror whose interval
112/// has elapsed. Runs until the process ends.
113pub async fn run_scheduler(store: Store, config: Arc<Config>) {
114 let mut ticker = tokio::time::interval(TICK);
115 loop {
116 ticker.tick().await;
117 let now = now_ms();
118 let due = match store.due_mirrors(now).await {
119 Ok(d) => d,
120 Err(e) => {
121 tracing::warn!(error = %e, "could not query due mirrors");
122 continue;
123 }
124 };
125 for mirror in &due {
126 let _ = sync_one(&store, &config, mirror).await;
127 }
128 }
129}
130
131/// The current time in Unix milliseconds.
132fn now_ms() -> i64 {
133 use std::time::{SystemTime, UNIX_EPOCH};
134 SystemTime::now()
135 .duration_since(UNIX_EPOCH)
136 .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn push_refspecs_default_is_all_refs() {
145 assert_eq!(push_refspecs(None), git::mirror::all_refs());
146 assert_eq!(push_refspecs(Some(" ")), git::mirror::all_refs());
147 }
148
149 #[test]
150 fn push_refspecs_honours_a_branch_filter() {
151 let specs = push_refspecs(Some("main, release/*"));
152 assert_eq!(
153 specs,
154 vec![
155 "+refs/heads/main:refs/heads/main".to_string(),
156 "+refs/heads/release/*:refs/heads/release/*".to_string(),
157 "+refs/tags/*:refs/tags/*".to_string(),
158 ]
159 );
160 }
161}
crates/web/Cargo.toml +1 −0
@@ -16,6 +16,7 @@ model = { workspace = true }
16auth = { workspace = true }16auth = { workspace = true }
17mail = { workspace = true }17mail = { workspace = true }
18git = { workspace = true }18git = { workspace = true }
19mirror = { workspace = true }
19api = { workspace = true }20api = { workspace = true }
20highlight = { workspace = true }21highlight = { workspace = true }
21axum = { workspace = true }22axum = { workspace = true }
src/serve.rs +4 −0
@@ -68,11 +68,15 @@ pub fn run(req: ServeRequest) -> anyhow::Result<ExitCode> {
68 }68 }
69 });69 });
7070
71 // The mirror scheduler syncs due push/pull mirrors in the background.
72 let mirror_task = tokio::spawn(mirror::run_scheduler(store.clone(), Arc::clone(&config)));
73
71 let state = web::AppState::build(config, store, secret)?;74 let state = web::AppState::build(config, store, secret)?;
72 // Load admin config overrides from the database before serving.75 // Load admin config overrides from the database before serving.
73 state.reload_settings().await;76 state.reload_settings().await;
74 let result = web::serve(state, addr).await;77 let result = web::serve(state, addr).await;
75 ssh_task.abort();78 ssh_task.abort();
79 mirror_task.abort();
76 result?;80 result?;
77 Ok(ExitCode::SUCCESS)81 Ok(ExitCode::SUCCESS)
78 })82 })