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