Commit dfd79e9c40
Verified · cmc ci/build: error ci/test: error ci/vuln: error
Layout: unified · split
cmd/gitbay/main.go +1
| @@ -45,6 +45,7 @@ func newRoot() *cobra.Command { | ||
| 45 | 45 | pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}), |
| 46 | 46 | pass("jobs", "list the jobs a trigger can name", passOpts{server: []string{"build", "jobs"}, needsRepo: true}), |
| 47 | 47 | pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}), |
| 48 | pass("cancel", "withdraw a queued build: <n>", passOpts{server: []string{"build", "cancel"}, needsRepo: true}), | |
| 48 | 49 | ), |
| 49 | 50 | pass("dashboard", "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds", |
| 50 | 51 | passOpts{server: []string{"dashboard"}}), |
e2e/build_cancel_test.go added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | // build cancel withdraws a queued build; a running one is the runner's. | |
| 11 | // Cancelling a duplicate of a commit that already passed puts that | |
| 12 | // result back on the commit. | |
| 13 | func TestBuildCancel(t *testing.T) { | |
| 14 | inst := startInstance(t) | |
| 15 | inst.runner = buildRunner(t) | |
| 16 | aliceKey := inst.newKey(t, "alice") | |
| 17 | bobKey := inst.newKey(t, "bob") | |
| 18 | runnerKey := inst.newKey(t, "ci") | |
| 19 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 20 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 21 | inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin") | |
| 22 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 23 | t.Fatal("repo create failed") | |
| 24 | } | |
| 25 | work := t.TempDir() | |
| 26 | env := inst.gitEnv(aliceKey) | |
| 27 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 28 | dir := filepath.Join(work, "w") | |
| 29 | os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755) | |
| 30 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n unit:\n steps:\n - echo fine\n"), 0o644) | |
| 31 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 32 | mustGit(t, dir, env, "add", ".") | |
| 33 | mustGit(t, dir, env, "commit", "-q", "-m", "ci") | |
| 34 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 35 | sha := strings.Fields(strings.TrimSpace(mustGit(t, dir, env, "ls-remote", "origin", "refs/heads/main")))[0] | |
| 36 | ||
| 37 | // Build 1 is queued. A reader cannot cancel it; the owner can. | |
| 38 | if _, _, code := inst.ssh(t, bobKey, "", "build", "cancel", "alice/app", "1"); code != 4 { | |
| 39 | t.Fatal("reader cancelled a build") | |
| 40 | } | |
| 41 | if out, errOut, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", "1"); code != 0 || !strings.Contains(out, "cancelled alice/app build 1") { | |
| 42 | t.Fatalf("cancel: exit %d %s%s", code, out, errOut) | |
| 43 | } | |
| 44 | if list := inst.buildList(t, aliceKey); !strings.Contains(list, "unit\tcancelled") { | |
| 45 | t.Fatalf("build list after cancel:\n%s", list) | |
| 46 | } | |
| 47 | if out, _, _ := inst.ssh(t, aliceKey, "", "build", "log", "alice/app", "1"); !strings.Contains(out, "cancelled by alice") { | |
| 48 | t.Fatalf("log after cancel:\n%s", out) | |
| 49 | } | |
| 50 | if st, _, _ := inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha); !strings.Contains(st, "error") || !strings.Contains(st, "cancelled") { | |
| 51 | t.Fatalf("status after cancel:\n%s", st) | |
| 52 | } | |
| 53 | if _, _, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", "1"); code != 2 { | |
| 54 | t.Fatal("cancelled a cancelled build") | |
| 55 | } | |
| 56 | // Nothing for a runner to claim. | |
| 57 | if out := inst.runnerOnce(t, runnerKey); strings.Contains(out, "unit") && !strings.Contains(out, "no pending") { | |
| 58 | t.Fatalf("runner picked up a cancelled build:\n%s", out) | |
| 59 | } | |
| 60 | if status, _ := inst.get(t, "/alice/app/badge/build.svg"); status != 200 { | |
| 61 | t.Fatalf("badge after cancel: %d", status) | |
| 62 | } | |
| 63 | ||
| 64 | // Build 2: triggered, run to success. Build 3: the same commit queued | |
| 65 | // again; cancelling it restores the passed result on the commit. | |
| 66 | if _, _, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "unit"); code != 0 { | |
| 67 | t.Fatal("trigger failed") | |
| 68 | } | |
| 69 | inst.runnerOnce(t, runnerKey) | |
| 70 | if list := inst.buildList(t, aliceKey); !strings.Contains(list, "unit\tsuccess") { | |
| 71 | t.Fatalf("build 2 did not pass:\n%s", list) | |
| 72 | } | |
| 73 | if _, _, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "unit"); code != 0 { | |
| 74 | t.Fatal("second trigger failed") | |
| 75 | } | |
| 76 | if st, _, _ := inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha); !strings.Contains(st, "pending") { | |
| 77 | t.Fatalf("status before cancelling the duplicate:\n%s", st) | |
| 78 | } | |
| 79 | if _, errOut, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", "3"); code != 0 { | |
| 80 | t.Fatalf("cancel duplicate: %s", errOut) | |
| 81 | } | |
| 82 | if st, _, _ := inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha); !strings.Contains(st, "success") || !strings.Contains(st, "passed in build 2") { | |
| 83 | t.Fatalf("status after cancelling the duplicate:\n%s", st) | |
| 84 | } | |
| 85 | // A running build cannot be cancelled here. | |
| 86 | if _, _, code := inst.ssh(t, aliceKey, "", "build", "trigger", "alice/app", "unit"); code != 0 { | |
| 87 | t.Fatal("third trigger failed") | |
| 88 | } | |
| 89 | if _, _, code := inst.ssh(t, runnerKey, "", "runner", "next"); code != 0 { | |
| 90 | t.Fatal("claim failed") | |
| 91 | } | |
| 92 | if _, errOut, code := inst.ssh(t, aliceKey, "", "build", "cancel", "alice/app", "4"); code != 2 || !strings.Contains(errOut, "running") { | |
| 93 | t.Fatalf("cancelled a running build: exit %d %s", code, errOut) | |
| 94 | } | |
| 95 | } | |
internal/control/build.go +39
| @@ -33,6 +33,9 @@ func init() { | ||
| 33 | 33 | Summary: "list the jobs a trigger can name", |
| 34 | 34 | Usage: "build jobs <owner/name>", ReadOnly: true, Run: runBuildJobs}) |
| 35 | 35 | |
| 36 | register(Command{Path: []string{"build", "cancel"}, | |
| 37 | Summary: "withdraw a queued build before a runner claims it", | |
| 38 | Usage: "build cancel <owner/name> <n>", Run: runBuildCancel}) | |
| 36 | 39 | register(Command{Path: []string{"build", "trigger"}, |
| 37 | 40 | Summary: "queue a job now (scheduled or not)", |
| 38 | 41 | Usage: "build trigger <owner/name> <job>", Run: runBuildTrigger}) |
| @@ -511,3 +514,39 @@ func QueueBranchBuilds( | ||
| 511 | 514 | } |
| 512 | 515 | } |
| 513 | 516 | } |
| 517 | ||
| 518 | func runBuildCancel(c *Ctx, args []string) int { | |
| 519 | repo, b, code := buildRef(c, args) | |
| 520 | if code >= 0 { | |
| 521 | return code | |
| 522 | } | |
| 523 | grant, err := c.Store.AccessRole(repo.ID, c.User.ID) | |
| 524 | if err != nil { | |
| 525 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 526 | } | |
| 527 | if !policy.CanWrite(c.User, repo, grant) { | |
| 528 | return c.fail(protocol.ExitDenied, "cancelling a build needs write access to %s", repo.Path()) | |
| 529 | } | |
| 530 | if b.Status != "pending" { | |
| 531 | return c.fail(protocol.ExitUsage, "build %d is %s; only a queued build can be cancelled", b.Number, b.Status) | |
| 532 | } | |
| 533 | if err := c.Store.CancelBuild(b.ID); err != nil { | |
| 534 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 535 | } | |
| 536 | c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf("cancelled by %s before a runner claimed it\n", c.User.Username))) | |
| 537 | // The queued status replaced whatever the commit had for this job. If | |
| 538 | // the commit passed the job on another ref, that result stands again; | |
| 539 | // otherwise the context says it was withdrawn. | |
| 540 | if prev, ok, err := c.Store.SuccessBuildFor(repo.ID, b.SHA, b.Job); err == nil && ok { | |
| 541 | url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), prev.Number) | |
| 542 | c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "success", | |
| 543 | fmt.Sprintf("passed in build %d on %s", prev.Number, prev.Ref), url, c.User.ID) | |
| 544 | } else { | |
| 545 | url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number) | |
| 546 | c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "error", "cancelled", url, c.User.ID) | |
| 547 | } | |
| 548 | c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job)) | |
| 549 | return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled"}, func(w io.Writer) { | |
| 550 | fmt.Fprintf(w, "cancelled %s build %d (%s)\n", repo.Path(), b.Number, b.Job) | |
| 551 | }) | |
| 552 | } | |
internal/httpd/badge.go +6 −5
| @@ -13,11 +13,12 @@ import ( | ||
| 13 | 13 | |
| 14 | 14 | // badgeColors are the shield fills per build state. |
| 15 | 15 | var badgeColors = map[string]string{ |
| 16 | "success": "#2da44e", | |
| 17 | "failure": "#cf222e", | |
| 18 | "running": "#bf8700", | |
| 19 | "pending": "#bf8700", | |
| 20 | "unknown": "#6b7280", | |
| 16 | "success": "#2da44e", | |
| 17 | "failure": "#cf222e", | |
| 18 | "running": "#bf8700", | |
| 19 | "pending": "#bf8700", | |
| 20 | "cancelled": "#6b7280", | |
| 21 | "unknown": "#6b7280", | |
| 21 | 22 | } |
| 22 | 23 | |
| 23 | 24 | // badgeWidth approximates Verdana 11px advance so the pill fits its text |
internal/store/builds.go +26
| @@ -290,3 +290,29 @@ func (b Build) Elapsed() time.Duration { | ||
| 290 | 290 | } |
| 291 | 291 | return 0 |
| 292 | 292 | } |
| 293 | ||
| 294 | // CancelBuild withdraws a build that no runner has claimed. A running | |
| 295 | // build is the runner's to finish; cancelling it here would leave the | |
| 296 | // runner reporting on a row that says otherwise. | |
| 297 | func (s *Store) CancelBuild(id int64) error { | |
| 298 | res, err := s.DB.Exec(`UPDATE builds SET status = 'cancelled', | |
| 299 | finished_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ? AND status = 'pending'`, id) | |
| 300 | if err != nil { | |
| 301 | return err | |
| 302 | } | |
| 303 | if n, _ := res.RowsAffected(); n == 0 { | |
| 304 | return ErrNotFound | |
| 305 | } | |
| 306 | return nil | |
| 307 | } | |
| 308 | ||
| 309 | // SuccessBuildFor finds a passed build of the commit for the job, on any | |
| 310 | // ref: what a cancelled duplicate can point back at. | |
| 311 | func (s *Store) SuccessBuildFor(repoID int64, sha, job string) (Build, bool, error) { | |
| 312 | b, err := scanBuild(s.DB.QueryRow(buildSelect+ | |
| 313 | " WHERE repo_id = ? AND sha = ? AND job = ? AND status = 'success' ORDER BY number DESC LIMIT 1", repoID, sha, job)) | |
| 314 | if errors.Is(err, sql.ErrNoRows) { | |
| 315 | return Build{}, false, nil | |
| 316 | } | |
| 317 | return b, err == nil, err | |
| 318 | } | |
internal/web/static/style.css +1
| @@ -1019,6 +1019,7 @@ table.difftable td.ln a.cmt:focus { color: var(--accent); text-decoration: under | ||
| 1019 | 1019 | .badge.check-success { --chip: var(--ok); } |
| 1020 | 1020 | .badge.check-pending { --chip: var(--warn); } |
| 1021 | 1021 | .badge.check-failure, .badge.check-error { --chip: var(--bad); } |
| 1022 | .badge.check-cancelled, .chip.check-cancelled { --chip: var(--neutral); } | |
| 1022 | 1023 | .badge.check-running, .chip.check-running { --chip: var(--warn); } |
| 1023 | 1024 | .chip.check-success { --chip: var(--ok); } |
| 1024 | 1025 | .chip.check-pending { --chip: var(--warn); } |