Commit a27671383a
Verified · cmc
Layout: unified · split
cmd/gitbayd/backup.go +28 −12
| @@ -467,6 +467,19 @@ func addDir(tw *tar.Writer, path, name string) error { | |||
| 467 | // checked for integrity alone and says so. Repositories are extracted to | 467 | // checked for integrity alone and says so. Repositories are extracted to |
| 468 | // a temporary directory for the check, so it needs free space for them. | 468 | // a temporary directory for the check, so it needs free space for them. |
| 469 | func verifyBackup(path, identity string) error { | 469 | func verifyBackup(path, identity string) error { |
| 470 | tmp, err := os.MkdirTemp("", "gitbay-verify-") | ||
| 471 | if err != nil { | ||
| 472 | return err | ||
| 473 | } | ||
| 474 | defer os.RemoveAll(tmp) | ||
| 475 | return checkArchive(path, identity, tmp, false) | ||
| 476 | } | ||
| 477 | |||
| 478 | // checkArchive extracts the archive at path into dest and runs verify's | ||
| 479 | // checks on it. With full unset it extracts only the database and the | ||
| 480 | // repositories; with full set, every member, so dest is a restored | ||
| 481 | // server.root. Alternates and commondir are left out either way. | ||
| 482 | func checkArchive(path, identity, dest string, full bool) error { | ||
| 470 | f, err := os.Open(path) | 483 | f, err := os.Open(path) |
| 471 | if err != nil { | 484 | if err != nil { |
| 472 | return err | 485 | return err |
| @@ -481,11 +494,6 @@ func verifyBackup(path, identity string) error { | |||
| 481 | return fmt.Errorf("%s: not a gzip archive: %w", path, err) | 494 | return fmt.Errorf("%s: not a gzip archive: %w", path, err) |
| 482 | } | 495 | } |
| 483 | tr := tar.NewReader(gz) | 496 | tr := tar.NewReader(gz) |
| 484 | tmp, err := os.MkdirTemp("", "gitbay-verify-") | ||
| 485 | if err != nil { | ||
| 486 | return err | ||
| 487 | } | ||
| 488 | defer os.RemoveAll(tmp) | ||
| 489 | dbPath := "" | 497 | dbPath := "" |
| 490 | inArchive := map[string]bool{} | 498 | inArchive := map[string]bool{} |
| 491 | members := 0 | 499 | members := 0 |
| @@ -502,7 +510,7 @@ func verifyBackup(path, identity string) error { | |||
| 502 | members++ | 510 | members++ |
| 503 | switch { | 511 | switch { |
| 504 | case h.Name == "gitbay.db": | 512 | case h.Name == "gitbay.db": |
| 505 | dbPath = filepath.Join(tmp, "gitbay.db") | 513 | dbPath = filepath.Join(dest, "gitbay.db") |
| 506 | if err := extractTo(tr, dbPath); err != nil { | 514 | if err := extractTo(tr, dbPath); err != nil { |
| 507 | return fmt.Errorf("%s: extracting the database: %w", path, err) | 515 | return fmt.Errorf("%s: extracting the database: %w", path, err) |
| 508 | } | 516 | } |
| @@ -510,18 +518,26 @@ func verifyBackup(path, identity string) error { | |||
| 510 | // Objects are named by their sha256, so each is checked as it | 518 | // Objects are named by their sha256, so each is checked as it |
| 511 | // streams past and none is extracted. | 519 | // streams past and none is extracted. |
| 512 | lfsObjects++ | 520 | lfsObjects++ |
| 521 | if !filepath.IsLocal(h.Name) { | ||
| 522 | return fmt.Errorf("%s: member %q leaves the archive root", path, h.Name) | ||
| 523 | } | ||
| 513 | sum := sha256.New() | 524 | sum := sha256.New() |
| 514 | if _, err := io.Copy(sum, tr); err != nil { | 525 | if full { |
| 526 | err = extractTo(io.TeeReader(tr, sum), filepath.Join(dest, filepath.FromSlash(h.Name))) | ||
| 527 | } else { | ||
| 528 | _, err = io.Copy(sum, tr) | ||
| 529 | } | ||
| 530 | if err != nil { | ||
| 515 | return fmt.Errorf("%s: reading %s: %w", path, h.Name, err) | 531 | return fmt.Errorf("%s: reading %s: %w", path, h.Name, err) |
| 516 | } | 532 | } |
| 517 | if hex.EncodeToString(sum.Sum(nil)) != filepath.Base(h.Name) { | 533 | if hex.EncodeToString(sum.Sum(nil)) != filepath.Base(h.Name) { |
| 518 | badLFS = append(badLFS, h.Name) | 534 | badLFS = append(badLFS, h.Name) |
| 519 | } | 535 | } |
| 520 | case strings.HasPrefix(h.Name, "repos/"): | 536 | case full || strings.HasPrefix(h.Name, "repos/"): |
| 521 | trimmed := strings.TrimSuffix(h.Name, "/") | 537 | trimmed := strings.TrimSuffix(h.Name, "/") |
| 522 | // repos/<owner>/<name>.git/HEAD marks one repository present. | 538 | // repos/<owner>/<name>.git/HEAD marks one repository present. |
| 523 | parts := strings.Split(trimmed, "/") | 539 | parts := strings.Split(trimmed, "/") |
| 524 | if len(parts) == 4 && parts[3] == "HEAD" && strings.HasSuffix(parts[2], ".git") { | 540 | if len(parts) == 4 && parts[0] == "repos" && parts[3] == "HEAD" && strings.HasSuffix(parts[2], ".git") { |
| 525 | inArchive[parts[1]+"/"+strings.TrimSuffix(parts[2], ".git")] = true | 541 | inArchive[parts[1]+"/"+strings.TrimSuffix(parts[2], ".git")] = true |
| 526 | } | 542 | } |
| 527 | if !filepath.IsLocal(trimmed) { | 543 | if !filepath.IsLocal(trimmed) { |
| @@ -530,7 +546,7 @@ func verifyBackup(path, identity string) error { | |||
| 530 | if borrowsObjects(trimmed) { | 546 | if borrowsObjects(trimmed) { |
| 531 | continue | 547 | continue |
| 532 | } | 548 | } |
| 533 | dest := filepath.Join(tmp, filepath.FromSlash(trimmed)) | 549 | dest := filepath.Join(dest, filepath.FromSlash(trimmed)) |
| 534 | switch h.Typeflag { | 550 | switch h.Typeflag { |
| 535 | case tar.TypeDir: | 551 | case tar.TypeDir: |
| 536 | // The archive's directory modes do not matter to fsck, and | 552 | // The archive's directory modes do not matter to fsck, and |
| @@ -592,7 +608,7 @@ func verifyBackup(path, identity string) error { | |||
| 592 | var failed []error | 608 | var failed []error |
| 593 | var broken []string | 609 | var broken []string |
| 594 | for _, r := range repos { | 610 | for _, r := range repos { |
| 595 | dir := filepath.Join(tmp, "repos", r.OwnerName, r.Name+".git") | 611 | dir := filepath.Join(dest, "repos", r.OwnerName, r.Name+".git") |
| 596 | if err := gitutil.FsckConnectivity(dir); err != nil { | 612 | if err := gitutil.FsckConnectivity(dir); err != nil { |
| 597 | fmt.Fprintf(os.Stderr, "%s: %v\n", r.Path(), err) | 613 | fmt.Fprintf(os.Stderr, "%s: %v\n", r.Path(), err) |
| 598 | broken = append(broken, r.Path()) | 614 | broken = append(broken, r.Path()) |
| @@ -603,7 +619,7 @@ func verifyBackup(path, identity string) error { | |||
| 603 | } else { | 619 | } else { |
| 604 | fmt.Printf("connectivity ok on %d repositories\n", len(repos)) | 620 | fmt.Printf("connectivity ok on %d repositories\n", len(repos)) |
| 605 | } | 621 | } |
| 606 | assets, badAssets, err := checkReleaseAssets(st, repos, tmp) | 622 | assets, badAssets, err := checkReleaseAssets(st, repos, dest) |
| 607 | if err != nil { | 623 | if err != nil { |
| 608 | return err | 624 | return err |
| 609 | } | 625 | } |
cmd/gitbayd/main.go +1
| @@ -455,6 +455,7 @@ func adminCmd() *cobra.Command { | |||
| 455 | hostCmd("runners [--json]", "runner accounts: last poll, scope, the build each holds", "admin", "runners"), | 455 | hostCmd("runners [--json]", "runner accounts: last poll, scope, the build each holds", "admin", "runners"), |
| 456 | auditCmd, | 456 | auditCmd, |
| 457 | backupCmd(), | 457 | backupCmd(), |
| 458 | restoreDrillCmd(), | ||
| 458 | secretsCmd(), | 459 | secretsCmd(), |
| 459 | gcCmd(), | 460 | gcCmd(), |
| 460 | adminMigrateCommitRefsCmd(), | 461 | adminMigrateCommitRefsCmd(), |
cmd/gitbayd/restoredrill.go added +107
| @@ -0,0 +1,107 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "fmt" | ||
| 6 | "io/fs" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "strings" | ||
| 10 | "time" | ||
| 11 | |||
| 12 | "github.com/spf13/cobra" | ||
| 13 | |||
| 14 | "gitbay.org/gitbay/internal/store" | ||
| 15 | ) | ||
| 16 | |||
| 17 | // restoreDrillCmd rehearses the archive half of a restore: extract a | ||
| 18 | // full archive into an empty directory, run verify's checks on what was | ||
| 19 | // extracted, and report the elapsed time and the newest activity the | ||
| 20 | // restored database holds. | ||
| 21 | func restoreDrillCmd() *cobra.Command { | ||
| 22 | var into, identity string | ||
| 23 | cmd := &cobra.Command{ | ||
| 24 | Use: "restore-drill <archive> --into <dir>", | ||
| 25 | Short: "restore a full archive into an empty directory, verify it, report elapsed time and the newest recovered activity", | ||
| 26 | Long: `Extracts every member of a full backup archive into --into, which must | ||
| 27 | be empty or absent, and runs the checks of backup --verify on the | ||
| 28 | extracted copy: database integrity, every repository present and | ||
| 29 | passing git fsck --connectivity-only, release assets and LFS object | ||
| 30 | digests. It then prints the newest issue, comment and push in the | ||
| 31 | restored database, which is the recovery point, and the elapsed time. | ||
| 32 | |||
| 33 | The result is a server.root a gitbayd can be pointed at. The archive | ||
| 34 | does not carry server.secret_key_file or config.toml; the Admin wiki's | ||
| 35 | Restore drill section covers those and the offsite path.`, | ||
| 36 | Args: cobra.ExactArgs(1), | ||
| 37 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 38 | if into == "" { | ||
| 39 | return errors.New("--into <dir> is required") | ||
| 40 | } | ||
| 41 | return restoreDrill(args[0], identity, into) | ||
| 42 | }, | ||
| 43 | } | ||
| 44 | cmd.Flags().StringVar(&into, "into", "", "empty or absent directory to restore into") | ||
| 45 | cmd.Flags().StringVar(&identity, "identity", "", "an age identity file that opens an encrypted archive") | ||
| 46 | return cmd | ||
| 47 | } | ||
| 48 | |||
| 49 | func restoreDrill(archive, identity, into string) error { | ||
| 50 | start := time.Now() | ||
| 51 | ents, err := os.ReadDir(into) | ||
| 52 | switch { | ||
| 53 | case errors.Is(err, fs.ErrNotExist): | ||
| 54 | if err := os.MkdirAll(into, 0o700); err != nil { | ||
| 55 | return err | ||
| 56 | } | ||
| 57 | case err != nil: | ||
| 58 | return err | ||
| 59 | case len(ents) > 0: | ||
| 60 | return fmt.Errorf("%s is not empty; restore into an empty or absent directory", into) | ||
| 61 | } | ||
| 62 | checkErr := checkArchive(archive, identity, into, true) | ||
| 63 | if ents, err := os.ReadDir(into); err == nil { | ||
| 64 | var names []string | ||
| 65 | for _, e := range ents { | ||
| 66 | names = append(names, e.Name()) | ||
| 67 | } | ||
| 68 | fmt.Printf("restored into %s: %s\n", into, strings.Join(names, " ")) | ||
| 69 | } | ||
| 70 | // store.Open would create a missing database. | ||
| 71 | db := filepath.Join(into, "gitbay.db") | ||
| 72 | if _, err := os.Stat(db); err == nil { | ||
| 73 | if err := printNewest(db); err != nil { | ||
| 74 | checkErr = errors.Join(checkErr, err) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | fmt.Printf("elapsed %s\n", time.Since(start).Round(100*time.Millisecond)) | ||
| 78 | return checkErr | ||
| 79 | } | ||
| 80 | |||
| 81 | // newest are the recovered timestamps a drill records. | ||
| 82 | var newest = []struct{ label, query string }{ | ||
| 83 | {"issue", "SELECT MAX(created_at) FROM issues"}, | ||
| 84 | {"issue comment", "SELECT MAX(created_at) FROM issue_comments"}, | ||
| 85 | {"merge request comment", "SELECT MAX(created_at) FROM mr_comments"}, | ||
| 86 | {"push", "SELECT MAX(created_at) FROM events WHERE kind = 'push'"}, | ||
| 87 | } | ||
| 88 | |||
| 89 | func printNewest(db string) error { | ||
| 90 | st, err := store.Open(db) | ||
| 91 | if err != nil { | ||
| 92 | return err | ||
| 93 | } | ||
| 94 | defer st.Close() | ||
| 95 | for _, n := range newest { | ||
| 96 | var at *string | ||
| 97 | if err := st.DB.QueryRow(n.query).Scan(&at); err != nil { | ||
| 98 | return fmt.Errorf("newest %s: %w", n.label, err) | ||
| 99 | } | ||
| 100 | v := "none" | ||
| 101 | if at != nil { | ||
| 102 | v = *at | ||
| 103 | } | ||
| 104 | fmt.Printf("newest %s: %s\n", n.label, v) | ||
| 105 | } | ||
| 106 | return nil | ||
| 107 | } | ||
cmd/gitbayd/restoredrill_test.go added +63
| @@ -0,0 +1,63 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // A drill restores a full archive into an empty directory as a usable | ||
| 11 | // root, verifies it, and refuses a directory that already holds files. | ||
| 12 | func TestRestoreDrill(t *testing.T) { | ||
| 13 | cfg := testConfig(t) | ||
| 14 | root := cfg.Server.Root | ||
| 15 | st, err := openStore(cfg) | ||
| 16 | if err != nil { | ||
| 17 | t.Fatal(err) | ||
| 18 | } | ||
| 19 | uid, err := st.CreateUser("krz", false) | ||
| 20 | if err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | rid, err := st.CreateRepo("user", uid, "thing", "public") | ||
| 24 | if err != nil { | ||
| 25 | t.Fatal(err) | ||
| 26 | } | ||
| 27 | if _, err := st.CreateIssue(rid, uid, "bug", "", "md"); err != nil { | ||
| 28 | t.Fatal(err) | ||
| 29 | } | ||
| 30 | if err := st.RecordEvent(rid, uid, "push", "{}"); err != nil { | ||
| 31 | t.Fatal(err) | ||
| 32 | } | ||
| 33 | st.Close() | ||
| 34 | work := t.TempDir() | ||
| 35 | gitIn(t, work, "init", "-q", "-b", "main") | ||
| 36 | writeFile(t, filepath.Join(work, "a.txt"), []byte("a\n")) | ||
| 37 | gitIn(t, work, "add", "a.txt") | ||
| 38 | gitIn(t, work, "commit", "-q", "-m", "one") | ||
| 39 | gitIn(t, work, "clone", "-q", "--bare", work, filepath.Join(root, "repos", "krz", "thing.git")) | ||
| 40 | writeFile(t, filepath.Join(root, "ssh", "host_ed25519"), []byte("key\n")) | ||
| 41 | |||
| 42 | archive := filepath.Join(t.TempDir(), "b.tar.gz") | ||
| 43 | if err := runBackup(cfg, archive, false); err != nil { | ||
| 44 | t.Fatal(err) | ||
| 45 | } | ||
| 46 | into := filepath.Join(t.TempDir(), "restored") | ||
| 47 | if err := restoreDrill(archive, "", into); err != nil { | ||
| 48 | t.Fatal(err) | ||
| 49 | } | ||
| 50 | for _, p := range []string{"gitbay.db", "ssh/host_ed25519", "repos/krz/thing.git/HEAD"} { | ||
| 51 | if _, err := os.Stat(filepath.Join(into, p)); err != nil { | ||
| 52 | t.Errorf("restored root lacks %s: %v", p, err) | ||
| 53 | } | ||
| 54 | } | ||
| 55 | if got := gitIn(t, filepath.Join(into, "repos", "krz", "thing.git"), "log", "--format=%s", "main"); got != "one" { | ||
| 56 | t.Errorf("restored log %q", got) | ||
| 57 | } | ||
| 58 | |||
| 59 | err = restoreDrill(archive, "", into) | ||
| 60 | if err == nil || !strings.Contains(err.Error(), "not empty") { | ||
| 61 | t.Fatalf("drill into a non-empty directory: %v", err) | ||
| 62 | } | ||
| 63 | } | ||