Commit b2cce47084

b2cce470846123d765a4839fda58088eb94c54fd

parent: 51386c203f

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-02 02:56 UTC

backup --verify: read an archive back

Restore had been drilled once. Nothing checked an archive after it was
written, so a damaged or incomplete one looked like any other.

admin backup --verify <archive> extracts the snapshot to a temporary
file, runs SQLite's integrity check, and compares the repositories the
snapshot names against the .git directories in the archive. A
database-only archive is checked for integrity and says so. Damage, a
failed check, or a repository the database names that the archive
lacks all exit non-zero, which is what lets the backup timer run it
after every write.

Closes #80

Layout: unified · split

cmd/gitbayd/backup.go +97 −1
@@ -26,7 +26,7 @@ import (
26// objects in the archive (harmless); the reverse order could leave database 26// objects in the archive (harmless); the reverse order could leave database
27// rows pointing at objects the archive never captured. 27// rows pointing at objects the archive never captured.
28func backupCmd() *cobra.Command { 28func backupCmd() *cobra.Command {
29 var out string 29 var out, verify string
30 var dbOnly bool 30 var dbOnly bool
31 cmd := &cobra.Command{ 31 cmd := &cobra.Command{
32 Use: "backup", 32 Use: "backup",
@@ -44,6 +44,9 @@ so it supplements a full backup and does not replace one.
44Restore: extract into an empty directory, point server.root at it, start 44Restore: extract into an empty directory, point server.root at it, start
45gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`, 45gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`,
46 RunE: func(cmd *cobra.Command, args []string) error { 46 RunE: func(cmd *cobra.Command, args []string) error {
47 if verify != "" {
48 return verifyBackup(verify)
49 }
47 cfg, err := config.Load(configPath) 50 cfg, err := config.Load(configPath)
48 if err != nil { 51 if err != nil {
49 return err 52 return err
@@ -56,6 +59,7 @@ gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`,
56 } 59 }
57 cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz)") 60 cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz)")
58 cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories") 61 cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories")
62 cmd.Flags().StringVar(&verify, "verify", "", "check an archive instead of writing one: database integrity, and its repositories against the archive's")
59 return cmd 63 return cmd
60} 64}
61 65
@@ -175,3 +179,95 @@ func addFile(tw *tar.Writer, path, name string) error {
175 _, err = io.Copy(tw, src) 179 _, err = io.Copy(tw, src)
176 return err 180 return err
177} 181}
182
183// verifyBackup reads an archive back: the database snapshot must pass
184// SQLite's integrity check, and every repository it names must be in the
185// archive. A database-only archive is checked for integrity alone and
186// says so. Nothing is written except a temporary copy of the database.
187func verifyBackup(path string) error {
188 f, err := os.Open(path)
189 if err != nil {
190 return err
191 }
192 defer f.Close()
193 gz, err := gzip.NewReader(f)
194 if err != nil {
195 return fmt.Errorf("%s: not a gzip archive: %w", path, err)
196 }
197 tr := tar.NewReader(gz)
198 tmp, err := os.MkdirTemp("", "gitbay-verify-")
199 if err != nil {
200 return err
201 }
202 defer os.RemoveAll(tmp)
203 dbPath := ""
204 inArchive := map[string]bool{}
205 members := 0
206 for {
207 h, err := tr.Next()
208 if err == io.EOF {
209 break
210 }
211 if err != nil {
212 return fmt.Errorf("%s: archive damaged after %d members: %w", path, members, err)
213 }
214 members++
215 switch {
216 case h.Name == "gitbay.db":
217 dbPath = filepath.Join(tmp, "gitbay.db")
218 w, err := os.Create(dbPath)
219 if err != nil {
220 return err
221 }
222 if _, err := io.Copy(w, tr); err != nil {
223 w.Close()
224 return fmt.Errorf("%s: extracting the database: %w", path, err)
225 }
226 w.Close()
227 case strings.HasPrefix(h.Name, "repos/"):
228 // repos/<owner>/<name>.git/HEAD marks one repository present.
229 parts := strings.Split(h.Name, "/")
230 if len(parts) == 4 && parts[3] == "HEAD" && strings.HasSuffix(parts[2], ".git") {
231 inArchive[parts[1]+"/"+strings.TrimSuffix(parts[2], ".git")] = true
232 }
233 }
234 }
235 if dbPath == "" {
236 return fmt.Errorf("%s: no gitbay.db in the archive", path)
237 }
238 st, err := store.Open(dbPath)
239 if err != nil {
240 return fmt.Errorf("%s: database does not open: %w", path, err)
241 }
242 defer st.Close()
243 var integrity string
244 if err := st.DB.QueryRow("PRAGMA integrity_check").Scan(&integrity); err != nil {
245 return fmt.Errorf("%s: integrity check: %w", path, err)
246 }
247 if integrity != "ok" {
248 return fmt.Errorf("%s: database integrity: %s", path, integrity)
249 }
250 repos, err := st.ListAllRepos()
251 if err != nil {
252 return err
253 }
254 if len(inArchive) == 0 {
255 fmt.Printf("%s: database only; integrity ok, %d repositories in the database, none in the archive\n", path, len(repos))
256 return nil
257 }
258 var missing []string
259 for _, r := range repos {
260 if !inArchive[r.Path()] {
261 missing = append(missing, r.Path())
262 }
263 }
264 extra := len(inArchive) - (len(repos) - len(missing))
265 fmt.Printf("%s: integrity ok, %d repositories in the database, %d in the archive\n", path, len(repos), len(inArchive))
266 if len(missing) > 0 {
267 return fmt.Errorf("%s: %d repositories the database names are not in the archive: %s", path, len(missing), strings.Join(missing, ", "))
268 }
269 if extra > 0 {
270 fmt.Printf("%d repositories in the archive that the database does not name (deleted after the snapshot, or a wiki)\n", extra)
271 }
272 return nil
273}
e2e/reap_test.go +40
@@ -204,3 +204,43 @@ func TestGCLFSOrphans(t *testing.T) {
204 t.Fatalf("after gc: kept=%v orphan=%v young=%v", exists(kept), exists(orphan), exists(young)) 204 t.Fatalf("after gc: kept=%v orphan=%v young=%v", exists(kept), exists(orphan), exists(young))
205 } 205 }
206} 206}
207
208// backup --verify reads an archive back and says whether a restore would
209// have what the database expects.
210func TestBackupVerify(t *testing.T) {
211 inst := startInstance(t)
212 aliceKey := inst.newKey(t, "alice")
213 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
214 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/keep"); code != 0 {
215 t.Fatal("repo create failed")
216 }
217 full := filepath.Join(t.TempDir(), "full.tar.gz")
218 inst.admin(t, "admin", "backup", "--out", full)
219 if out := inst.admin(t, "admin", "backup", "--verify", full); !strings.Contains(out, "integrity ok, 1 repositories in the database, 1 in the archive") {
220 t.Fatalf("verify full:\n%s", out)
221 }
222 dbOnly := filepath.Join(t.TempDir(), "db.tar.gz")
223 inst.admin(t, "admin", "backup", "--db-only", "--out", dbOnly)
224 if out := inst.admin(t, "admin", "backup", "--verify", dbOnly); !strings.Contains(out, "database only; integrity ok, 1 repositories in the database") {
225 t.Fatalf("verify db-only:\n%s", out)
226 }
227 // A truncated archive is named as damaged, not reported healthy.
228 raw, _ := os.ReadFile(full)
229 cut := filepath.Join(t.TempDir(), "cut.tar.gz")
230 os.WriteFile(cut, raw[:len(raw)/2], 0o644)
231 if out := inst.forgedAdminErr(t, "admin", "backup", "--verify", cut); !strings.Contains(out, "damaged") && !strings.Contains(out, "unexpected EOF") {
232 t.Fatalf("verify truncated:\n%s", out)
233 }
234 // A repository the database names but the archive lacks fails it.
235 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/late"); code != 0 {
236 t.Fatal("repo create failed")
237 }
238 late := filepath.Join(t.TempDir(), "late.tar.gz")
239 inst.admin(t, "admin", "backup", "--out", late)
240 os.RemoveAll(filepath.Join(inst.root, "repos", "alice", "late.git"))
241 stale := filepath.Join(t.TempDir(), "stale.tar.gz")
242 inst.admin(t, "admin", "backup", "--out", stale)
243 if out := inst.forgedAdminErr(t, "admin", "backup", "--verify", stale); !strings.Contains(out, "not in the archive: alice/late") {
244 t.Fatalf("verify missing repo:\n%s", out)
245 }
246}