| @@ -0,0 +1,174 @@ |
| |
1 | package e2e |
| |
2 | |
| |
3 | import ( |
| |
4 | "fmt" |
| |
5 | "net" |
| |
6 | "os" |
| |
7 | "os/exec" |
| |
8 | "path/filepath" |
| |
9 | "strings" |
| |
10 | "testing" |
| |
11 | "time" |
| |
12 | ) |
| |
13 | |
| |
14 | func 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] |
| |
77 | root = %q |
| |
78 | site_url = "https://gitbay.test" |
| |
79 | [ssh] |
| |
80 | port = %d |
| |
81 | [http] |
| |
82 | addr = "127.0.0.1:%d" |
| |
83 | tls = "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 | } |