| @@ -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 | } |