Commit b2cce47084
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbayd/backup.go +97 −1
| @@ -26,7 +26,7 @@ import ( | ||
| 26 | 26 | // objects in the archive (harmless); the reverse order could leave database |
| 27 | 27 | // rows pointing at objects the archive never captured. |
| 28 | 28 | func backupCmd() *cobra.Command { |
| 29 | var out string | |
| 29 | var out, verify string | |
| 30 | 30 | var dbOnly bool |
| 31 | 31 | cmd := &cobra.Command{ |
| 32 | 32 | Use: "backup", |
| @@ -44,6 +44,9 @@ so it supplements a full backup and does not replace one. | ||
| 44 | 44 | Restore: extract into an empty directory, point server.root at it, start |
| 45 | 45 | gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`, |
| 46 | 46 | RunE: func(cmd *cobra.Command, args []string) error { |
| 47 | if verify != "" { | |
| 48 | return verifyBackup(verify) | |
| 49 | } | |
| 47 | 50 | cfg, err := config.Load(configPath) |
| 48 | 51 | if err != nil { |
| 49 | 52 | return err |
| @@ -56,6 +59,7 @@ gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`, | ||
| 56 | 59 | } |
| 57 | 60 | cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz)") |
| 58 | 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") | |
| 59 | 63 | return cmd |
| 60 | 64 | } |
| 61 | 65 | |
| @@ -175,3 +179,95 @@ func addFile(tw *tar.Writer, path, name string) error { | ||
| 175 | 179 | _, err = io.Copy(tw, src) |
| 176 | 180 | return err |
| 177 | 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. | |
| 187 | func 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, or a wiki)\n", extra) | |
| 271 | } | |
| 272 | return nil | |
| 273 | } | |
e2e/reap_test.go +40
| @@ -204,3 +204,43 @@ func TestGCLFSOrphans(t *testing.T) { | ||
| 204 | 204 | t.Fatalf("after gc: kept=%v orphan=%v young=%v", exists(kept), exists(orphan), exists(young)) |
| 205 | 205 | } |
| 206 | 206 | } |
| 207 | ||
| 208 | // backup --verify reads an archive back and says whether a restore would | |
| 209 | // have what the database expects. | |
| 210 | func TestBackupVerify(t *testing.T) { | |
| 211 | inst := startInstance(t) | |
| 212 | aliceKey := inst.newKey(t, "alice") | |
| 213 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 214 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/keep"); code != 0 { | |
| 215 | t.Fatal("repo create failed") | |
| 216 | } | |
| 217 | full := filepath.Join(t.TempDir(), "full.tar.gz") | |
| 218 | inst.admin(t, "admin", "backup", "--out", full) | |
| 219 | if out := inst.admin(t, "admin", "backup", "--verify", full); !strings.Contains(out, "integrity ok, 1 repositories in the database, 1 in the archive") { | |
| 220 | t.Fatalf("verify full:\n%s", out) | |
| 221 | } | |
| 222 | dbOnly := filepath.Join(t.TempDir(), "db.tar.gz") | |
| 223 | inst.admin(t, "admin", "backup", "--db-only", "--out", dbOnly) | |
| 224 | if out := inst.admin(t, "admin", "backup", "--verify", dbOnly); !strings.Contains(out, "database only; integrity ok, 1 repositories in the database") { | |
| 225 | t.Fatalf("verify db-only:\n%s", out) | |
| 226 | } | |
| 227 | // A truncated archive is named as damaged, not reported healthy. | |
| 228 | raw, _ := os.ReadFile(full) | |
| 229 | cut := filepath.Join(t.TempDir(), "cut.tar.gz") | |
| 230 | os.WriteFile(cut, raw[:len(raw)/2], 0o644) | |
| 231 | if out := inst.forgedAdminErr(t, "admin", "backup", "--verify", cut); !strings.Contains(out, "damaged") && !strings.Contains(out, "unexpected EOF") { | |
| 232 | t.Fatalf("verify truncated:\n%s", out) | |
| 233 | } | |
| 234 | // A repository the database names but the archive lacks fails it. | |
| 235 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/late"); code != 0 { | |
| 236 | t.Fatal("repo create failed") | |
| 237 | } | |
| 238 | late := filepath.Join(t.TempDir(), "late.tar.gz") | |
| 239 | inst.admin(t, "admin", "backup", "--out", late) | |
| 240 | os.RemoveAll(filepath.Join(inst.root, "repos", "alice", "late.git")) | |
| 241 | stale := filepath.Join(t.TempDir(), "stale.tar.gz") | |
| 242 | inst.admin(t, "admin", "backup", "--out", stale) | |
| 243 | if out := inst.forgedAdminErr(t, "admin", "backup", "--verify", stale); !strings.Contains(out, "not in the archive: alice/late") { | |
| 244 | t.Fatalf("verify missing repo:\n%s", out) | |
| 245 | } | |
| 246 | } | |