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}