krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
main: e2e/backup_test.go · raw
1package e2e
2
3import (
4 "fmt"
5 "net"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12)
13
14func TestAdminBackup(t *testing.T) {
15 inst := startInstance(t)
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19
20 // Content worth backing up: a repo with commits and a tag, and an issue.
21 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/keep"); code != 0 {
22 t.Fatalf("repo create: %s", errOut)
23 }
24 work := t.TempDir()
25 env := inst.gitEnv(aliceKey)
26 mustGit(t, work, env, "clone", inst.sshURL("alice/keep"), "w")
27 dir := filepath.Join(work, "w")
28 os.WriteFile(filepath.Join(dir, "data.txt"), []byte("precious\n"), 0o644)
29 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
30 mustGit(t, dir, env, "add", ".")
31 mustGit(t, dir, env, "commit", "-q", "-m", "keep me")
32 mustGit(t, dir, env, "tag", "v1")
33 mustGit(t, dir, env, "push", "-q", "origin", "main", "v1")
34 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/keep", "--title", "'survives backup'"); code != 0 {
35 t.Fatal("issue create failed")
36 }
37
38 // Back up while the daemon is running.
39 archive := filepath.Join(t.TempDir(), "backup.tar.gz")
40 out := inst.admin(t, "admin", "backup", "--out", archive)
41 if !strings.Contains(out, "1 repositories") {
42 t.Fatalf("backup summary: %s", out)
43 }
44
45 // The archive holds the snapshot, the repo, and the host key — and none
46 // of the transient state.
47 list, err := exec.Command("tar", "-tzf", archive).Output()
48 if err != nil {
49 t.Fatal(err)
50 }
51 names := string(list)
52 for _, want := range []string{"gitbay.db", "repos/alice/keep.git/", "ssh/host_ed25519"} {
53 if !strings.Contains(names, want) {
54 t.Fatalf("archive missing %s:\n%s", want, names)
55 }
56 }
57 for _, line := range strings.Split(strings.TrimSpace(names), "\n") {
58 // Top-level transient state must be absent; a repo's own inert
59 // sample hooks directory (keep.git/hooks/) is fine.
60 for _, banned := range []string{"hook.sock", "hooks/", "askpass.sh", "gitbay.db-wal"} {
61 if line == banned || strings.HasPrefix(line, banned) {
62 t.Fatalf("archive contains transient state %s:\n%s", line, names)
63 }
64 }
65 }
66
67 // Restore: extract into a fresh root and serve from it.
68 root2 := t.TempDir()
69 if outB, err := exec.Command("tar", "-xzf", archive, "-C", root2).CombinedOutput(); err != nil {
70 t.Fatalf("extract: %v\n%s", err, outB)
71 }
72 port2 := freePort(t)
73 httpPort2 := freePort(t)
74 config2 := filepath.Join(root2, "config.toml")
75 cfg := fmt.Sprintf(`
76[server]
77root = %q
78site_url = "https://gitbay.test"
79[ssh]
80port = %d
81[http]
82addr = "127.0.0.1:%d"
83tls = "off"
84`, root2, port2, httpPort2)
85 if err := os.WriteFile(config2, []byte(cfg), 0o600); err != nil {
86 t.Fatal(err)
87 }
88 proc2 := exec.Command(inst.gitbayd, "--config", config2, "serve")
89 proc2.Stderr = os.Stderr
90 if err := proc2.Start(); err != nil {
91 t.Fatal(err)
92 }
93 t.Cleanup(func() { proc2.Process.Kill(); proc2.Wait() })
94 deadline := time.Now().Add(10 * time.Second)
95 for {
96 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port2), 200*time.Millisecond)
97 if err == nil {
98 conn.Close()
99 break
100 }
101 if time.Now().After(deadline) {
102 t.Fatal("restored gitbayd did not start")
103 }
104 time.Sleep(50 * time.Millisecond)
105 }
106
107 // Strict host key checking against the ORIGINAL instance's host key:
108 // the preserved key means the restored server is cryptographically the
109 // same host. known_hosts entries are per host:port, so rebind the
110 // original entry to the new port.
111 khRaw, err := os.ReadFile(filepath.Join(inst.sshDir, "known_hosts"))
112 if err != nil {
113 t.Fatal(err)
114 }
115 fields := strings.Fields(strings.SplitN(string(khRaw), "\n", 2)[0])
116 if len(fields) < 3 {
117 t.Fatalf("unexpected known_hosts: %q", khRaw)
118 }
119 kh2 := filepath.Join(t.TempDir(), "known_hosts")
120 entry := fmt.Sprintf("[127.0.0.1]:%d %s %s\n", port2, fields[1], fields[2])
121 if err := os.WriteFile(kh2, []byte(entry), 0o600); err != nil {
122 t.Fatal(err)
123 }
124 ssh2 := func(args ...string) (string, string, int) {
125 base := []string{
126 "-p", fmt.Sprint(port2), "-i", aliceKey,
127 "-o", "IdentitiesOnly=yes",
128 "-o", "UserKnownHostsFile=" + kh2,
129 "-o", "StrictHostKeyChecking=yes",
130 "-o", "BatchMode=yes",
131 "git@127.0.0.1",
132 }
133 cmd := exec.Command("ssh", append(base, args...)...)
134 var o, e strings.Builder
135 cmd.Stdout, cmd.Stderr = &o, &e
136 err := cmd.Run()
137 code := 0
138 if ee, ok := err.(*exec.ExitError); ok {
139 code = ee.ExitCode()
140 } else if err != nil {
141 t.Fatalf("ssh: %v", err)
142 }
143 return o.String(), e.String(), code
144 }
145
146 // Identity, repo data, and issue all survived.
147 out2, errOut, code := ssh2("whoami")
148 if code != 0 || strings.TrimSpace(out2) != "alice" {
149 t.Fatalf("whoami on restored instance: exit %d, %q, %s", code, out2, errOut)
150 }
151 if out2, _, code = ssh2("repo", "log", "alice/keep"); code != 0 || !strings.Contains(out2, "keep me") {
152 t.Fatalf("restored log: %d\n%s", code, out2)
153 }
154 if out2, _, code = ssh2("issue", "show", "alice/keep", "1"); code != 0 || !strings.Contains(out2, "survives backup") {
155 t.Fatalf("restored issue: %d\n%s", code, out2)
156 }
157
158 // The restored instance accepts new pushes: hooks were regenerated at
159 // startup, not restored from the archive.
160 env2 := append(os.Environ(),
161 fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=%s -o BatchMode=yes",
162 aliceKey, kh2),
163 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
164 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
165 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
166 work2 := t.TempDir()
167 mustGit(t, work2, env2, "clone", fmt.Sprintf("ssh://git@127.0.0.1:%d/alice/keep.git", port2), "w")
168 dir2 := filepath.Join(work2, "w")
169 if data, _ := os.ReadFile(filepath.Join(dir2, "data.txt")); string(data) != "precious\n" {
170 t.Fatalf("restored content: %q", data)
171 }
172 mustGit(t, dir2, env2, "commit", "-q", "--allow-empty", "-m", "post-restore")
173 mustGit(t, dir2, env2, "push", "-q", "origin", "main")
174}