cmd/gitbayd/backup.go

v1.5.0
gitbay/cmd/gitbayd/backup.go history · blame · raw

177 lines · 4926 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 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			cfg, err := config.Load(configPath)
 48			if err != nil {
 49				return err
 50			}
 51			if out == "" {
 52				out = fmt.Sprintf("gitbay-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
 53			}
 54			return runBackup(cfg, out, dbOnly)
 55		},
 56	}
 57	cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz)")
 58	cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories")
 59	return cmd
 60}
 61
 62func runBackup(cfg config.Config, out string, dbOnly bool) error {
 63	st, err := openStore(cfg)
 64	if err != nil {
 65		return err
 66	}
 67	defer st.Close()
 68
 69	// 1. Consistent database snapshot, before any repository is read.
 70	snap := filepath.Join(os.TempDir(), fmt.Sprintf("gitbay-snap-%d.db", os.Getpid()))
 71	os.Remove(snap)
 72	defer os.Remove(snap)
 73	if err := snapshotDB(st, snap); err != nil {
 74		return fmt.Errorf("database snapshot: %w", err)
 75	}
 76
 77	f, err := os.Create(out)
 78	if err != nil {
 79		return err
 80	}
 81	defer f.Close()
 82	gz := gzip.NewWriter(f)
 83	tw := tar.NewWriter(gz)
 84
 85	if err := addFile(tw, snap, "gitbay.db"); err != nil {
 86		return err
 87	}
 88
 89	// 2. Everything under the root except transient or regenerated state.
 90	// Skipped entirely for --db-only.
 91	skip := map[string]bool{
 92		"gitbay.db": true, "gitbay.db-wal": true, "gitbay.db-shm": true,
 93		"hook.sock": true, "askpass.sh": true, "hooks": true,
 94	}
 95	repoCount := 0
 96	root := cfg.Server.Root
 97	if !dbOnly {
 98		err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
 99			if err != nil {
100				return err
101			}
102			rel, err := filepath.Rel(root, path)
103			if err != nil {
104				return err
105			}
106			if rel == "." {
107				return nil
108			}
109			if top, _, _ := strings.Cut(rel, string(filepath.Separator)); skip[top] {
110				if d.IsDir() {
111					return filepath.SkipDir
112				}
113				return nil
114			}
115			if !d.Type().IsRegular() && !d.IsDir() {
116				return nil // sockets, symlinks
117			}
118			if d.IsDir() {
119				if strings.HasSuffix(rel, ".git") {
120					repoCount++
121				}
122				return nil // directories are implied by member paths
123			}
124			return addFile(tw, path, filepath.ToSlash(rel))
125		})
126		if err != nil {
127			return err
128		}
129	}
130	if err := tw.Close(); err != nil {
131		return err
132	}
133	if err := gz.Close(); err != nil {
134		return err
135	}
136	if err := f.Close(); err != nil {
137		return err
138	}
139
140	info, _ := os.Stat(out)
141	if dbOnly {
142		fmt.Printf("wrote %s (database only, %.1f MB)\n", out, float64(info.Size())/1e6)
143		return nil
144	}
145	fmt.Printf("wrote %s (%d repositories, %.1f MB)\n", out, repoCount, float64(info.Size())/1e6)
146	return nil
147}
148
149// snapshotDB writes a consistent copy of the live database. VACUUM INTO
150// takes a read snapshot, so concurrent daemon writes are safe under WAL.
151func snapshotDB(st *store.Store, dest string) error {
152	quoted := strings.ReplaceAll(dest, "'", "''")
153	_, err := st.DB.Exec(fmt.Sprintf("VACUUM INTO '%s'", quoted))
154	return err
155}
156
157func addFile(tw *tar.Writer, path, name string) error {
158	info, err := os.Stat(path)
159	if err != nil {
160		return err
161	}
162	hdr, err := tar.FileInfoHeader(info, "")
163	if err != nil {
164		return err
165	}
166	hdr.Name = name
167	if err := tw.WriteHeader(hdr); err != nil {
168		return err
169	}
170	src, err := os.Open(path)
171	if err != nil {
172		return err
173	}
174	defer src.Close()
175	_, err = io.Copy(tw, src)
176	return err
177}