cmd/gitbayd/backup.go
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}