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