backup: verify release assets and LFS; admin restore-drill !518

merged merged by cmc on 2026-09-29 03:37 UTC · krz/gitbay:backup-verify-259 into main

10 files changed, +459 −38

Layout: unified · split

.gitbay/wiki/Admin.org +78 −19
@@ -491,11 +491,18 @@ each one it removes.
491 491
492=--verify= reads an archive back: the snapshot must pass SQLite's 492=--verify= reads an archive back: the snapshot must pass SQLite's
493integrity check, every repository the snapshot names must be in the 493integrity check, every repository the snapshot names must be in the
494archive, and each must pass =git fsck --connectivity-only=. It 494archive, and each must pass =git fsck --connectivity-only=. Every
495extracts the repositories to a temporary directory for that, so it 495release asset the snapshot names must be in its repository with its
496needs free space the size of the repositories. A database-only archive 496recorded size and sha256, and every LFS object in the archive must
497is checked for integrity and says so. Exit is non-zero on damage, a 497hash to its name. LFS objects are named by pointer files in git
498missing repository or a missing object. 498history rather than by the database, so a pointer whose object was
499never uploaded is not caught, and with =[lfs] root= outside
500=server.root= the archive holds no LFS objects at all. It extracts the
501repositories to a temporary directory for the checks, so it needs free
502space the size of the repositories. A database-only archive is checked
503for integrity and says so. Exit is non-zero on damage, a missing
504repository, a missing object, or a missing or altered asset or LFS
505object.
499 506
500A full backup holds =<root>/backup.lock= from its database snapshot to 507A full backup holds =<root>/backup.lock= from its database snapshot to
501its last repository. While it runs, =repo delete=, =repo rename=, 508its last repository. While it runs, =repo delete=, =repo rename=,
@@ -653,28 +660,80 @@ gitbayd admin secrets rotate # new key, reseal, retire the old one (as root)
653 660
654** Restore drill 661** Restore drill
655 662
656A restore onto a clean host, run quarterly and after any change to the 663A restore onto a clean host, run quarterly (January, April, July,
657backup code (=cmd/gitbayd/backup.go=, the offsite job), and recorded 664October) and after any change to the backup code
658below. The disaster it rehearses is losing bay1, so the local archives 665(=cmd/gitbayd/backup.go=, =cmd/gitbayd/restoredrill.go=, the offsite
659are gone with it and the sources are the main offsite restic 666job), and recorded below. The disaster it rehearses is losing bay1, so
660repository (repositories, LFS, the staged database, =config.toml=), 667the local archives are gone with it and the sources are the main
661the off-host copy of =secret.key= and =apns.p8= (a keys repository once 668offsite restic repository (repositories, LFS, the staged database,
662runbook D creates it; until then the operator's hand-made copy), and 669=config.toml=), the off-host copy of =secret.key= and =apns.p8= (a keys
663the operator's password manager (=offsite.env=, the keys repository's 670repository once runbook D creates it; until then the operator's
664password and token once it exists, =backup-identity.txt=). The steps are in the data-at-rest 671hand-made copy), and the operator's password manager (=offsite.env=,
665plan's operator runbook 672the keys repository's password and token once it exists,
666(=docs/plans/2026-09-27-data-at-rest-and-backup.md=). 673=backup-identity.txt=). The full steps are runbook C of the
674data-at-rest plan (=docs/plans/2026-09-27-data-at-rest-and-backup.md=).
675
676=gitbayd admin restore-drill= does the archive half. It extracts a
677full archive into an empty or absent directory, runs every =--verify=
678check on the extracted copy, and prints what was restored, the newest
679issue, issue comment, merge request comment and push in the restored
680database, and the elapsed time. Exit is non-zero if any check fails.
681
682#+begin_src sh
683gitbayd admin restore-drill /var/backups/gitbay/gitbay-20260927-090000.tar.gz --into /srv/drill
684gitbayd admin restore-drill <archive>.tar.gz.age --identity backup-identity.txt --into /srv/drill
685#+end_src
686
687What to restore, and from where:
688
689| Item | Source | Path on the drill host |
690|-------------------------------+-------------------------------------------------------------+------------------------------------------------|
691| Database | staged copy in =/var/lib/gitbay-stage= (restic), or an archive | =<root>/gitbay.db= |
692| Repositories | =/var/lib/gitbay/repos= (restic), or an archive | =<root>/repos= |
693| LFS objects | =/var/lib/gitbay/lfs= (restic), or an archive | =<root>/lfs= (or =[lfs] root=) |
694| Release assets | inside each repository (=gitbay-releases/=) | with the repositories |
695| Host keys | =/var/lib/gitbay/ssh= (restic), or an archive | =<root>/ssh= (or =[ssh] host_keys=) |
696| =config.toml= | =/var/lib/gitbay-stage/config.toml= (restic) | =/etc/gitbay/config.toml= |
697| =secret.key=, =apns.p8= | the off-host copy; no archive or main snapshot carries them | =/etc/gitbay/=, mode 0600, owned by =gitbay= |
698
699From the offsite path (restic is not run by any gitbay command):
700
701#+begin_src sh
702restic restore latest --target / --include /var/lib/gitbay --include /var/lib/gitbay-stage
703cp /var/lib/gitbay-stage/gitbay.db /var/lib/gitbay/gitbay.db
704gitbayd --config /etc/gitbay/config.toml admin backup --out /tmp/drill.tar.gz
705gitbayd admin restore-drill /tmp/drill.tar.gz --into /tmp/drill-root
706#+end_src
707
708The second archive is how the restic tree gets the same checks and
709timestamps; =/tmp/drill-root= is discarded afterwards.
710
711What to check, each a column below:
712
713- DB integrity, connectivity, release assets, LFS: =restore-drill=
714 prints =integrity ok=, =connectivity ok on N repositories=, =release
715 assets ok: N=, =LFS objects ok: N=. Compare N with =gitbayd admin
716 stats --json= on the source at the snapshot time.
717- Secrets: =gitbayd --config /etc/gitbay/config.toml admin secrets
718 check= opens every value.
719- Host key: =ssh-keyscan -p 22 <drill-host>= matches the source's
720 fingerprint.
721- Config: =gitbayd --config /etc/gitbay/config.toml check-config=.
722- Service: =ssh -p 22 git@<drill-host> whoami= and a =git clone= over
723 SSH succeed.
667 724
668Time to service runs from the clean host's first root login to the 725Time to service runs from the clean host's first root login to the
669first successful =git clone= over SSH from it. The recovery point is 726first successful =git clone= over SSH from it. The recovery point is
670the time of the newest restic snapshot restored. 727the time of the newest restic snapshot restored; record beside it the
728newest issue, comment and push =restore-drill= printed, which show how
729much activity the restore carries.
671 730
672No drill has been run yet; the procedure above is written but 731No drill has been run yet; the procedure above is written but
673unexercised, and #259 stays open until the first row below is 732unexercised, and #259 stays open until the first row below is
674recorded. 733recorded.
675 734
676| Date | Host | Snapshot restored (UTC) | Time to service | DB integrity | Connectivity | LFS | Release assets | Host key | Secrets | Notes | 735| Date | Host | Snapshot restored (UTC) | Newest issue / comment / push | Time to service | DB integrity | Connectivity | LFS | Release assets | Host key | Secrets | Notes |
677|------+------+-------------------------+-----------------+--------------+--------------+-----+----------------+----------+---------+-------| 736|------+------+-------------------------+-------------------------------+-----------------+--------------+--------------+-----+----------------+----------+---------+-------|
678 737
679* Upgrades 738* Upgrades
680 739
.gitbay/wiki/Architecture/08-Operations.org +6 −2
@@ -62,8 +62,12 @@ the product activity feed, not an audit trail.
62- Excluded: WAL files, the hook socket, askpass scripts, generated 62- Excluded: WAL files, the hook socket, askpass scripts, generated
63 hooks. 63 hooks.
64- =gitbayd admin backup --verify= checks SQLite integrity, that every 64- =gitbayd admin backup --verify= checks SQLite integrity, that every
65 repository the database names is present, and =git fsck 65 repository the database names is present, =git fsck
66 --connectivity-only= on each (=backup.go=). 66 --connectivity-only= on each, release assets against their recorded
67 sha256, and LFS objects against their names (=backup.go=).
68 =gitbayd admin restore-drill= runs the same checks on a full
69 extraction and reports elapsed time and the newest recovered
70 activity (=restoredrill.go=).
67- Repository deletes, renames and transfers refuse while a full backup 71- Repository deletes, renames and transfers refuse while a full backup
68 runs (=internal/backuplock=), so the snapshot and the walk agree. 72 runs (=internal/backuplock=), so the snapshot and the walk agree.
69- The host's restic credentials are append-only; the key that can 73- The host's restic credentials are append-only; the key that can
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -99,6 +99,6 @@ chapter names of OWASP ASVS 4.0 where one fits.
99| Concurrency limit on git pack generation | in place | global, per-principal, bounded queue across SSH, HTTP and git:// (=internal/packlimit=); not in system SSH mode | 99| Concurrency limit on git pack generation | in place | global, per-principal, bounded queue across SSH, HTTP and git:// (=internal/packlimit=); not in system SSH mode |
100| Service hardening | in place | systemd sandboxing ([[file:03-Deployment.org][3]]) | 100| Service hardening | in place | systemd sandboxing ([[file:03-Deployment.org][3]]) |
101| Backups offsite and append-only | in place | restic with append-only credentials (documented) | 101| Backups offsite and append-only | in place | restic with append-only credentials (documented) |
102| Restore tested | gap | #259 | 102| Restore tested | gap | tooling in place (=admin restore-drill=, Admin wiki "Restore drill"); clean-host drill pending (#259) |
103| Migrations validated before commit | gap | foreign-key check runs after commit (#261) | 103| Migrations validated before commit | gap | foreign-key check runs after commit (#261) |
104| Signed, reviewed changes to production | in place | signed commits, =require-mr=, ff-only merges, clean-tree deploys | 104| Signed, reviewed changes to production | in place | signed commits, =require-mr=, ff-only merges, clean-tree deploys |
.gitbay/wiki/Architecture/10-Known-Gaps.org +1 −1
@@ -10,7 +10,7 @@ what the 2026-09-27 review found; remove a row when its issue closes.
10 10
11| Issue | Area | Gap | Severity | 11| Issue | Area | Gap | Severity |
12|-------+------------------+-----------------------------------------------------------------------+----------| 12|-------+------------------+-----------------------------------------------------------------------+----------|
13| #259 | Recovery | No restore has been exercised; the drill is written (Admin wiki) and not yet run | high | 13| #259 | Recovery | No restore has been exercised; the procedure and tooling (=admin restore-drill=, =backup --verify=) are in place, the clean-host drill is pending | high |
14| #260 | CI network | Builds share the runner's source address; no egress policy | medium | 14| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | 15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
16 16
CHANGELOG.org +12
@@ -4,6 +4,18 @@ Versioning follows semver from v0.1.0. Database migrations run
4automatically on daemon start; upgrade notes appear per release when 4automatically on daemon start; upgrade notes appear per release when
5anything beyond "replace the binary and restart" is needed. 5anything beyond "replace the binary and restart" is needed.
6 6
7* Unreleased
8
9- =gitbayd admin backup --verify= also checks every release asset the
10 database names against its recorded size and sha256, and every
11 archived LFS object against its name (#259).
12- =gitbayd admin restore-drill <archive> --into <dir>= extracts a full
13 archive into an empty directory, runs the =--verify= checks on the
14 extracted copy, and prints the newest issue, comment and push it
15 recovered and the elapsed time. The Admin wiki's Restore drill
16 section lists what to restore, what to check and where to record it
17 (#259).
18
7* v1.37.0 — 2026-09-29 19* v1.37.0 — 2026-09-29
8 20
9Findings from the 2026-09-27 architecture review. 21Findings from the 2026-09-27 architecture review.
cmd/gitbayd/backup.go +115 −15
@@ -4,6 +4,8 @@ import (
4 "archive/tar" 4 "archive/tar"
5 "bufio" 5 "bufio"
6 "compress/gzip" 6 "compress/gzip"
7 "crypto/sha256"
8 "encoding/hex"
7 "errors" 9 "errors"
8 "fmt" 10 "fmt"
9 "io" 11 "io"
@@ -12,6 +14,7 @@ import (
12 "path" 14 "path"
13 "path/filepath" 15 "path/filepath"
14 "regexp" 16 "regexp"
17 "strconv"
15 "strings" 18 "strings"
16 "time" 19 "time"
17 20
@@ -21,6 +24,7 @@ import (
21 "gitbay.org/gitbay/internal/backuplock" 24 "gitbay.org/gitbay/internal/backuplock"
22 "gitbay.org/gitbay/internal/config" 25 "gitbay.org/gitbay/internal/config"
23 "gitbay.org/gitbay/internal/gitutil" 26 "gitbay.org/gitbay/internal/gitutil"
27 "gitbay.org/gitbay/internal/lfs"
24 "gitbay.org/gitbay/internal/store" 28 "gitbay.org/gitbay/internal/store"
25) 29)
26 30
@@ -71,7 +75,7 @@ public keys and its name ends in .age. --verify then needs --identity
71 } 75 }
72 cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz; .age is appended when [backup] age_recipients is set)") 76 cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz; .age is appended when [backup] age_recipients is set)")
73 cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories") 77 cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories")
74 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") 78 cmd.Flags().StringVar(&verify, "verify", "", "check an archive instead of writing one: database integrity, its repositories against the archive's, git connectivity of each, release assets and LFS object digests")
75 cmd.Flags().StringVar(&identity, "identity", "", "with --verify: an age identity file that opens an encrypted archive") 79 cmd.Flags().StringVar(&identity, "identity", "", "with --verify: an age identity file that opens an encrypted archive")
76 return cmd 80 return cmd
77} 81}
@@ -459,10 +463,24 @@ func addDir(tw *tar.Writer, path, name string) error {
459// verifyBackup reads an archive back, decrypting it with identity when it 463// verifyBackup reads an archive back, decrypting it with identity when it
460// is encrypted: the database snapshot must pass SQLite's integrity check, 464// is encrypted: the database snapshot must pass SQLite's integrity check,
461// every repository it names must be in the archive, and each of those 465// every repository it names must be in the archive, and each of those
462// must pass git fsck --connectivity-only. A database-only archive is 466// must pass git fsck --connectivity-only; release assets must match the
467// database and LFS objects their names. A database-only archive is
463// checked for integrity alone and says so. Repositories are extracted to 468// checked for integrity alone and says so. Repositories are extracted to
464// a temporary directory for the check, so it needs free space for them. 469// a temporary directory for the check, so it needs free space for them.
465func verifyBackup(path, identity string) error { 470func verifyBackup(path, identity string) error {
471 tmp, err := os.MkdirTemp("", "gitbay-verify-")
472 if err != nil {
473 return err
474 }
475 defer os.RemoveAll(tmp)
476 return checkArchive(path, identity, tmp, false)
477}
478
479// checkArchive extracts the archive at path into dest and runs verify's
480// checks on it. With full unset it extracts only the database and the
481// repositories; with full set, every member, so dest is a restored
482// server.root. Alternates and commondir are left out either way.
483func checkArchive(path, identity, dest string, full bool) error {
466 f, err := os.Open(path) 484 f, err := os.Open(path)
467 if err != nil { 485 if err != nil {
468 return err 486 return err
@@ -477,14 +495,11 @@ func verifyBackup(path, identity string) error {
477 return fmt.Errorf("%s: not a gzip archive: %w", path, err) 495 return fmt.Errorf("%s: not a gzip archive: %w", path, err)
478 } 496 }
479 tr := tar.NewReader(gz) 497 tr := tar.NewReader(gz)
480 tmp, err := os.MkdirTemp("", "gitbay-verify-")
481 if err != nil {
482 return err
483 }
484 defer os.RemoveAll(tmp)
485 dbPath := "" 498 dbPath := ""
486 inArchive := map[string]bool{} 499 inArchive := map[string]bool{}
487 members := 0 500 members := 0
501 lfsObjects := 0
502 var badLFS []string
488 for { 503 for {
489 h, err := tr.Next() 504 h, err := tr.Next()
490 if err == io.EOF { 505 if err == io.EOF {
@@ -496,15 +511,35 @@ func verifyBackup(path, identity string) error {
496 members++ 511 members++
497 switch { 512 switch {
498 case h.Name == "gitbay.db": 513 case h.Name == "gitbay.db":
499 dbPath = filepath.Join(tmp, "gitbay.db") 514 dbPath = filepath.Join(dest, "gitbay.db")
500 if err := extractTo(tr, dbPath); err != nil { 515 if err := extractTo(tr, dbPath); err != nil {
501 return fmt.Errorf("%s: extracting the database: %w", path, err) 516 return fmt.Errorf("%s: extracting the database: %w", path, err)
502 } 517 }
503 case strings.HasPrefix(h.Name, "repos/"): 518 case strings.HasPrefix(h.Name, "lfs/") && h.Typeflag == tar.TypeReg && lfs.OIDPat.MatchString(filepath.Base(h.Name)):
519 // Objects are named by their sha256, so each is checked as it
520 // streams past and none is extracted. Other names, such as an
521 // upload's .upload-* staging file, are not objects.
522 lfsObjects++
523 if !filepath.IsLocal(h.Name) {
524 return fmt.Errorf("%s: member %q leaves the archive root", path, h.Name)
525 }
526 sum := sha256.New()
527 if full {
528 err = extractTo(io.TeeReader(tr, sum), filepath.Join(dest, filepath.FromSlash(h.Name)))
529 } else {
530 _, err = io.Copy(sum, tr)
531 }
532 if err != nil {
533 return fmt.Errorf("%s: reading %s: %w", path, h.Name, err)
534 }
535 if hex.EncodeToString(sum.Sum(nil)) != filepath.Base(h.Name) {
536 badLFS = append(badLFS, h.Name)
537 }
538 case full || strings.HasPrefix(h.Name, "repos/"):
504 trimmed := strings.TrimSuffix(h.Name, "/") 539 trimmed := strings.TrimSuffix(h.Name, "/")
505 // repos/<owner>/<name>.git/HEAD marks one repository present. 540 // repos/<owner>/<name>.git/HEAD marks one repository present.
506 parts := strings.Split(trimmed, "/") 541 parts := strings.Split(trimmed, "/")
507 if len(parts) == 4 && parts[3] == "HEAD" && strings.HasSuffix(parts[2], ".git") { 542 if len(parts) == 4 && parts[0] == "repos" && parts[3] == "HEAD" && strings.HasSuffix(parts[2], ".git") {
508 inArchive[parts[1]+"/"+strings.TrimSuffix(parts[2], ".git")] = true 543 inArchive[parts[1]+"/"+strings.TrimSuffix(parts[2], ".git")] = true
509 } 544 }
510 if !filepath.IsLocal(trimmed) { 545 if !filepath.IsLocal(trimmed) {
@@ -513,7 +548,7 @@ func verifyBackup(path, identity string) error {
513 if borrowsObjects(trimmed) { 548 if borrowsObjects(trimmed) {
514 continue 549 continue
515 } 550 }
516 dest := filepath.Join(tmp, filepath.FromSlash(trimmed)) 551 dest := filepath.Join(dest, filepath.FromSlash(trimmed))
517 switch h.Typeflag { 552 switch h.Typeflag {
518 case tar.TypeDir: 553 case tar.TypeDir:
519 // The archive's directory modes do not matter to fsck, and 554 // The archive's directory modes do not matter to fsck, and
@@ -572,19 +607,84 @@ func verifyBackup(path, identity string) error {
572 if extra > 0 { 607 if extra > 0 {
573 fmt.Printf("%d repositories in the archive that the database does not name (created after the snapshot)\n", extra) 608 fmt.Printf("%d repositories in the archive that the database does not name (created after the snapshot)\n", extra)
574 } 609 }
610 var failed []error
575 var broken []string 611 var broken []string
576 for _, r := range repos { 612 for _, r := range repos {
577 dir := filepath.Join(tmp, "repos", r.OwnerName, r.Name+".git") 613 dir := filepath.Join(dest, "repos", r.OwnerName, r.Name+".git")
578 if err := gitutil.FsckConnectivity(dir); err != nil { 614 if err := gitutil.FsckConnectivity(dir); err != nil {
579 fmt.Fprintf(os.Stderr, "%s: %v\n", r.Path(), err) 615 fmt.Fprintf(os.Stderr, "%s: %v\n", r.Path(), err)
580 broken = append(broken, r.Path()) 616 broken = append(broken, r.Path())
581 } 617 }
582 } 618 }
583 if len(broken) > 0 { 619 if len(broken) > 0 {
584 return fmt.Errorf("%s: %d repositories fail the connectivity check: %s", path, len(broken), strings.Join(broken, ", ")) 620 failed = append(failed, fmt.Errorf("%s: %d repositories fail the connectivity check: %s", path, len(broken), strings.Join(broken, ", ")))
621 } else {
622 fmt.Printf("connectivity ok on %d repositories\n", len(repos))
585 } 623 }
586 fmt.Printf("connectivity ok on %d repositories\n", len(repos)) 624 assets, badAssets, err := checkReleaseAssets(st, repos, dest)
587 return nil 625 if err != nil {
626 return err
627 }
628 if len(badAssets) > 0 {
629 failed = append(failed, fmt.Errorf("%s: %d release assets missing or not matching their digest: %s", path, len(badAssets), strings.Join(badAssets, ", ")))
630 } else {
631 fmt.Printf("release assets ok: %d\n", assets)
632 }
633 // LFS objects are named by pointer files in git history, not by the
634 // database, so this checks the archived objects' digests and not that
635 // every pointer has its object. With [lfs] root outside server.root
636 // the archive carries none.
637 if len(badLFS) > 0 {
638 failed = append(failed, fmt.Errorf("%s: %d LFS objects do not match their digest: %s", path, len(badLFS), strings.Join(badLFS, ", ")))
639 } else {
640 fmt.Printf("LFS objects ok: %d\n", lfsObjects)
641 }
642 return errors.Join(failed...)
643}
644
645// checkReleaseAssets checks that every release asset the database names
646// is under root, extracted, with its recorded size and sha256. It
647// returns how many the database names and those that fail.
648func checkReleaseAssets(st *store.Store, repos []store.Repo, root string) (int, []string, error) {
649 byID := map[int64]store.Repo{}
650 for _, r := range repos {
651 byID[r.ID] = r
652 }
653 rows, err := st.DB.Query(`SELECT rl.repo_id, a.release_id, a.name, a.size, a.sha256
654 FROM release_assets a JOIN releases rl ON rl.id = a.release_id
655 ORDER BY rl.repo_id, a.release_id, a.name`)
656 if err != nil {
657 return 0, nil, err
658 }
659 defer rows.Close()
660 n := 0
661 var bad []string
662 for rows.Next() {
663 var repoID, relID, size int64
664 var name, want string
665 if err := rows.Scan(&repoID, &relID, &name, &size, &want); err != nil {
666 return 0, nil, err
667 }
668 n++
669 r, ok := byID[repoID]
670 owner := r.Path()
671 if !ok {
672 owner = fmt.Sprintf("repository %d", repoID)
673 }
674 label := fmt.Sprintf("%s release %d %s", owner, relID, name)
675 f, err := os.Open(filepath.Join(root, "repos", r.OwnerName, r.Name+".git", "gitbay-releases", strconv.FormatInt(relID, 10), name))
676 if err != nil {
677 bad = append(bad, label)
678 continue
679 }
680 sum := sha256.New()
681 got, err := io.Copy(sum, f)
682 f.Close()
683 if err != nil || got != size || hex.EncodeToString(sum.Sum(nil)) != want {
684 bad = append(bad, label)
685 }
686 }
687 return n, bad, rows.Err()
588} 688}
589 689
590// borrowsObjects reports an archive member that would point git at 690// borrowsObjects reports an archive member that would point git at
cmd/gitbayd/backup_test.go +75
@@ -3,6 +3,8 @@ package main
3import ( 3import (
4 "archive/tar" 4 "archive/tar"
5 "compress/gzip" 5 "compress/gzip"
6 "crypto/sha256"
7 "encoding/hex"
6 "errors" 8 "errors"
7 "io" 9 "io"
8 "io/fs" 10 "io/fs"
@@ -10,6 +12,7 @@ import (
10 "os/exec" 12 "os/exec"
11 "path/filepath" 13 "path/filepath"
12 "sort" 14 "sort"
15 "strconv"
13 "strings" 16 "strings"
14 "testing" 17 "testing"
15 "time" 18 "time"
@@ -840,3 +843,75 @@ func TestVerifyIgnoresCommondir(t *testing.T) {
840 t.Fatalf("verify of a repository whose objects are only in its commondir: %v", err) 843 t.Fatalf("verify of a repository whose objects are only in its commondir: %v", err)
841 } 844 }
842} 845}
846
847// verify checks each release asset the database names against its
848// recorded digest, and each archived LFS object against its name.
849func TestVerifyChecksReleaseAssetsAndLFS(t *testing.T) {
850 cfg := testConfig(t)
851 root := cfg.Server.Root
852 st, err := openStore(cfg)
853 if err != nil {
854 t.Fatal(err)
855 }
856 uid, err := st.CreateUser("krz", false)
857 if err != nil {
858 t.Fatal(err)
859 }
860 rid, err := st.CreateRepo("user", uid, "thing", "public")
861 if err != nil {
862 t.Fatal(err)
863 }
864 relID, err := st.CreateRelease(rid, "v1", "v1", "", uid, "md")
865 if err != nil {
866 t.Fatal(err)
867 }
868 asset := []byte("binary\n")
869 sum := sha256.Sum256(asset)
870 if err := st.AddReleaseAsset(relID, "tool", int64(len(asset)), hex.EncodeToString(sum[:])); err != nil {
871 t.Fatal(err)
872 }
873 st.Close()
874 dir := filepath.Join(root, "repos", "krz", "thing.git")
875 gitIn(t, root, "init", "-q", "--bare", dir)
876 assetFile := filepath.Join(dir, "gitbay-releases", strconv.FormatInt(relID, 10), "tool")
877 writeFile(t, assetFile, asset)
878 obj := []byte("large\n")
879 oid := sha256.Sum256(obj)
880 o := hex.EncodeToString(oid[:])
881 writeFile(t, filepath.Join(root, "lfs", o[:2], o[2:4], o), obj)
882 // An upload in progress stages a temporary file beside the objects.
883 writeFile(t, filepath.Join(root, "lfs", o[:2], o[2:4], ".upload-123"), []byte("partial"))
884
885 good := filepath.Join(t.TempDir(), "good.tar.gz")
886 if err := runBackup(cfg, good, false); err != nil {
887 t.Fatal(err)
888 }
889 if err := verifyBackup(good, ""); err != nil {
890 t.Fatalf("intact archive: %v", err)
891 }
892
893 writeFile(t, assetFile, []byte("tampered\n"))
894 wrong := strings.Repeat("0", 64)
895 writeFile(t, filepath.Join(root, "lfs", "00", "00", wrong), obj)
896 bad := filepath.Join(t.TempDir(), "bad.tar.gz")
897 if err := runBackup(cfg, bad, false); err != nil {
898 t.Fatal(err)
899 }
900 err = verifyBackup(bad, "")
901 if err == nil || !strings.Contains(err.Error(), "krz/thing release") || !strings.Contains(err.Error(), wrong) {
902 t.Fatalf("archive with a bad asset and LFS object: %v", err)
903 }
904 if strings.Contains(err.Error(), o) || strings.Contains(err.Error(), ".upload-") {
905 t.Fatalf("intact LFS object or upload staging file reported: %v", err)
906 }
907}
908
909func writeFile(t *testing.T, p string, b []byte) {
910 t.Helper()
911 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
912 t.Fatal(err)
913 }
914 if err := os.WriteFile(p, b, 0o644); err != nil {
915 t.Fatal(err)
916 }
917}
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 @@
1package main
2
3import (
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.
21func 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
27be empty or absent, and runs the checks of backup --verify on the
28extracted copy: database integrity, every repository present and
29passing git fsck --connectivity-only, release assets and LFS object
30digests. It then prints the newest issue, comment and push in the
31restored database, which is the recovery point, and the elapsed time.
32
33The result is a server.root a gitbayd can be pointed at. The archive
34does not carry server.secret_key_file or config.toml; the Admin wiki's
35Restore 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
49func 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.
82var 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
89func 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 @@
1package main
2
3import (
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.
12func 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}