internal/control/runnernext_test.go

v1.15.0
gitbay/internal/control/runnernext_test.go history · blame · raw

243 lines · 8150 bytes

  1package control
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9	"testing"
 10
 11	"gitbay.org/gitbay/internal/config"
 12	"gitbay.org/gitbay/internal/protocol"
 13	"gitbay.org/gitbay/internal/store"
 14)
 15
 16// runnerCtx builds a Ctx good enough to run runRunnerNext directly: an
 17// admin user (requireRunner accepts admin as well as scope "runner"), a
 18// server root that matches where the test's bare repo lives.
 19func runnerCtx(st *store.Store, uid int64, root string) (*Ctx, *bytes.Buffer) {
 20	var out bytes.Buffer
 21	c := &Ctx{
 22		User:   store.User{ID: uid, Username: "ci", IsAdmin: true},
 23		Store:  st,
 24		Cfg:    config.Config{Server: config.Server{Root: root, SiteURL: "https://x.test"}},
 25		Stdin:  strings.NewReader(""),
 26		Stdout: &out,
 27		Stderr: &out,
 28	}
 29	return c, &out
 30}
 31
 32// A queued build's sha is orphaned by rewinding "main" past it — the shape
 33// a force-push leaves, without needing an actual git-receive-pack round
 34// trip. The base commit stays reachable, giving one real build behind it.
 35func setupOrphanRepo(t *testing.T) (*store.Store, store.Repo, int64, string, string, string) {
 36	t.Helper()
 37	st, repo, uid := newQueueTestRepo(t)
 38	git := gitRunner(t)
 39	root := t.TempDir()
 40
 41	src := filepath.Join(root, "src")
 42	os.MkdirAll(src, 0o755)
 43	git(root, "init", "-q", "-b", "main", "src")
 44	git(src, "commit", "-q", "--allow-empty", "-m", "base")
 45	baseSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
 46	git(src, "commit", "-q", "--allow-empty", "-m", "orphaned")
 47	orphanSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
 48
 49	dir := RepoDir(root, repo.OwnerName, repo.Name)
 50	os.MkdirAll(filepath.Dir(dir), 0o755)
 51	git(root, "clone", "-q", "--bare", src, dir)
 52	git(dir, "update-ref", "refs/heads/main", baseSHA)
 53
 54	return st, repo, uid, root, baseSHA, orphanSHA
 55}
 56
 57// A build queued for a sha a force-push orphaned is cancelled at claim
 58// time, and the runner gets the next real build instead of an impossible
 59// one.
 60func TestRunnerNextSkipsOrphanedBuildAndClaimsNext(t *testing.T) {
 61	st, repo, uid, root, baseSHA, orphanSHA := setupOrphanRepo(t)
 62
 63	orphanedID, err := st.CreateBuild(repo.ID, "unit", orphanSHA, "main", "[]", "", "", true)
 64	if err != nil {
 65		t.Fatal(err)
 66	}
 67	if err := st.SetCommitStatus(repo.ID, orphanSHA, "ci/unit", "pending", "queued", "https://x.test", uid); err != nil {
 68		t.Fatal(err)
 69	}
 70	realID, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", "", true)
 71	if err != nil {
 72		t.Fatal(err)
 73	}
 74
 75	c, out := runnerCtx(st, uid, root)
 76	code := runRunnerNext(c, nil)
 77	if code != protocol.ExitOK {
 78		t.Fatalf("runner next: exit %d, output:\n%s", code, out.String())
 79	}
 80	if !strings.Contains(out.String(), fmt.Sprintf("build %d: ", realID)) || !strings.Contains(out.String(), baseSHA[:10]) {
 81		t.Fatalf("expected the real build handed out, got:\n%s", out.String())
 82	}
 83
 84	orphaned, err := st.BuildByNumber(repo.ID, orphanedID)
 85	if err != nil {
 86		t.Fatal(err)
 87	}
 88	if orphaned.Status != "cancelled" {
 89		t.Fatalf("orphaned build status = %q, want cancelled", orphaned.Status)
 90	}
 91	log, _ := st.BuildLog(orphaned.ID)
 92	if !strings.Contains(string(log), "not reachable") {
 93		t.Fatalf("orphaned build log missing the reason:\n%s", log)
 94	}
 95	statuses, err := st.ListCommitStatuses(repo.ID, orphanSHA)
 96	if err != nil {
 97		t.Fatal(err)
 98	}
 99	if len(statuses) != 1 || statuses[0].State == "pending" {
100		t.Fatalf("orphaned build's commit status still pending: %+v", statuses)
101	}
102
103	real, err := st.BuildByNumber(repo.ID, realID)
104	if err != nil {
105		t.Fatal(err)
106	}
107	if real.Status != "running" {
108		t.Fatalf("real build status = %q, want running (claimed)", real.Status)
109	}
110}
111
112// A build whose sha is genuinely reachable is claimed exactly as before:
113// the reachability check must never reject a healthy build.
114func TestRunnerNextClaimsReachableBuildNormally(t *testing.T) {
115	st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t)
116	id, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", "", true)
117	if err != nil {
118		t.Fatal(err)
119	}
120
121	c, out := runnerCtx(st, uid, root)
122	code := runRunnerNext(c, nil)
123	if code != protocol.ExitOK {
124		t.Fatalf("runner next: exit %d, output:\n%s", code, out.String())
125	}
126	if strings.Contains(out.String(), "no pending builds") {
127		t.Fatalf("a reachable build was not handed out:\n%s", out.String())
128	}
129	b, err := st.BuildByNumber(repo.ID, id)
130	if err != nil {
131		t.Fatal(err)
132	}
133	if b.Status != "running" {
134		t.Fatalf("reachable build status = %q, want running", b.Status)
135	}
136	log, _ := st.BuildLog(b.ID)
137	if strings.Contains(string(log), "cancelled") {
138		t.Fatalf("a healthy build was cancelled:\n%s", log)
139	}
140}
141
142// A queue built entirely of one orphaned sha, past the loop's cap, still
143// terminates and reports no pending builds — never a spin, never an
144// error — while everything up to the cap is actually resolved rather than
145// left claimed and dangling.
146func TestRunnerNextOrphanedQueuePastCapReportsNoPendingBuilds(t *testing.T) {
147	st, repo, uid, root, _, orphanSHA := setupOrphanRepo(t)
148	total := maxOrphanSkip + 1
149	for i := 0; i < total; i++ {
150		if _, err := st.CreateBuild(repo.ID, fmt.Sprintf("job%d", i), orphanSHA, "main", "[]", "", "", true); err != nil {
151			t.Fatal(err)
152		}
153	}
154
155	c, out := runnerCtx(st, uid, root)
156	code := runRunnerNext(c, nil)
157	if code != protocol.ExitOK {
158		t.Fatalf("runner next: exit %d, output:\n%s", code, out.String())
159	}
160	if !strings.Contains(out.String(), "no pending builds") {
161		t.Fatalf("expected no pending builds, got:\n%s", out.String())
162	}
163
164	builds, err := st.ListBuilds(repo.ID, total+1)
165	if err != nil {
166		t.Fatal(err)
167	}
168	var cancelled, pending, other int
169	for _, b := range builds {
170		switch b.Status {
171		case "cancelled":
172			cancelled++
173		case "pending":
174			pending++
175		default:
176			other++
177		}
178	}
179	if other != 0 {
180		t.Fatalf("a build was left claimed rather than resolved: cancelled=%d pending=%d other=%d", cancelled, pending, other)
181	}
182	if cancelled != maxOrphanSkip {
183		t.Fatalf("cancelled %d builds, want the cap of %d", cancelled, maxOrphanSkip)
184	}
185	if pending != total-maxOrphanSkip {
186		t.Fatalf("pending %d builds, want %d left behind by the cap", pending, total-maxOrphanSkip)
187	}
188}
189
190// Reachable erroring — no repository on disk at all — must not read as
191// "unreachable": the ambiguous case is claimable, never cancelled.
192func TestRunnerNextClaimsBuildWhenReachabilityCannotBeChecked(t *testing.T) {
193	st, repo, uid := newQueueTestRepo(t)
194	// No RepoDir created on disk at all: Reachable will fail to even stat
195	// the repository, which must not be read as "orphaned".
196	root := t.TempDir()
197	id, err := st.CreateBuild(repo.ID, "unit", strings.Repeat("a", 40), "main", "[]", "", "", true)
198	if err != nil {
199		t.Fatal(err)
200	}
201
202	c, out := runnerCtx(st, uid, root)
203	code := runRunnerNext(c, nil)
204	if code != protocol.ExitOK {
205		t.Fatalf("runner next: exit %d, output:\n%s", code, out.String())
206	}
207	if strings.Contains(out.String(), "no pending builds") {
208		t.Fatalf("a build was not handed out when reachability could not be checked:\n%s", out.String())
209	}
210	b, err := st.BuildByNumber(repo.ID, id)
211	if err != nil {
212		t.Fatal(err)
213	}
214	if b.Status != "running" {
215		t.Fatalf("build status = %q, want running: an unchecked build must still be claimable", b.Status)
216	}
217}
218
219// runner log records when the stream ended, so a build whose runner then
220// vanishes is failed within minutes rather than at the deadline (#179).
221func TestRunnerLogMarksStreamClosed(t *testing.T) {
222	st, repo, uid := newQueueTestRepo(t)
223	root := t.TempDir()
224	if _, err := st.CreateBuild(repo.ID, "unit", strings.Repeat("a", 40), "main", "[]", "", "", true); err != nil {
225		t.Fatal(err)
226	}
227	b, ok, err := st.ClaimBuild([]int64{repo.ID})
228	if err != nil || !ok {
229		t.Fatalf("claim: %v", err)
230	}
231	c, _ := runnerCtx(st, uid, root)
232	c.Stdin = strings.NewReader("hello\n") // one chunk, then EOF: the stream ends
233	if code := runRunnerLog(c, []string{fmt.Sprint(b.ID)}); code != 0 {
234		t.Fatalf("runner log exited %d", code)
235	}
236	got, _ := st.BuildByID(b.ID)
237	if got.LogClosedAt == "" {
238		t.Fatal("log_closed_at not set when the stream ended")
239	}
240	if got.Status != "running" {
241		t.Errorf("status %s, want still running until the runner reports", got.Status)
242	}
243}