e2e/backup_test.go

1ed9fb9399b21da8e6cf45be8389792aac82d5bc
gitbay/e2e/backup_test.go history · blame · raw

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}