internal/control/runnernext_test.go
217 lines · 7156 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}