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