krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

repo-descriptions: e2e/backup_test.go · raw

  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	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]
 77root = %q
 78site_url = "https://gitbay.test"
 79[ssh]
 80port = %d
 81[http]
 82addr = "127.0.0.1:%d"
 83tls = "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}