e2e/backup_test.go

ff759b53942049b2043e132f5482b442a7265b98
gitbay/e2e/backup_test.go history · blame · raw

215 lines · 8088 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	// Restore: extract into a fresh root and serve from it.
 91	root2 := t.TempDir()
 92	if outB, err := exec.Command("tar", "-xzf", archive, "-C", root2).CombinedOutput(); err != nil {
 93		t.Fatalf("extract: %v\n%s", err, outB)
 94	}
 95	ports := freePorts(t, 2)
 96	port2, httpPort2 := ports[0], ports[1]
 97	config2 := filepath.Join(root2, "config.toml")
 98	cfg := fmt.Sprintf(`
 99[server]
100root = %q
101site_url = "https://gitbay.test"
102secret_key_file = %q
103[ssh]
104port = %d
105[http]
106addr = "127.0.0.1:%d"
107tls = "off"
108`, root2, inst.keyFile, port2, httpPort2)
109	if err := os.WriteFile(config2, []byte(cfg), 0o600); err != nil {
110		t.Fatal(err)
111	}
112	proc2 := exec.Command(inst.gitbayd, "--config", config2, "serve")
113	proc2.Stderr = os.Stderr
114	if err := proc2.Start(); err != nil {
115		t.Fatal(err)
116	}
117	t.Cleanup(func() { proc2.Process.Kill(); proc2.Wait() })
118	deadline := time.Now().Add(10 * time.Second)
119	for {
120		conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port2), 200*time.Millisecond)
121		if err == nil {
122			conn.Close()
123			break
124		}
125		if time.Now().After(deadline) {
126			t.Fatal("restored gitbayd did not start")
127		}
128		time.Sleep(50 * time.Millisecond)
129	}
130
131	// Strict host key checking against the ORIGINAL instance's host key:
132	// the preserved key means the restored server is cryptographically the
133	// same host. known_hosts entries are per host:port, so rebind the
134	// original entry to the new port.
135	khRaw, err := os.ReadFile(filepath.Join(inst.sshDir, "known_hosts"))
136	if err != nil {
137		t.Fatal(err)
138	}
139	fields := strings.Fields(strings.SplitN(string(khRaw), "\n", 2)[0])
140	if len(fields) < 3 {
141		t.Fatalf("unexpected known_hosts: %q", khRaw)
142	}
143	kh2 := filepath.Join(t.TempDir(), "known_hosts")
144	entry := fmt.Sprintf("[127.0.0.1]:%d %s %s\n", port2, fields[1], fields[2])
145	if err := os.WriteFile(kh2, []byte(entry), 0o600); err != nil {
146		t.Fatal(err)
147	}
148	ssh2 := func(args ...string) (string, string, int) {
149		base := []string{
150			"-p", fmt.Sprint(port2), "-i", aliceKey,
151			"-o", "IdentitiesOnly=yes",
152			"-o", "UserKnownHostsFile=" + kh2,
153			"-o", "StrictHostKeyChecking=yes",
154			"-o", "BatchMode=yes",
155			"git@127.0.0.1",
156		}
157		cmd := exec.Command("ssh", append(base, args...)...)
158		var o, e strings.Builder
159		cmd.Stdout, cmd.Stderr = &o, &e
160		err := cmd.Run()
161		code := 0
162		if ee, ok := err.(*exec.ExitError); ok {
163			code = ee.ExitCode()
164		} else if err != nil {
165			t.Fatalf("ssh: %v", err)
166		}
167		return o.String(), e.String(), code
168	}
169
170	// Identity, repo data, and issue all survived.
171	out2, errOut, code := ssh2("whoami")
172	if code != 0 || strings.TrimSpace(out2) != "alice" {
173		t.Fatalf("whoami on restored instance: exit %d, %q, %s", code, out2, errOut)
174	}
175	// With the original key the restored secrets open; with another key
176	// they do not.
177	if out, err := exec.Command(inst.gitbayd, "--config", config2, "admin", "secrets", "check").CombinedOutput(); err != nil || !secretsCheckOneSealed.Match(out) {
178		t.Fatalf("secrets check on the restored instance: %v\n%s", err, out)
179	}
180	config3 := filepath.Join(root2, "config-wrong-key.toml")
181	wrong := strings.Replace(cfg, fmt.Sprintf("secret_key_file = %q", inst.keyFile),
182		fmt.Sprintf("secret_key_file = %q", filepath.Join(t.TempDir(), "other.key")), 1)
183	if err := os.WriteFile(config3, []byte(wrong), 0o600); err != nil {
184		t.Fatal(err)
185	}
186	if out, err := exec.Command(inst.gitbayd, "--config", config3, "admin", "secrets", "init").CombinedOutput(); err != nil {
187		t.Fatalf("init the wrong key: %v\n%s", err, out)
188	}
189	if out, err := exec.Command(inst.gitbayd, "--config", config3, "admin", "secrets", "check").CombinedOutput(); err == nil || !strings.Contains(string(out), "does not hold") {
190		t.Fatalf("secrets check with the wrong key: %v\n%s", err, out)
191	}
192	if out2, _, code = ssh2("repo", "log", "alice/keep"); code != 0 || !strings.Contains(out2, "keep me") {
193		t.Fatalf("restored log: %d\n%s", code, out2)
194	}
195	if out2, _, code = ssh2("issue", "show", "alice/keep", "1"); code != 0 || !strings.Contains(out2, "survives backup") {
196		t.Fatalf("restored issue: %d\n%s", code, out2)
197	}
198
199	// The restored instance accepts new pushes: hooks were regenerated at
200	// startup, not restored from the archive.
201	env2 := append(os.Environ(),
202		fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=%s -o BatchMode=yes",
203			aliceKey, kh2),
204		"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
205		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
206		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
207	work2 := t.TempDir()
208	mustGit(t, work2, env2, "clone", fmt.Sprintf("ssh://git@127.0.0.1:%d/alice/keep.git", port2), "w")
209	dir2 := filepath.Join(work2, "w")
210	if data, _ := os.ReadFile(filepath.Join(dir2, "data.txt")); string(data) != "precious\n" {
211		t.Fatalf("restored content: %q", data)
212	}
213	mustGit(t, dir2, env2, "commit", "-q", "--allow-empty", "-m", "post-restore")
214	mustGit(t, dir2, env2, "push", "-q", "origin", "main")
215}