krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: cmd/gitbayd/backup.go · raw
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 cmd := &cobra.Command{
31 Use: "backup",
32 Short: "write a consistent backup archive (database snapshot first, then repositories)",
33 Long: `Writes a tar.gz of the server root: a consistent SQLite snapshot,
34all repositories, and the SSH host keys. Transient state (hook socket,
35regenerated hook scripts, askpass helper, WAL files) is excluded.
36
37Restore: extract into an empty directory, point server.root at it, start
38gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`,
39 RunE: func(cmd *cobra.Command, args []string) error {
40 cfg, err := config.Load(configPath)
41 if err != nil {
42 return err
43 }
44 if out == "" {
45 out = fmt.Sprintf("gitbay-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
46 }
47 return runBackup(cfg, out)
48 },
49 }
50 cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz)")
51 return cmd
52}
53
54func runBackup(cfg config.Config, out string) error {
55 st, err := openStore(cfg)
56 if err != nil {
57 return err
58 }
59 defer st.Close()
60
61 // 1. Consistent database snapshot, before any repository is read.
62 snap := filepath.Join(os.TempDir(), fmt.Sprintf("gitbay-snap-%d.db", os.Getpid()))
63 os.Remove(snap)
64 defer os.Remove(snap)
65 if err := snapshotDB(st, snap); err != nil {
66 return fmt.Errorf("database snapshot: %w", err)
67 }
68
69 f, err := os.Create(out)
70 if err != nil {
71 return err
72 }
73 defer f.Close()
74 gz := gzip.NewWriter(f)
75 tw := tar.NewWriter(gz)
76
77 if err := addFile(tw, snap, "gitbay.db"); err != nil {
78 return err
79 }
80
81 // 2. Everything under the root except transient or regenerated state.
82 skip := map[string]bool{
83 "gitbay.db": true, "gitbay.db-wal": true, "gitbay.db-shm": true,
84 "hook.sock": true, "askpass.sh": true, "hooks": true,
85 }
86 repoCount := 0
87 root := cfg.Server.Root
88 err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
89 if err != nil {
90 return err
91 }
92 rel, err := filepath.Rel(root, path)
93 if err != nil {
94 return err
95 }
96 if rel == "." {
97 return nil
98 }
99 if top, _, _ := strings.Cut(rel, string(filepath.Separator)); skip[top] {
100 if d.IsDir() {
101 return filepath.SkipDir
102 }
103 return nil
104 }
105 if !d.Type().IsRegular() && !d.IsDir() {
106 return nil // sockets, symlinks
107 }
108 if d.IsDir() {
109 if strings.HasSuffix(rel, ".git") {
110 repoCount++
111 }
112 return nil // directories are implied by member paths
113 }
114 return addFile(tw, path, filepath.ToSlash(rel))
115 })
116 if err != nil {
117 return err
118 }
119 if err := tw.Close(); err != nil {
120 return err
121 }
122 if err := gz.Close(); err != nil {
123 return err
124 }
125 if err := f.Close(); err != nil {
126 return err
127 }
128
129 info, _ := os.Stat(out)
130 fmt.Printf("wrote %s (%d repositories, %.1f MB)\n", out, repoCount, float64(info.Size())/1e6)
131 return nil
132}
133
134// snapshotDB writes a consistent copy of the live database. VACUUM INTO
135// takes a read snapshot, so concurrent daemon writes are safe under WAL.
136func snapshotDB(st *store.Store, dest string) error {
137 quoted := strings.ReplaceAll(dest, "'", "''")
138 _, err := st.DB.Exec(fmt.Sprintf("VACUUM INTO '%s'", quoted))
139 return err
140}
141
142func addFile(tw *tar.Writer, path, name string) error {
143 info, err := os.Stat(path)
144 if err != nil {
145 return err
146 }
147 hdr, err := tar.FileInfoHeader(info, "")
148 if err != nil {
149 return err
150 }
151 hdr.Name = name
152 if err := tw.WriteHeader(hdr); err != nil {
153 return err
154 }
155 src, err := os.Open(path)
156 if err != nil {
157 return err
158 }
159 defer src.Close()
160 _, err = io.Copy(tw, src)
161 return err
162}