Commit 4906a493f2

4906a493f253d7242e69aa4cc207d3c424e4094b

parent: 6576de5182

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 22:06 UTC

backup: hold repository moves off during a full backup; verify git connectivity

Ref #259

Layout: unified · split

cmd/gitbayd/backup.go +90 −15
@@ -15,7 +15,9 @@ import (
1515 "filippo.io/age"
1616 "github.com/spf13/cobra"
1717
18 "gitbay.org/gitbay/internal/backuplock"
1819 "gitbay.org/gitbay/internal/config"
20 "gitbay.org/gitbay/internal/gitutil"
1921 "gitbay.org/gitbay/internal/store"
2022)
2123
@@ -62,7 +64,7 @@ public keys and its name ends in .age. --verify then needs --identity
6264 }
6365 cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz; .age is appended when [backup] age_recipients is set)")
6466 cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories")
65 cmd.Flags().StringVar(&verify, "verify", "", "check an archive instead of writing one: database integrity, and its repositories against the archive's")
67 cmd.Flags().StringVar(&verify, "verify", "", "check an archive instead of writing one: database integrity, its repositories against the archive's, and git connectivity of each")
6668 cmd.Flags().StringVar(&identity, "identity", "", "with --verify: an age identity file that opens an encrypted archive")
6769 return cmd
6870}
@@ -90,6 +92,17 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error {
9092 return fmt.Errorf("%s ends in .age but [backup] age_recipients is not set, so the archive would not be encrypted", out)
9193 }
9294
95 // Deletes, renames and transfers wait until the walk finishes, so
96 // every repository the snapshot names is still on disk when the walk
97 // reaches it (#259). A database-only archive reads no repository.
98 if !dbOnly {
99 release, err := backuplock.Hold(cfg.Server.Root)
100 if err != nil {
101 return fmt.Errorf("backup lock: %w", err)
102 }
103 defer release()
104 }
105
93106 st, err := openStore(cfg)
94107 if err != nil {
95108 return err
@@ -142,6 +155,7 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error {
142155 skip := map[string]bool{
143156 "gitbay.db": true, "gitbay.db-wal": true, "gitbay.db-shm": true,
144157 "hook.sock": true, "askpass.sh": true, "hooks": true,
158 backuplock.Name: true,
145159 }
146160 repoCount := 0
147161 root := cfg.Server.Root
@@ -170,7 +184,11 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error {
170184 if strings.HasSuffix(rel, ".git") {
171185 repoCount++
172186 }
173 return nil // directories are implied by member paths
187 // A directory entry, even for one that holds no file (a
188 // bare repository's refs/heads and refs/tags once every
189 // ref is packed), so extraction recreates it: git's own
190 // repository discovery needs refs/ to exist.
191 return addDir(tw, path, filepath.ToSlash(rel))
174192 }
175193 return addFile(tw, path, filepath.ToSlash(rel))
176194 })
@@ -252,11 +270,29 @@ func addFile(tw *tar.Writer, path, name string) error {
252270 return err
253271}
254272
273// addDir writes a directory entry, so an empty directory survives
274// extraction. The mode never exceeds 0755, whatever the source directory
275// carries.
276func addDir(tw *tar.Writer, path, name string) error {
277 info, err := os.Stat(path)
278 if err != nil {
279 return err
280 }
281 hdr, err := tar.FileInfoHeader(info, "")
282 if err != nil {
283 return err
284 }
285 hdr.Name = name + "/"
286 hdr.Mode = hdr.Mode&^0o777 | hdr.Mode&0o755
287 return tw.WriteHeader(hdr)
288}
289
255290// verifyBackup reads an archive back, decrypting it with identity when it
256// is encrypted: the database snapshot must pass
257// SQLite's integrity check, and every repository it names must be in the
258// archive. A database-only archive is checked for integrity alone and
259// says so. Nothing is written except a temporary copy of the database.
291// is encrypted: the database snapshot must pass SQLite's integrity check,
292// every repository it names must be in the archive, and each of those
293// must pass git fsck --connectivity-only. A database-only archive is
294// checked for integrity alone and says so. Repositories are extracted to
295// a temporary directory for the check, so it needs free space for them.
260296func verifyBackup(path, identity string) error {
261297 f, err := os.Open(path)
262298 if err != nil {
@@ -292,21 +328,32 @@ func verifyBackup(path, identity string) error {
292328 switch {
293329 case h.Name == "gitbay.db":
294330 dbPath = filepath.Join(tmp, "gitbay.db")
295 w, err := os.Create(dbPath)
296 if err != nil {
297 return err
298 }
299 if _, err := io.Copy(w, tr); err != nil {
300 w.Close()
331 if err := extractTo(tr, dbPath); err != nil {
301332 return fmt.Errorf("%s: extracting the database: %w", path, err)
302333 }
303 w.Close()
304334 case strings.HasPrefix(h.Name, "repos/"):
335 trimmed := strings.TrimSuffix(h.Name, "/")
305336 // repos/<owner>/<name>.git/HEAD marks one repository present.
306 parts := strings.Split(h.Name, "/")
337 parts := strings.Split(trimmed, "/")
307338 if len(parts) == 4 && parts[3] == "HEAD" && strings.HasSuffix(parts[2], ".git") {
308339 inArchive[parts[1]+"/"+strings.TrimSuffix(parts[2], ".git")] = true
309340 }
341 if !filepath.IsLocal(trimmed) {
342 return fmt.Errorf("%s: member %q leaves the archive root", path, h.Name)
343 }
344 dest := filepath.Join(tmp, filepath.FromSlash(trimmed))
345 switch h.Typeflag {
346 case tar.TypeDir:
347 // The archive's directory modes do not matter to fsck, and
348 // a hostile one would stop RemoveAll cleaning up.
349 if err := os.MkdirAll(dest, 0o700); err != nil {
350 return fmt.Errorf("%s: creating %s: %w", path, h.Name, err)
351 }
352 case tar.TypeReg:
353 if err := extractTo(tr, dest); err != nil {
354 return fmt.Errorf("%s: extracting %s: %w", path, h.Name, err)
355 }
356 }
310357 }
311358 }
312359 // Read to the end so gzip checks its trailer and age its final chunk.
@@ -351,11 +398,39 @@ func verifyBackup(path, identity string) error {
351398 return fmt.Errorf("%s: %d repositories the database names are not in the archive: %s", path, len(missing), strings.Join(missing, ", "))
352399 }
353400 if extra > 0 {
354 fmt.Printf("%d repositories in the archive that the database does not name (deleted after the snapshot)\n", extra)
401 fmt.Printf("%d repositories in the archive that the database does not name (created after the snapshot)\n", extra)
355402 }
403 var broken []string
404 for _, r := range repos {
405 dir := filepath.Join(tmp, "repos", r.OwnerName, r.Name+".git")
406 if err := gitutil.FsckConnectivity(dir); err != nil {
407 fmt.Fprintf(os.Stderr, "%s: %v\n", r.Path(), err)
408 broken = append(broken, r.Path())
409 }
410 }
411 if len(broken) > 0 {
412 return fmt.Errorf("%s: %d repositories fail the connectivity check: %s", path, len(broken), strings.Join(broken, ", "))
413 }
414 fmt.Printf("connectivity ok on %d repositories\n", len(repos))
356415 return nil
357416}
358417
418// extractTo writes one archive member to dest, owner-only.
419func extractTo(r io.Reader, dest string) error {
420 if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil {
421 return err
422 }
423 w, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
424 if err != nil {
425 return err
426 }
427 if _, err := io.Copy(w, r); err != nil {
428 w.Close()
429 return err
430 }
431 return w.Close()
432}
433
359434const ageHeader = "age-encryption.org/v1\n"
360435
361436// archiveReader returns the archive's gzip stream, decrypting it first
cmd/gitbayd/backup_test.go +159
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "io"
88 "os"
9 "os/exec"
910 "path/filepath"
1011 "sort"
1112 "strings"
@@ -14,6 +15,7 @@ import (
1415
1516 "filippo.io/age"
1617
18 "gitbay.org/gitbay/internal/backuplock"
1719 "gitbay.org/gitbay/internal/config"
1820)
1921
@@ -324,3 +326,160 @@ func TestArchivePath(t *testing.T) {
324326 }
325327 }
326328}
329
330func gitIn(t *testing.T, dir string, args ...string) string {
331 t.Helper()
332 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
333 cmd.Env = append(os.Environ(),
334 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e",
335 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e")
336 out, err := cmd.CombinedOutput()
337 if err != nil {
338 t.Fatalf("git %v: %v\n%s", args, err, out)
339 }
340 return strings.TrimSpace(string(out))
341}
342
343// verify runs git's connectivity check on every repository the
344// database names: a repository missing an object fails it.
345func TestVerifyChecksConnectivity(t *testing.T) {
346 cfg := testConfig(t)
347 st, err := openStore(cfg)
348 if err != nil {
349 t.Fatal(err)
350 }
351 uid, err := st.CreateUser("krz", false)
352 if err != nil {
353 t.Fatal(err)
354 }
355 if _, err := st.CreateRepo("user", uid, "thing", "public"); err != nil {
356 t.Fatal(err)
357 }
358 st.Close()
359
360 work := t.TempDir()
361 gitIn(t, work, "init", "-q", "-b", "main")
362 if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil {
363 t.Fatal(err)
364 }
365 gitIn(t, work, "add", "a.txt")
366 gitIn(t, work, "commit", "-q", "-m", "one")
367 dir := filepath.Join(cfg.Server.Root, "repos", "krz", "thing.git")
368 gitIn(t, work, "clone", "-q", "--bare", work, dir)
369
370 good := filepath.Join(t.TempDir(), "good.tar.gz")
371 if err := runBackup(cfg, good, false); err != nil {
372 t.Fatal(err)
373 }
374 if err := verifyBackup(good, ""); err != nil {
375 t.Fatalf("intact archive: %v", err)
376 }
377
378 blob := gitIn(t, dir, "rev-parse", "HEAD:a.txt")
379 if err := os.Remove(filepath.Join(dir, "objects", blob[:2], blob[2:])); err != nil {
380 t.Fatal(err)
381 }
382 bad := filepath.Join(t.TempDir(), "bad.tar.gz")
383 if err := runBackup(cfg, bad, false); err != nil {
384 t.Fatal(err)
385 }
386 err = verifyBackup(bad, "")
387 if err == nil || !strings.Contains(err.Error(), "krz/thing") || !strings.Contains(err.Error(), "connectivity") {
388 t.Fatalf("archive with a missing blob: %v", err)
389 }
390}
391
392// A full backup waits for a delete under way, and does not archive its
393// own lock file.
394func TestFullBackupWaitsForRepositoryMoves(t *testing.T) {
395 cfg := testConfig(t)
396 s, err := openStore(cfg)
397 if err != nil {
398 t.Fatal(err)
399 }
400 s.Close()
401 inFlight, err := backuplock.TryShared(cfg.Server.Root)
402 if err != nil {
403 t.Fatal(err)
404 }
405 out := filepath.Join(t.TempDir(), "b.tar.gz")
406 done := make(chan error, 1)
407 go func() { done <- runBackup(cfg, out, false) }()
408 select {
409 case err := <-done:
410 t.Fatalf("backup finished while a delete held the lock: %v", err)
411 case <-time.After(200 * time.Millisecond):
412 }
413 inFlight()
414 select {
415 case err := <-done:
416 if err != nil {
417 t.Fatal(err)
418 }
419 case <-time.After(10 * time.Second):
420 t.Fatal("backup never started after the delete finished")
421 }
422 for _, n := range members(t, out) {
423 if n == backuplock.Name {
424 t.Fatalf("archive carries %s", n)
425 }
426 }
427}
428
429// A repository with every ref packed keeps its empty refs/heads and
430// refs/tags directories through backup and extraction, the same as a real
431// restore would: git needs refs/ to recognize a bare repository at all,
432// even when every ref lives in packed-refs (#259).
433func TestBackupPreservesPackedRefDirs(t *testing.T) {
434 cfg := testConfig(t)
435 st, err := openStore(cfg)
436 if err != nil {
437 t.Fatal(err)
438 }
439 uid, err := st.CreateUser("krz", false)
440 if err != nil {
441 t.Fatal(err)
442 }
443 if _, err := st.CreateRepo("user", uid, "thing", "public"); err != nil {
444 t.Fatal(err)
445 }
446 st.Close()
447
448 work := t.TempDir()
449 gitIn(t, work, "init", "-q", "-b", "main")
450 if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil {
451 t.Fatal(err)
452 }
453 gitIn(t, work, "add", "a.txt")
454 gitIn(t, work, "commit", "-q", "-m", "one")
455 dir := filepath.Join(cfg.Server.Root, "repos", "krz", "thing.git")
456 gitIn(t, work, "clone", "-q", "--bare", work, dir)
457 gitIn(t, dir, "pack-refs", "--all")
458 entries, err := os.ReadDir(filepath.Join(dir, "refs", "heads"))
459 if err != nil {
460 t.Fatal(err)
461 }
462 if len(entries) != 0 {
463 t.Fatalf("refs/heads not empty after pack-refs --all: %v", entries)
464 }
465
466 archive := filepath.Join(t.TempDir(), "b.tar.gz")
467 if err := runBackup(cfg, archive, false); err != nil {
468 t.Fatal(err)
469 }
470
471 // verify sees the archive exactly as a restore would: no workaround.
472 if err := verifyBackup(archive, ""); err != nil {
473 t.Fatalf("verify: %v", err)
474 }
475
476 restored := t.TempDir()
477 if out, err := exec.Command("tar", "-xzf", archive, "-C", restored).CombinedOutput(); err != nil {
478 t.Fatalf("extract: %v\n%s", err, out)
479 }
480 restoredRepo := filepath.Join(restored, "repos", "krz", "thing.git")
481 if got := gitIn(t, restoredRepo, "rev-parse", "--verify", "HEAD"); got == "" {
482 t.Fatal("rev-parse --verify HEAD returned nothing after restore")
483 }
484 gitIn(t, restoredRepo, "fsck", "--connectivity-only", "--no-progress", "--no-dangling")
485}
e2e/backup_test.go +4
@@ -87,6 +87,10 @@ func TestAdminBackup(t *testing.T) {
8787 t.Fatal("the archived database carries the build secret in clear")
8888 }
8989
90 if out := inst.admin(t, "admin", "backup", "--verify", archive); !strings.Contains(out, "connectivity ok on 1 repositories") {
91 t.Fatalf("verify: %s", out)
92 }
93
9094 // Restore: extract into a fresh root and serve from it.
9195 root2 := t.TempDir()
9296 if outB, err := exec.Command("tar", "-xzf", archive, "-C", root2).CombinedOutput(); err != nil {