cmd/gitbayd/backup.go

88fc476788ab9c640649427d815217360569b4d9
gitbay/cmd/gitbayd/backup.go history · blame · raw

273 lines · 7969 bytes

  1package main
  2
  3import (
  4	"archive/tar"
  5	"compress/gzip"
  6	"fmt"
  7	"io"
  8	"io/fs"
  9	"os"
 10	"path/filepath"
 11	"strings"
 12	"time"
 13
 14	"github.com/spf13/cobra"
 15
 16	"gitbay.org/gitbay/internal/config"
 17	"gitbay.org/gitbay/internal/store"
 18)
 19
 20// backupCmd produces one tar.gz holding a consistent database snapshot plus
 21// every repository and the SSH host keys. Restore by extracting the archive
 22// into a fresh server.root.
 23//
 24// Ordering: the database is snapshotted BEFORE the repositories are read.
 25// A push that lands mid-backup then shows up only as unreferenced git
 26// objects in the archive (harmless); the reverse order could leave database
 27// rows pointing at objects the archive never captured.
 28func backupCmd() *cobra.Command {
 29	var out, verify string
 30	var dbOnly bool
 31	cmd := &cobra.Command{
 32		Use:   "backup",
 33		Short: "write a consistent backup archive (database snapshot first, then repositories)",
 34		Long: `Writes a tar.gz of the server root: a consistent SQLite snapshot,
 35all repositories, and the SSH host keys. Transient state (hook socket,
 36regenerated hook scripts, askpass helper, WAL files) is excluded.
 37
 38--db-only writes the database snapshot alone. It is seconds and megabytes
 39rather than minutes and gigabytes, which is what makes a frequent schedule
 40affordable, and the database is the copy of issues, merge requests and
 41comments that exists nowhere else. Repositories are not in such an archive,
 42so it supplements a full backup and does not replace one.
 43
 44Restore: extract into an empty directory, point server.root at it, start
 45gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`,
 46		RunE: func(cmd *cobra.Command, args []string) error {
 47			if verify != "" {
 48				return verifyBackup(verify)
 49			}
 50			cfg, err := config.Load(configPath)
 51			if err != nil {
 52				return err
 53			}
 54			if out == "" {
 55				out = fmt.Sprintf("gitbay-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
 56			}
 57			return runBackup(cfg, out, dbOnly)
 58		},
 59	}
 60	cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz)")
 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")
 63	return cmd
 64}
 65
 66func runBackup(cfg config.Config, out string, dbOnly bool) error {
 67	st, err := openStore(cfg)
 68	if err != nil {
 69		return err
 70	}
 71	defer st.Close()
 72
 73	// 1. Consistent database snapshot, before any repository is read.
 74	snap := filepath.Join(os.TempDir(), fmt.Sprintf("gitbay-snap-%d.db", os.Getpid()))
 75	os.Remove(snap)
 76	defer os.Remove(snap)
 77	if err := snapshotDB(st, snap); err != nil {
 78		return fmt.Errorf("database snapshot: %w", err)
 79	}
 80
 81	f, err := os.Create(out)
 82	if err != nil {
 83		return err
 84	}
 85	defer f.Close()
 86	gz := gzip.NewWriter(f)
 87	tw := tar.NewWriter(gz)
 88
 89	if err := addFile(tw, snap, "gitbay.db"); err != nil {
 90		return err
 91	}
 92
 93	// 2. Everything under the root except transient or regenerated state.
 94	// Skipped entirely for --db-only.
 95	skip := map[string]bool{
 96		"gitbay.db": true, "gitbay.db-wal": true, "gitbay.db-shm": true,
 97		"hook.sock": true, "askpass.sh": true, "hooks": true,
 98	}
 99	repoCount := 0
100	root := cfg.Server.Root
101	if !dbOnly {
102		err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
103			if err != nil {
104				return err
105			}
106			rel, err := filepath.Rel(root, path)
107			if err != nil {
108				return err
109			}
110			if rel == "." {
111				return nil
112			}
113			if top, _, _ := strings.Cut(rel, string(filepath.Separator)); skip[top] {
114				if d.IsDir() {
115					return filepath.SkipDir
116				}
117				return nil
118			}
119			if !d.Type().IsRegular() && !d.IsDir() {
120				return nil // sockets, symlinks
121			}
122			if d.IsDir() {
123				if strings.HasSuffix(rel, ".git") {
124					repoCount++
125				}
126				return nil // directories are implied by member paths
127			}
128			return addFile(tw, path, filepath.ToSlash(rel))
129		})
130		if err != nil {
131			return err
132		}
133	}
134	if err := tw.Close(); err != nil {
135		return err
136	}
137	if err := gz.Close(); err != nil {
138		return err
139	}
140	if err := f.Close(); err != nil {
141		return err
142	}
143
144	info, _ := os.Stat(out)
145	if dbOnly {
146		fmt.Printf("wrote %s (database only, %.1f MB)\n", out, float64(info.Size())/1e6)
147		return nil
148	}
149	fmt.Printf("wrote %s (%d repositories, %.1f MB)\n", out, repoCount, float64(info.Size())/1e6)
150	return nil
151}
152
153// snapshotDB writes a consistent copy of the live database. VACUUM INTO
154// takes a read snapshot, so concurrent daemon writes are safe under WAL.
155func snapshotDB(st *store.Store, dest string) error {
156	quoted := strings.ReplaceAll(dest, "'", "''")
157	_, err := st.DB.Exec(fmt.Sprintf("VACUUM INTO '%s'", quoted))
158	return err
159}
160
161func addFile(tw *tar.Writer, path, name string) error {
162	info, err := os.Stat(path)
163	if err != nil {
164		return err
165	}
166	hdr, err := tar.FileInfoHeader(info, "")
167	if err != nil {
168		return err
169	}
170	hdr.Name = name
171	if err := tw.WriteHeader(hdr); err != nil {
172		return err
173	}
174	src, err := os.Open(path)
175	if err != nil {
176		return err
177	}
178	defer src.Close()
179	_, err = io.Copy(tw, src)
180	return err
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)\n", extra)
271	}
272	return nil
273}