Commit 5f1152bca4
Verified · cmc
Layout: unified · split
.gitbay/wiki/Admin.org +6 −3
| @@ -456,9 +456,12 @@ missing repository or a missing object. | |||
| 456 | 456 | ||
| 457 | A full backup holds =<root>/backup.lock= from its database snapshot to | 457 | A full backup holds =<root>/backup.lock= from its database snapshot to |
| 458 | its last repository. While it runs, =repo delete=, =repo rename=, | 458 | its last repository. While it runs, =repo delete=, =repo rename=, |
| 459 | =repo transfer=, =admin repo delete= and =org rename= refuse with "a | 459 | =repo transfer=, =admin repo delete=, =org rename=, =admin mr prune= |
| 460 | backup is running"; retry when it finishes. Database-only backups take | 460 | and =gitbayd admin gc= refuse with "a backup is running"; retry when it |
| 461 | no lock. | 461 | finishes. Database-only backups take no lock. A pack that git's own |
| 462 | automatic gc removes during the walk is skipped; each repository's refs | ||
| 463 | are archived before its objects, so the refs still find their objects, | ||
| 464 | and =--verify= reports it if one does not. | ||
| 462 | 465 | ||
| 463 | Verifying an archive of unknown origin is safe as an unprivileged | 466 | Verifying an archive of unknown origin is safe as an unprivileged |
| 464 | user: the connectivity check runs git against the archived | 467 | user: the connectivity check runs git against the archived |
cmd/gitbayd/backup.go +47 −12
| @@ -162,14 +162,17 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error { | |||
| 162 | repoCount := 0 | 162 | repoCount := 0 |
| 163 | root := cfg.Server.Root | 163 | root := cfg.Server.Root |
| 164 | if !dbOnly { | 164 | if !dbOnly { |
| 165 | err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { | 165 | err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { |
| 166 | if err != nil { | ||
| 167 | return err | ||
| 168 | } | ||
| 169 | rel, err := filepath.Rel(root, path) | 166 | rel, err := filepath.Rel(root, path) |
| 170 | if err != nil { | 167 | if err != nil { |
| 171 | return err | 168 | return err |
| 172 | } | 169 | } |
| 170 | if walkErr != nil { | ||
| 171 | if vanished(walkErr, rel) { | ||
| 172 | return nil | ||
| 173 | } | ||
| 174 | return walkErr | ||
| 175 | } | ||
| 173 | if rel == "." { | 176 | if rel == "." { |
| 174 | return nil | 177 | return nil |
| 175 | } | 178 | } |
| @@ -195,6 +198,9 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error { | |||
| 195 | // ref is packed), so extraction recreates it: git's own | 198 | // ref is packed), so extraction recreates it: git's own |
| 196 | // repository discovery needs refs/ to exist. | 199 | // repository discovery needs refs/ to exist. |
| 197 | if err := addDir(tw, path, filepath.ToSlash(rel)); err != nil { | 200 | if err := addDir(tw, path, filepath.ToSlash(rel)); err != nil { |
| 201 | if vanished(err, rel) { | ||
| 202 | return filepath.SkipDir | ||
| 203 | } | ||
| 198 | return err | 204 | return err |
| 199 | } | 205 | } |
| 200 | if strings.HasSuffix(rel, ".git") { | 206 | if strings.HasSuffix(rel, ".git") { |
| @@ -206,7 +212,11 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error { | |||
| 206 | } | 212 | } |
| 207 | return nil | 213 | return nil |
| 208 | } | 214 | } |
| 209 | return addFile(tw, path, filepath.ToSlash(rel)) | 215 | beforeAdd(path) |
| 216 | if err := addFile(tw, path, filepath.ToSlash(rel)); !vanished(err, rel) { | ||
| 217 | return err | ||
| 218 | } | ||
| 219 | return nil | ||
| 210 | }) | 220 | }) |
| 211 | if err != nil { | 221 | if err != nil { |
| 212 | return err | 222 | return err |
| @@ -295,6 +305,29 @@ func addRefs(tw *tar.Writer, path, name string) error { | |||
| 295 | }) | 305 | }) |
| 296 | } | 306 | } |
| 297 | 307 | ||
| 308 | // beforeAdd runs before each file the walk archives outside refs. Tests | ||
| 309 | // use it to remove a file between listing and reading. | ||
| 310 | var beforeAdd = func(path string) {} | ||
| 311 | |||
| 312 | // vanished reports a file or directory under a repository's objects/ | ||
| 313 | // that went between the walk listing it and reading it: a pack or loose | ||
| 314 | // object a concurrent gc or receive.autogc removed. The walk skips it. | ||
| 315 | // Refs archived earlier reach only objects that are still reachable, and | ||
| 316 | // a repack writes those into a new pack before removing the old one; if | ||
| 317 | // one is lost regardless, verify's fsck reports it. | ||
| 318 | func vanished(err error, rel string) bool { | ||
| 319 | if !errors.Is(err, fs.ErrNotExist) { | ||
| 320 | return false | ||
| 321 | } | ||
| 322 | parts := strings.Split(filepath.ToSlash(rel), "/") | ||
| 323 | for i := 0; i+2 < len(parts); i++ { | ||
| 324 | if strings.HasSuffix(parts[i], ".git") && parts[i+1] == "objects" { | ||
| 325 | return true | ||
| 326 | } | ||
| 327 | } | ||
| 328 | return false | ||
| 329 | } | ||
| 330 | |||
| 298 | // syncDir makes a rename in dir durable. | 331 | // syncDir makes a rename in dir durable. |
| 299 | func syncDir(dir string) error { | 332 | func syncDir(dir string) error { |
| 300 | d, err := os.Open(dir) | 333 | d, err := os.Open(dir) |
| @@ -313,8 +346,15 @@ func snapshotDB(st *store.Store, dest string) error { | |||
| 313 | return err | 346 | return err |
| 314 | } | 347 | } |
| 315 | 348 | ||
| 349 | // addFile opens before writing the header, so a file removed after the | ||
| 350 | // walk listed it fails before the archive has a member for it. | ||
| 316 | func addFile(tw *tar.Writer, path, name string) error { | 351 | func addFile(tw *tar.Writer, path, name string) error { |
| 317 | info, err := os.Stat(path) | 352 | src, err := os.Open(path) |
| 353 | if err != nil { | ||
| 354 | return err | ||
| 355 | } | ||
| 356 | defer src.Close() | ||
| 357 | info, err := src.Stat() | ||
| 318 | if err != nil { | 358 | if err != nil { |
| 319 | return err | 359 | return err |
| 320 | } | 360 | } |
| @@ -326,12 +366,7 @@ func addFile(tw *tar.Writer, path, name string) error { | |||
| 326 | if err := tw.WriteHeader(hdr); err != nil { | 366 | if err := tw.WriteHeader(hdr); err != nil { |
| 327 | return err | 367 | return err |
| 328 | } | 368 | } |
| 329 | src, err := os.Open(path) | 369 | _, err = io.CopyN(tw, src, hdr.Size) |
| 330 | if err != nil { | ||
| 331 | return err | ||
| 332 | } | ||
| 333 | defer src.Close() | ||
| 334 | _, err = io.Copy(tw, src) | ||
| 335 | return err | 370 | return err |
| 336 | } | 371 | } |
| 337 | 372 | ||
cmd/gitbayd/backup_test.go +78
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | "compress/gzip" | 5 | "compress/gzip" |
| 6 | "errors" | 6 | "errors" |
| 7 | "io" | 7 | "io" |
| 8 | "io/fs" | ||
| 8 | "os" | 9 | "os" |
| 9 | "os/exec" | 10 | "os/exec" |
| 10 | "path/filepath" | 11 | "path/filepath" |
| @@ -548,3 +549,80 @@ func TestBackupArchivesRefsBeforeObjects(t *testing.T) { | |||
| 548 | } | 549 | } |
| 549 | gitIn(t, repo, "cat-file", "-e", second) | 550 | gitIn(t, repo, "cat-file", "-e", second) |
| 550 | } | 551 | } |
| 552 | |||
| 553 | // A pack removed between the walk listing it and reading it is skipped, | ||
| 554 | // and verify's fsck then reports what it held; a vanished file outside | ||
| 555 | // objects/ still fails the backup. | ||
| 556 | func TestBackupSkipsVanishedObjects(t *testing.T) { | ||
| 557 | cfg := testConfig(t) | ||
| 558 | st, err := openStore(cfg) | ||
| 559 | if err != nil { | ||
| 560 | t.Fatal(err) | ||
| 561 | } | ||
| 562 | uid, err := st.CreateUser("krz", false) | ||
| 563 | if err != nil { | ||
| 564 | t.Fatal(err) | ||
| 565 | } | ||
| 566 | if _, err := st.CreateRepo("user", uid, "thing", "public"); err != nil { | ||
| 567 | t.Fatal(err) | ||
| 568 | } | ||
| 569 | st.Close() | ||
| 570 | |||
| 571 | work := t.TempDir() | ||
| 572 | gitIn(t, work, "init", "-q", "-b", "main") | ||
| 573 | if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil { | ||
| 574 | t.Fatal(err) | ||
| 575 | } | ||
| 576 | gitIn(t, work, "add", "a.txt") | ||
| 577 | gitIn(t, work, "commit", "-q", "-m", "one") | ||
| 578 | dir := filepath.Join(cfg.Server.Root, "repos", "krz", "thing.git") | ||
| 579 | gitIn(t, work, "clone", "-q", "--bare", work, dir) | ||
| 580 | gitIn(t, dir, "repack", "-q", "-a", "-d") | ||
| 581 | |||
| 582 | removed := "" | ||
| 583 | beforeAdd = func(path string) { | ||
| 584 | if removed == "" && strings.HasSuffix(path, ".pack") { | ||
| 585 | removed = path | ||
| 586 | os.Remove(path) | ||
| 587 | } | ||
| 588 | } | ||
| 589 | t.Cleanup(func() { beforeAdd = func(string) {} }) | ||
| 590 | archive := filepath.Join(t.TempDir(), "b.tar.gz") | ||
| 591 | if err := runBackup(cfg, archive, false); err != nil { | ||
| 592 | t.Fatalf("backup with a vanished pack: %v", err) | ||
| 593 | } | ||
| 594 | if removed == "" { | ||
| 595 | t.Fatal("no pack was archived") | ||
| 596 | } | ||
| 597 | for _, n := range members(t, archive) { | ||
| 598 | if strings.HasSuffix(n, ".pack") { | ||
| 599 | t.Errorf("archive carries %s", n) | ||
| 600 | } | ||
| 601 | } | ||
| 602 | if err := verifyBackup(archive, ""); err == nil || !strings.Contains(err.Error(), "connectivity") { | ||
| 603 | t.Errorf("verify of an archive missing its pack: %v", err) | ||
| 604 | } | ||
| 605 | |||
| 606 | beforeAdd = func(path string) { | ||
| 607 | if strings.HasSuffix(path, filepath.Join("thing.git", "config")) { | ||
| 608 | os.Remove(path) | ||
| 609 | } | ||
| 610 | } | ||
| 611 | err = runBackup(cfg, filepath.Join(t.TempDir(), "c.tar.gz"), false) | ||
| 612 | if !errors.Is(err, fs.ErrNotExist) { | ||
| 613 | t.Fatalf("backup with a vanished config: %v, want not-exist", err) | ||
| 614 | } | ||
| 615 | } | ||
| 616 | |||
| 617 | // gc refuses while a full backup holds the lock. | ||
| 618 | func TestGCRefusedDuringBackup(t *testing.T) { | ||
| 619 | cfg := testConfig(t) | ||
| 620 | release, err := backuplock.Hold(cfg.Server.Root) | ||
| 621 | if err != nil { | ||
| 622 | t.Fatal(err) | ||
| 623 | } | ||
| 624 | defer release() | ||
| 625 | if err := runGC(cfg, "", false, false); !errors.Is(err, backuplock.ErrBusy) { | ||
| 626 | t.Fatalf("gc during a backup: %v", err) | ||
| 627 | } | ||
| 628 | } | ||
cmd/gitbayd/maint.go +51 −38
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | ||
| 9 | "github.com/spf13/cobra" | 9 | "github.com/spf13/cobra" |
| 10 | 10 | ||
| 11 | "gitbay.org/gitbay/internal/backuplock" | ||
| 11 | "gitbay.org/gitbay/internal/config" | 12 | "gitbay.org/gitbay/internal/config" |
| 12 | "gitbay.org/gitbay/internal/control" | 13 | "gitbay.org/gitbay/internal/control" |
| 13 | "gitbay.org/gitbay/internal/gitutil" | 14 | "gitbay.org/gitbay/internal/gitutil" |
| @@ -27,44 +28,7 @@ func gcCmd() *cobra.Command { | |||
| 27 | if err != nil { | 28 | if err != nil { |
| 28 | return err | 29 | return err |
| 29 | } | 30 | } |
| 30 | st, err := openStore(cfg) | 31 | return runGC(cfg, repoPath, aggressive, withLFS) |
| 31 | if err != nil { | ||
| 32 | return err | ||
| 33 | } | ||
| 34 | defer st.Close() | ||
| 35 | |||
| 36 | var repos []store.Repo | ||
| 37 | if repoPath != "" { | ||
| 38 | r, err := st.RepoByPath(repoPath) | ||
| 39 | if err != nil { | ||
| 40 | return fmt.Errorf("no repository %q", repoPath) | ||
| 41 | } | ||
| 42 | repos = []store.Repo{r} | ||
| 43 | } else if repos, err = st.ListAllRepos(); err != nil { | ||
| 44 | return err | ||
| 45 | } | ||
| 46 | |||
| 47 | var before, after int64 | ||
| 48 | for _, r := range repos { | ||
| 49 | dir := control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name) | ||
| 50 | b := gitutil.DirSize(dir) | ||
| 51 | gcArgs := []string{"-C", dir, "gc", "--quiet"} | ||
| 52 | if aggressive { | ||
| 53 | gcArgs = append(gcArgs, "--aggressive") | ||
| 54 | } | ||
| 55 | if out, err := exec.Command(toolpath.Look("git"), gcArgs...).CombinedOutput(); err != nil { | ||
| 56 | fmt.Fprintf(os.Stderr, "%s: gc failed: %v\n%s", r.Path(), err, out) | ||
| 57 | continue | ||
| 58 | } | ||
| 59 | a := gitutil.DirSize(dir) | ||
| 60 | before, after = before+b, after+a | ||
| 61 | fmt.Printf("%s\t%s -> %s\n", r.Path(), human(b), human(a)) | ||
| 62 | } | ||
| 63 | fmt.Printf("total\t%s -> %s (freed %s)\n", human(before), human(after), human(before-after)) | ||
| 64 | if !withLFS { | ||
| 65 | return nil | ||
| 66 | } | ||
| 67 | return gcLFS(cfg, st) | ||
| 68 | }, | 32 | }, |
| 69 | } | 33 | } |
| 70 | cmd.Flags().StringVar(&repoPath, "repo", "", "one repository (owner/name) instead of all") | 34 | cmd.Flags().StringVar(&repoPath, "repo", "", "one repository (owner/name) instead of all") |
| @@ -73,6 +37,55 @@ func gcCmd() *cobra.Command { | |||
| 73 | return cmd | 37 | return cmd |
| 74 | } | 38 | } |
| 75 | 39 | ||
| 40 | // runGC refuses while a full backup runs: a prune removing a pack the | ||
| 41 | // backup has listed but not yet read would leave it out of the archive | ||
| 42 | // (#259). | ||
| 43 | func runGC(cfg config.Config, repoPath string, aggressive, withLFS bool) error { | ||
| 44 | release, err := backuplock.TryShared(cfg.Server.Root) | ||
| 45 | if err != nil { | ||
| 46 | return err | ||
| 47 | } | ||
| 48 | defer release() | ||
| 49 | st, err := openStore(cfg) | ||
| 50 | if err != nil { | ||
| 51 | return err | ||
| 52 | } | ||
| 53 | defer st.Close() | ||
| 54 | |||
| 55 | var repos []store.Repo | ||
| 56 | if repoPath != "" { | ||
| 57 | r, err := st.RepoByPath(repoPath) | ||
| 58 | if err != nil { | ||
| 59 | return fmt.Errorf("no repository %q", repoPath) | ||
| 60 | } | ||
| 61 | repos = []store.Repo{r} | ||
| 62 | } else if repos, err = st.ListAllRepos(); err != nil { | ||
| 63 | return err | ||
| 64 | } | ||
| 65 | |||
| 66 | var before, after int64 | ||
| 67 | for _, r := range repos { | ||
| 68 | dir := control.RepoDir(cfg.Server.Root, r.OwnerName, r.Name) | ||
| 69 | b := gitutil.DirSize(dir) | ||
| 70 | gcArgs := []string{"-C", dir, "gc", "--quiet"} | ||
| 71 | if aggressive { | ||
| 72 | gcArgs = append(gcArgs, "--aggressive") | ||
| 73 | } | ||
| 74 | if out, err := exec.Command(toolpath.Look("git"), gcArgs...).CombinedOutput(); err != nil { | ||
| 75 | fmt.Fprintf(os.Stderr, "%s: gc failed: %v\n%s", r.Path(), err, out) | ||
| 76 | continue | ||
| 77 | } | ||
| 78 | a := gitutil.DirSize(dir) | ||
| 79 | before, after = before+b, after+a | ||
| 80 | fmt.Printf("%s\t%s -> %s\n", r.Path(), human(b), human(a)) | ||
| 81 | } | ||
| 82 | fmt.Printf("total\t%s -> %s (freed %s)\n", human(before), human(after), human(before-after)) | ||
| 83 | if !withLFS { | ||
| 84 | return nil | ||
| 85 | } | ||
| 86 | return gcLFS(cfg, st) | ||
| 87 | } | ||
| 88 | |||
| 76 | func human(b int64) string { | 89 | func human(b int64) string { |
| 77 | switch { | 90 | switch { |
| 78 | case b >= 1<<30: | 91 | case b >= 1<<30: |
internal/backuplock/backuplock.go +7 −7
| @@ -1,9 +1,9 @@ | |||
| 1 | // Package backuplock keeps repository deletes, renames and transfers | 1 | // Package backuplock keeps repository deletes, renames, transfers and |
| 2 | // out of a full backup's way (#259). The backup runs in its own process | 2 | // prunes out of a full backup's way (#259). The backup runs in its own |
| 3 | // (gitbayd admin backup) and a delete in the daemon's, so the lock is | 3 | // process (gitbayd admin backup) and a delete in the daemon's, so the |
| 4 | // flock(2) on a file under server.root: the backup holds it exclusively | 4 | // lock is flock(2) on a file under server.root: the backup holds it |
| 5 | // from its database snapshot until the last repository is archived, and | 5 | // exclusively from its database snapshot until the last repository is |
| 6 | // each delete or move holds it shared while it runs. | 6 | // archived, and each delete, move or prune holds it shared while it runs. |
| 7 | package backuplock | 7 | package backuplock |
| 8 | 8 | ||
| 9 | import ( | 9 | import ( |
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | const Name = "backup.lock" | 17 | const Name = "backup.lock" |
| 18 | 18 | ||
| 19 | // ErrBusy is TryShared's answer while a backup holds the lock. | 19 | // ErrBusy is TryShared's answer while a backup holds the lock. |
| 20 | var ErrBusy = errors.New("a backup is running; repositories cannot be deleted, renamed or moved until it finishes, usually within minutes") | 20 | var ErrBusy = errors.New("a backup is running; repositories cannot be deleted, renamed, moved or pruned until it finishes, usually within minutes") |
| 21 | 21 | ||
| 22 | // open opens the lock file read-only, which is all flock needs, so the | 22 | // open opens the lock file read-only, which is all flock needs, so the |
| 23 | // daemon's user can lock a file a root-run backup created. O_NOFOLLOW | 23 | // daemon's user can lock a file a root-run backup created. O_NOFOLLOW |
internal/control/admin.go +8
| @@ -655,6 +655,14 @@ func runAdminMRPrune(c *Ctx, args []string) int { | |||
| 655 | mrs = append(mrs, mr) | 655 | mrs = append(mrs, mr) |
| 656 | } | 656 | } |
| 657 | 657 | ||
| 658 | // The prune would remove objects a running full backup has listed | ||
| 659 | // and not yet read. | ||
| 660 | release, code := holdOffBackup(c) | ||
| 661 | if code >= 0 { | ||
| 662 | return code | ||
| 663 | } | ||
| 664 | defer release() | ||
| 665 | |||
| 658 | // The record is written as each ref goes, not after the gc: a failure | 666 | // The record is written as each ref goes, not after the gc: a failure |
| 659 | // past this point leaves refs deleted, and the audit log and the MR | 667 | // past this point leaves refs deleted, and the audit log and the MR |
| 660 | // thread must say so. Re-running the same command finishes the job. | 668 | // thread must say so. Re-running the same command finishes the job. |
internal/control/backuplock_test.go +17
| @@ -38,3 +38,20 @@ func TestRepoDeleteAndRenameRefusedDuringBackup(t *testing.T) { | |||
| 38 | t.Fatalf("delete after the backup: exit %d, %s", code, errOut) | 38 | t.Fatalf("delete after the backup: exit %d, %s", code, errOut) |
| 39 | } | 39 | } |
| 40 | } | 40 | } |
| 41 | |||
| 42 | func TestAdminMRPruneRefusedDuringBackup(t *testing.T) { | ||
| 43 | st, repo, root, headSHA := prunedRepo(t) | ||
| 44 | dir := RepoDir(root, repo.OwnerName, repo.Name) | ||
| 45 | release, err := backuplock.Hold(root) | ||
| 46 | if err != nil { | ||
| 47 | t.Fatal(err) | ||
| 48 | } | ||
| 49 | defer release() | ||
| 50 | c, errOut := pruneCtx(st, root, rootUser(t, st)) | ||
| 51 | if code := Dispatch(c, []string{"admin", "mr", "prune", repo.Path(), "1", "--yes"}); code != protocol.ExitFailure || !strings.Contains(errOut.String(), "a backup is running") { | ||
| 52 | t.Fatalf("prune during a backup: exit %d, %s", code, errOut) | ||
| 53 | } | ||
| 54 | if !refExists(dir, mrHeadRef(1)) || !objectExists(dir, headSHA) { | ||
| 55 | t.Fatal("prune during a backup changed the repository") | ||
| 56 | } | ||
| 57 | } | ||