e2e/backup_test.go

v1.42.0
gitbay/e2e/backup_test.go history · blame · raw

219 lines · 8250 bytes

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