forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
9.8 KiB
1//! Writes a backup set. See `mod.rs` for the layout and the rules.
2
3use std::path::{Path, PathBuf};
4
5use forge_core::store::snapshot::{SNAPSHOT_METHOD, verify_snapshot};
6use forge_core::{Error, Result};
7
8use super::{
9 BACKUP_DIR_PREFIX, BackupOptions, BackupSource, BackupSummary, DB_FILE, DbSnapshot,
10 FORMAT_VERSION, FileEntry, KEY_FILE, MANIFEST_FILE, Manifest, PARTIAL_PREFIX, RepoEntry, Scope,
11 WikiEntry, create_private_dir, empty_sha256, ensure_private_dir_all, fsync_dir, hash_file,
12 io_err, manifest_stamp, name_stamp, set_private, sorted, unique_suffix, write_private_file,
13};
14use forge_git::GitEnv;
15
16/// Makes a complete backup set under `opts.out` and returns its summary.
17///
18/// On any failure nothing is left behind: the `.partial-…` directory and the scratch area are
19/// removed, and the error is returned. A finished set is renamed into place as its last step.
20pub async fn create_backup(source: &BackupSource, opts: &BackupOptions) -> Result<BackupSummary> {
21 let now = time::OffsetDateTime::now_utc();
22 let stamp = name_stamp(now);
23 ensure_private_dir_all(&opts.out)?;
24 let out = opts.out.as_path();
25 let final_dir = out.join(format!("{BACKUP_DIR_PREFIX}{stamp}"));
26 if std::fs::symlink_metadata(&final_dir).is_ok() {
27 return Err(Error::Conflict(format!(
28 "backup set already exists: {}",
29 final_dir.display()
30 )));
31 }
32
33 let suffix = unique_suffix();
34 let staging = out.join(format!(
35 "{PARTIAL_PREFIX}{BACKUP_DIR_PREFIX}{stamp}-{suffix}"
36 ));
37 let scratch_root = out.join(format!("{PARTIAL_PREFIX}verify-{suffix}"));
38 create_private_dir(&staging)?;
39
40 let built = build(source, opts, &staging, &scratch_root, now).await;
41 remove_if_exists(&scratch_root);
42 let mut summary = match built {
43 Ok(summary) => summary,
44 Err(e) => {
45 remove_if_exists(&staging);
46 return Err(e);
47 }
48 };
49 if let Err(e) = std::fs::rename(&staging, &final_dir) {
50 remove_if_exists(&staging);
51 return Err(io_err(e));
52 }
53 fsync_dir(out)?;
54 summary.path = final_dir;
55 Ok(summary)
56}
57
58/// Best-effort removal of a partial or scratch path. Failures are logged, not returned, because
59/// the caller is already reporting an error or has finished.
60fn remove_if_exists(path: &Path) {
61 if std::fs::symlink_metadata(path).is_err() {
62 return;
63 }
64 let result = if path.is_dir() {
65 std::fs::remove_dir_all(path)
66 } else {
67 std::fs::remove_file(path)
68 };
69 if let Err(e) = result {
70 tracing::warn!(path = %path.display(), error = %e, "could not remove backup leftovers");
71 }
72}
73
74async fn build(
75 source: &BackupSource,
76 opts: &BackupOptions,
77 staging: &Path,
78 scratch_root: &Path,
79 now: time::OffsetDateTime,
80) -> Result<BackupSummary> {
81 // The snapshot is always taken: its repo list is the set of repos to bundle. Repos-only
82 // drops it afterwards.
83 let db_dir = staging.join("db");
84 create_private_dir(&db_dir)?;
85 let snap = db_dir.join("forge.db");
86 source.store.snapshot_to(&snap).await?;
87 set_private(&snap)?;
88 let info = verify_snapshot(&snap).await?;
89 for entry in std::fs::read_dir(&db_dir)? {
90 let name = entry?.file_name();
91 if name != "forge.db" {
92 return Err(Error::Invalid(format!(
93 "snapshot left an unexpected file next to it: {}",
94 name.to_string_lossy()
95 )));
96 }
97 }
98
99 let db_snapshot = if opts.scope == Scope::ReposOnly {
100 std::fs::remove_file(&snap)?;
101 std::fs::remove_dir(&db_dir)?;
102 None
103 } else {
104 Some(DbSnapshot {
105 method: SNAPSHOT_METHOD.to_string(),
106 schema_version: info.schema_version,
107 })
108 };
109
110 let mut repos = Vec::new();
111 if opts.scope != Scope::DbOnly {
112 create_private_dir(&staging.join("repos"))?;
113 create_private_dir(scratch_root)?;
114 for (idx, (id, owner, name)) in info.repos.iter().enumerate() {
115 repos.push(bundle_repo(source, staging, scratch_root, idx, id, owner, name).await?);
116 }
117 }
118
119 let mut rels = Vec::new();
120 if db_snapshot.is_some() {
121 rels.push(DB_FILE.to_string());
122 }
123 for repo in &repos {
124 if let Some(bundle) = &repo.bundle {
125 rels.push(bundle.clone());
126 }
127 if let Some(bundle) = repo.wiki.as_ref().and_then(|w| w.bundle.as_ref()) {
128 rels.push(bundle.clone());
129 }
130 }
131 let key_included = opts.include_key;
132 if key_included {
133 copy_key(&source.git.data_dir, staging)?;
134 rels.push(KEY_FILE.to_string());
135 }
136 rels.sort();
137
138 let mut files = Vec::with_capacity(rels.len());
139 for rel in rels {
140 let path = staging.join(&rel);
141 let (bytes, sha256) = tokio::task::spawn_blocking(move || -> Result<(u64, String)> {
142 let f = std::fs::File::open(&path)?;
143 f.sync_all()?;
144 drop(f);
145 hash_file(&path)
146 })
147 .await
148 .map_err(|e| Error::Io(std::io::Error::other(e)))??;
149 files.push(FileEntry {
150 path: rel,
151 bytes,
152 sha256,
153 });
154 }
155
156 let schema_version = db_snapshot.as_ref().map(|d| d.schema_version);
157 let bundles = repos.iter().filter(|r| r.bundle.is_some()).count();
158 let empty_repos = repos.len() - bundles;
159 let total_bytes = files.iter().map(|f| f.bytes).sum();
160 let file_count = files.len();
161 let repo_count = repos.len();
162
163 let manifest = Manifest {
164 format: FORMAT_VERSION,
165 forge_version: format!(
166 "{} ({})",
167 env!("CARGO_PKG_VERSION"),
168 env!("FORGE_BUILD_SHA")
169 ),
170 created_at: manifest_stamp(now),
171 base_url: source.base_url.clone(),
172 scope: opts.scope,
173 db_snapshot,
174 key_included,
175 files: sorted(files),
176 repos,
177 };
178 let json = serde_json::to_vec_pretty(&manifest)
179 .map_err(|e| Error::Invalid(format!("manifest: {e}")))?;
180 write_private_file(&staging.join(MANIFEST_FILE), &json)?;
181 for dir in ["db", "repos", "keys"] {
182 let path = staging.join(dir);
183 if path.is_dir() {
184 fsync_dir(&path)?;
185 }
186 }
187 fsync_dir(staging)?;
188
189 Ok(BackupSummary {
190 path: PathBuf::new(),
191 scope: opts.scope,
192 files: file_count,
193 bytes: total_bytes,
194 repos: repo_count,
195 bundles,
196 empty_repos,
197 schema_version,
198 key_included,
199 })
200}
201
202/// Bundles one repo and its wiki (when it has one), each verified by importing it into a fresh
203/// scratch repo, and checks that the imported refs match the refs read before bundling. A mismatch
204/// means the repo changed during the backup, so the whole backup fails and should be run again.
205async fn bundle_repo(
206 source: &BackupSource,
207 staging: &Path,
208 scratch_root: &Path,
209 idx: usize,
210 id: &str,
211 owner: &str,
212 name: &str,
213) -> Result<RepoEntry> {
214 let git = &source.git;
215 let repo = forge_git::repo::repo_path(&git.data_dir, id)?;
216 let (refs_sha256, bundle) = bundle_git_repo(
217 git,
218 &repo,
219 staging,
220 scratch_root,
221 &format!("{idx}.git"),
222 &format!("repos/{id}.bundle"),
223 id,
224 )
225 .await?;
226 let wiki_dir = forge_git::repo::wiki_path(&git.data_dir, id)?;
227 let wiki = if wiki_dir.join("HEAD").is_file() {
228 let (refs_sha256, bundle) = bundle_git_repo(
229 git,
230 &wiki_dir,
231 staging,
232 scratch_root,
233 &format!("{idx}.wiki.git"),
234 &format!("repos/{id}.wiki.bundle"),
235 id,
236 )
237 .await?;
238 Some(WikiEntry {
239 refs_sha256,
240 bundle,
241 })
242 } else {
243 None
244 };
245 Ok(RepoEntry {
246 id: id.to_string(),
247 owner: owner.to_string(),
248 name: name.to_string(),
249 refs_sha256,
250 bundle,
251 wiki,
252 })
253}
254
255/// Bundles the bare repo at `repo_dir` to `rel` under `staging` and verifies it. Returns the refs
256/// checksum and the bundle path (`None` for an empty repo). `label` names the repo in errors.
257async fn bundle_git_repo(
258 git: &GitEnv,
259 repo_dir: &Path,
260 staging: &Path,
261 scratch_root: &Path,
262 scratch_name: &str,
263 rel: &str,
264 label: &str,
265) -> Result<(String, Option<String>)> {
266 let refs_sha256 = forge_git::backup::refs_checksum(git, repo_dir).await?;
267 let out = staging.join(rel);
268 let bundle = match forge_git::backup::bundle_create(git, repo_dir, &out).await? {
269 None => {
270 if refs_sha256 != empty_sha256() {
271 return Err(Error::Conflict(format!(
272 "repo {label} changed while it was being bundled; run the backup again"
273 )));
274 }
275 None
276 }
277 Some(()) => {
278 let scratch = scratch_root.join(scratch_name);
279 forge_git::repo::init_bare(git, &scratch, "main").await?;
280 let imported = forge_git::backup::bundle_verify(git, &scratch, &out).await?;
281 remove_if_exists(&scratch);
282 if imported != refs_sha256 {
283 return Err(Error::Conflict(format!(
284 "repo {label} changed while it was being bundled; run the backup again"
285 )));
286 }
287 Some(rel.to_string())
288 }
289 };
290 Ok((refs_sha256, bundle))
291}
292
293/// Copies `data_dir/forge.key` into the staging dir as `keys/forge.key`, mode 0600.
294fn copy_key(data_dir: &Path, staging: &Path) -> Result<()> {
295 let src = data_dir.join("forge.key");
296 if !src.is_file() {
297 return Err(Error::Config(format!(
298 "--include-key was given but there is no key at {}",
299 src.display()
300 )));
301 }
302 create_private_dir(&staging.join("keys"))?;
303 let dst = staging.join(KEY_FILE);
304 std::fs::copy(&src, &dst)?;
305 set_private(&dst)?;
306 std::fs::File::open(&dst)?.sync_all()?;
307 Ok(())
308}