builds: name the failed step and duration; jump to failure !489
23 files changed, +717 −56
Layout: unified · split
.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org +2 −1
| @@ -39,7 +39,8 @@ commit instead of failing silently. | |||
| 39 | (=cmd/gitbay-runner/isolate.go=). | 39 | (=cmd/gitbay-runner/isolate.go=). |
| 40 | 4. *Log.* =runner log <id>= streams stdin into the build row; the server | 40 | 4. *Log.* =runner log <id>= streams stdin into the build row; the server |
| 41 | ends the stream if the build is cancelled (=build.go=). | 41 | ends the stream if the build is cancelled (=build.go=). |
| 42 | 5. *Result.* =runner done <id> success|failure= sets the status, | 42 | 5. *Result.* =runner done <id> success|failure [--step <n>] [--reason |
| 43 | <text>]= records where a failed build stopped, sets the status, | ||
| 43 | records an event and mails the repository's watchers a log tail on | 44 | records an event and mails the repository's watchers a log tail on |
| 44 | failure (=build.go=). | 45 | failure (=build.go=). |
| 45 | 6. *Reap.* The scheduler fails a running build whose log stream closed | 46 | 6. *Reap.* The scheduler fails a running build whose log stream closed |
.gitbay/wiki/CI.org +11
| @@ -64,6 +64,17 @@ seconds: a follower who loses it is told the repository is not found. | |||
| 64 | A restart ends every open follow with a message saying so, rather | 64 | A restart ends every open follow with a message saying so, rather |
| 65 | than holding the drain; follow again once the daemon is back. | 65 | than holding the drain; follow again once the daemon is back. |
| 66 | 66 | ||
| 67 | A failed build names the step it stopped at: the log's last line reads | ||
| 68 | =step 3/3 failed: exit 1=, and =build show= prints =failed step= (=3/3 | ||
| 69 | go test ./... (exit 1)=) and =duration=. =build log <owner/name> <n> | ||
| 70 | --step failed= prints only that step's output, =--step 2= another one | ||
| 71 | (=0= is the clone before the first step), and =--tail 40= the last forty | ||
| 72 | lines of whichever was chosen; neither combines with =--follow=. The | ||
| 73 | build page folds the finished log into one section per step, opens the | ||
| 74 | failed one and links to it from the top as "Jump to failure". Builds | ||
| 75 | from before this reported no step; their last section is taken as the | ||
| 76 | failed one. | ||
| 77 | |||
| 67 | * What a build can reach | 78 | * What a build can reach |
| 68 | 79 | ||
| 69 | Builds have outbound internet access, trusted and untrusted alike. On | 80 | Builds have outbound internet access, trusted and untrusted alike. On |
.gitbay/wiki/Parity.org +3
| @@ -217,6 +217,9 @@ rather than the one the web page shows. | |||
| 217 | | build show (one build) | yes | yes | yes | | 217 | | build show (one build) | yes | yes | yes | |
| 218 | | build log | yes | yes | yes | | 218 | | build log | yes | yes | yes | |
| 219 | | build log follow (until it ends) | yes | yes | no | | 219 | | build log follow (until it ends) | yes | yes | no | |
| 220 | | build failed step, duration | yes | yes | no | | ||
| 221 | | build log one step | yes | yes | no | | ||
| 222 | | build log tail | yes | no | no | | ||
| 220 | | build jobs | yes | yes | yes | | 223 | | build jobs | yes | yes | yes | |
| 221 | | build trigger | yes | yes | yes | | 224 | | build trigger | yes | yes | yes | |
| 222 | | build cancel | yes | yes | yes | | 225 | | build cancel | yes | yes | yes | |
.gitbay/wiki/Users.org +3 −1
| @@ -558,7 +558,9 @@ require-contexts= names statuses the gate waits for until they report, | |||
| 558 | and turns the gate on. =build list= takes =--ref=, | 558 | and turns the gate on. =build list= takes =--ref=, |
| 559 | =--status= and =--job= to narrow the listing, combinable; the builds | 559 | =--status= and =--job= to narrow the listing, combinable; the builds |
| 560 | tab reads the same flags from its =?ref=, =?status= and =?job= query | 560 | tab reads the same flags from its =?ref=, =?status= and =?job= query |
| 561 | parameters and groups the result into one row per commit. Steps run | 561 | parameters and groups the result into one row per commit. =build show= |
| 562 | names a failed build's step and how long it ran, and =build log= takes | ||
| 563 | =--step <n>|failed= and =--tail <lines>=. Steps run | ||
| 562 | with =sh -c= on the instance's runner, stopping at the first failure; | 564 | with =sh -c= on the instance's runner, stopping at the first failure; |
| 563 | a broken config surfaces as a failed =ci/config= status. Environment: | 565 | a broken config surfaces as a failed =ci/config= status. Environment: |
| 564 | =GITBAY_REPO=, | 566 | =GITBAY_REPO=, |
CHANGELOG.org +6
| @@ -142,6 +142,12 @@ for the eighteen commands whose CLI path differs from the registry's | |||
| 142 | =deploy/runner-podman-setup.sh= (it installs nftables) before =make | 142 | =deploy/runner-podman-setup.sh= (it installs nftables) before =make |
| 143 | deploy-runner=. Deploy gitbayd, then the runner, after validating on | 143 | deploy-runner=. Deploy gitbayd, then the runner, after validating on |
| 144 | a scratch repository per the CI page. (#260) | 144 | a scratch repository per the CI page. (#260) |
| 145 | - =build show= names a failed build's step and duration; =build log | ||
| 146 | --step <n>|failed --tail <lines>= reads one step's output or the | ||
| 147 | last lines of the stored log. Deploy gitbayd before the runner: an | ||
| 148 | older server refuses the runner's =--step= and =--reason= (exit 2), | ||
| 149 | and its failed builds stay running until the reaper fails them. | ||
| 150 | (#266) | ||
| 145 | 151 | ||
| 146 | * v1.36.0 — 2026-09-23 | 152 | * v1.36.0 — 2026-09-23 |
| 147 | 153 | ||
cmd/gitbay-runner/isolate.go +17 −17
| @@ -76,25 +76,25 @@ func (r *runner) checkIsolation() error { | |||
| 76 | } | 76 | } |
| 77 | } | 77 | } |
| 78 | 78 | ||
| 79 | // runSteps executes a job's steps and reports whether all succeeded. The | 79 | // runSteps executes a job's steps. Returns nil when every step succeeded. |
| 80 | // clone has already happened, outside any container and with the runner's | 80 | // The clone has already happened, outside any container and with the |
| 81 | // key: the container never sees GIT_SSH_COMMAND, the key, or the runner's | 81 | // runner's key: the container never sees GIT_SSH_COMMAND, the key, or the |
| 82 | // environment — it gets the workspace and nothing else. | 82 | // runner's environment — it gets the workspace and nothing else. |
| 83 | type stepRunner func(cmd *exec.Cmd, deadline time.Time) (bool, string) | 83 | type stepRunner func(cmd *exec.Cmd, deadline time.Time) (bool, string) |
| 84 | 84 | ||
| 85 | func (r *runner) runSteps(j job, dir string, env []string, sink io.Writer, deadline time.Time, runStep stepRunner) bool { | 85 | func (r *runner) runSteps(j job, dir string, env []string, sink io.Writer, deadline time.Time, runStep stepRunner) *failure { |
| 86 | if r.isolation == isolationNone { | 86 | if r.isolation == isolationNone { |
| 87 | for _, step := range j.Steps { | 87 | for i, step := range j.Steps { |
| 88 | fmt.Fprintf(sink, "$ %s\n", step) | 88 | fmt.Fprintf(sink, "$ %s\n", step) |
| 89 | cmd := exec.Command(toolpath.Look("sh"), "-c", step) | 89 | cmd := exec.Command(toolpath.Look("sh"), "-c", step) |
| 90 | cmd.Dir, cmd.Env = dir, env | 90 | cmd.Dir, cmd.Env = dir, env |
| 91 | cmd.Stdout, cmd.Stderr = sink, sink | 91 | cmd.Stdout, cmd.Stderr = sink, sink |
| 92 | if ok, why := runStep(cmd, deadline); !ok { | 92 | if ok, why := runStep(cmd, deadline); !ok { |
| 93 | fmt.Fprintf(sink, "%s\n", why) | 93 | fmt.Fprintf(sink, "step %d/%d failed: %s\n", i+1, len(j.Steps), why) |
| 94 | return false | 94 | return &failure{Step: i + 1, Reason: why} |
| 95 | } | 95 | } |
| 96 | } | 96 | } |
| 97 | return true | 97 | return nil |
| 98 | } | 98 | } |
| 99 | return r.runStepsPodman(j, dir, env, sink, deadline, runStep) | 99 | return r.runStepsPodman(j, dir, env, sink, deadline, runStep) |
| 100 | } | 100 | } |
| @@ -103,7 +103,7 @@ func (r *runner) runSteps(j job, dir string, env []string, sink io.Writer, deadl | |||
| 103 | // step in it with `podman exec`. One container per job, not per step, | 103 | // step in it with `podman exec`. One container per job, not per step, |
| 104 | // because steps share state — a build step writes what a test step reads | 104 | // because steps share state — a build step writes what a test step reads |
| 105 | // — and per-step containers would break that. | 105 | // — and per-step containers would break that. |
| 106 | func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer, deadline time.Time, runStep stepRunner) bool { | 106 | func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer, deadline time.Time, runStep stepRunner) *failure { |
| 107 | podman := toolpath.Look("podman") | 107 | podman := toolpath.Look("podman") |
| 108 | image := j.Image | 108 | image := j.Image |
| 109 | if image == "" { | 109 | if image == "" { |
| @@ -124,7 +124,7 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer, | |||
| 124 | envFile := filepath.Join(r.workdir, fmt.Sprintf("env-%d", j.ID)) | 124 | envFile := filepath.Join(r.workdir, fmt.Sprintf("env-%d", j.ID)) |
| 125 | if err := writeEnvFile(envFile, fileEnv); err != nil { | 125 | if err := writeEnvFile(envFile, fileEnv); err != nil { |
| 126 | fmt.Fprintf(sink, "preparing the build environment: %v\n", err) | 126 | fmt.Fprintf(sink, "preparing the build environment: %v\n", err) |
| 127 | return false | 127 | return &failure{Reason: "preparing the build environment failed"} |
| 128 | } | 128 | } |
| 129 | defer os.Remove(envFile) | 129 | defer os.Remove(envFile) |
| 130 | 130 | ||
| @@ -137,7 +137,7 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer, | |||
| 137 | dir, f, err := r.cgroups.create(j.ID, r.memory, r.cpus) | 137 | dir, f, err := r.cgroups.create(j.ID, r.memory, r.cpus) |
| 138 | if err != nil { | 138 | if err != nil { |
| 139 | fmt.Fprintf(sink, "preparing the build cgroup: %v\n", err) | 139 | fmt.Fprintf(sink, "preparing the build cgroup: %v\n", err) |
| 140 | return false | 140 | return &failure{Reason: "preparing the build cgroup failed"} |
| 141 | } | 141 | } |
| 142 | cgroupFD = f | 142 | cgroupFD = f |
| 143 | defer f.Close() | 143 | defer f.Close() |
| @@ -179,22 +179,22 @@ func (r *runner) runStepsPodman(j job, dir string, env []string, sink io.Writer, | |||
| 179 | fmt.Fprintf(sink, "\nThis runner does not pull images. Ask an operator to provision %s "+ | 179 | fmt.Fprintf(sink, "\nThis runner does not pull images. Ask an operator to provision %s "+ |
| 180 | "on the runner host (podman pull, or podman build) before a job names it.\n", image) | 180 | "on the runner host (podman pull, or podman build) before a job names it.\n", image) |
| 181 | } | 181 | } |
| 182 | return false | 182 | return &failure{Reason: "starting the build container failed"} |
| 183 | } | 183 | } |
| 184 | defer exec.Command(podman, append(r.podmanGlobal(), "rm", "--force", name)...).Run() | 184 | defer exec.Command(podman, append(r.podmanGlobal(), "rm", "--force", name)...).Run() |
| 185 | 185 | ||
| 186 | for _, step := range j.Steps { | 186 | for i, step := range j.Steps { |
| 187 | fmt.Fprintf(sink, "$ %s\n", step) | 187 | fmt.Fprintf(sink, "$ %s\n", step) |
| 188 | cmd := exec.Command(podman, append(r.podmanGlobal(), "exec", "--workdir", "/workspace", name, "sh", "-c", step)...) | 188 | cmd := exec.Command(podman, append(r.podmanGlobal(), "exec", "--workdir", "/workspace", name, "sh", "-c", step)...) |
| 189 | cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + r.podmanHome()} | 189 | cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + r.podmanHome()} |
| 190 | intoCgroup(cmd, cgroupFD) | 190 | intoCgroup(cmd, cgroupFD) |
| 191 | cmd.Stdout, cmd.Stderr = sink, sink | 191 | cmd.Stdout, cmd.Stderr = sink, sink |
| 192 | if ok, why := runStep(cmd, deadline); !ok { | 192 | if ok, why := runStep(cmd, deadline); !ok { |
| 193 | fmt.Fprintf(sink, "%s\n", why) | 193 | fmt.Fprintf(sink, "step %d/%d failed: %s\n", i+1, len(j.Steps), why) |
| 194 | return false | 194 | return &failure{Step: i + 1, Reason: why} |
| 195 | } | 195 | } |
| 196 | } | 196 | } |
| 197 | return true | 197 | return nil |
| 198 | } | 198 | } |
| 199 | 199 | ||
| 200 | // podmanGlobal are the flags every podman invocation needs, before the | 200 | // podmanGlobal are the flags every podman invocation needs, before the |
cmd/gitbay-runner/main.go +33 −11
| @@ -11,6 +11,7 @@ package main | |||
| 11 | 11 | ||
| 12 | import ( | 12 | import ( |
| 13 | "encoding/json" | 13 | "encoding/json" |
| 14 | "errors" | ||
| 14 | "flag" | 15 | "flag" |
| 15 | "fmt" | 16 | "fmt" |
| 16 | "io" | 17 | "io" |
| @@ -274,11 +275,12 @@ func (r *runner) step() (bool, error) { | |||
| 274 | } | 275 | } |
| 275 | j := env.Data | 276 | j := env.Data |
| 276 | log.Printf("build %d: %s %s @ %.10s", j.ID, j.Repo, j.Job, j.SHA) | 277 | log.Printf("build %d: %s %s @ %.10s", j.ID, j.Repo, j.Job, j.SHA) |
| 277 | status := "failure" | 278 | f := r.run(j) |
| 278 | if r.run(j) { | 279 | status := "success" |
| 279 | status = "success" | 280 | if f != nil { |
| 281 | status = "failure" | ||
| 280 | } | 282 | } |
| 281 | if err := r.reportDone(j.ID, status); err != nil { | 283 | if err := r.reportDone(j.ID, status, f); err != nil { |
| 282 | return true, err | 284 | return true, err |
| 283 | } | 285 | } |
| 284 | log.Printf("build %d: %s", j.ID, status) | 286 | log.Printf("build %d: %s", j.ID, status) |
| @@ -315,16 +317,36 @@ func (s *logSink) broken() bool { | |||
| 315 | return s.w == nil | 317 | return s.w == nil |
| 316 | } | 318 | } |
| 317 | 319 | ||
| 320 | // failure says where a build stopped: Step is the 1-based step that | ||
| 321 | // failed, 0 when the build stopped before its first step (the clone, the | ||
| 322 | // container), and Reason is one short line (#266). | ||
| 323 | type failure struct { | ||
| 324 | Step int | ||
| 325 | Reason string | ||
| 326 | } | ||
| 327 | |||
| 328 | // exitReason is how a finished command's failure reads in a build's log | ||
| 329 | // and on the build: "exit 1" for a command that exited, the error | ||
| 330 | // otherwise (a signal, a start failure). | ||
| 331 | func exitReason(err error) string { | ||
| 332 | var ee *exec.ExitError | ||
| 333 | if errors.As(err, &ee) && ee.ExitCode() >= 0 { | ||
| 334 | return fmt.Sprintf("exit %d", ee.ExitCode()) | ||
| 335 | } | ||
| 336 | return err.Error() | ||
| 337 | } | ||
| 338 | |||
| 318 | // run clones, checks out, and executes the steps, streaming output to the | 339 | // run clones, checks out, and executes the steps, streaming output to the |
| 319 | // server. Returns whether every step succeeded. | 340 | // server. Returns nil when every step succeeded, else where the build |
| 320 | func (r *runner) run(j job) bool { | 341 | // stopped. |
| 342 | func (r *runner) run(j job) *failure { | ||
| 321 | dir := filepath.Join(r.workdir, fmt.Sprintf("build-%d", j.ID)) | 343 | dir := filepath.Join(r.workdir, fmt.Sprintf("build-%d", j.ID)) |
| 322 | defer os.RemoveAll(dir) | 344 | defer os.RemoveAll(dir) |
| 323 | 345 | ||
| 324 | home, doneHome, err := buildHome(r.workdir, j) | 346 | home, doneHome, err := buildHome(r.workdir, j) |
| 325 | if err != nil { | 347 | if err != nil { |
| 326 | log.Printf("build %d: build home: %v", j.ID, err) | 348 | log.Printf("build %d: build home: %v", j.ID, err) |
| 327 | return false | 349 | return &failure{Reason: "preparing the build home failed"} |
| 328 | } | 350 | } |
| 329 | defer doneHome() | 351 | defer doneHome() |
| 330 | 352 | ||
| @@ -333,13 +355,13 @@ func (r *runner) run(j job) bool { | |||
| 333 | pipe, err := logCmd.StdinPipe() | 355 | pipe, err := logCmd.StdinPipe() |
| 334 | if err != nil { | 356 | if err != nil { |
| 335 | log.Printf("build %d: log pipe: %v", j.ID, err) | 357 | log.Printf("build %d: log pipe: %v", j.ID, err) |
| 336 | return false | 358 | return &failure{Reason: "opening the log stream failed"} |
| 337 | } | 359 | } |
| 338 | sink := &logSink{w: pipe} | 360 | sink := &logSink{w: pipe} |
| 339 | logCmd.Stdout, logCmd.Stderr = io.Discard, io.Discard | 361 | logCmd.Stdout, logCmd.Stderr = io.Discard, io.Discard |
| 340 | if err := logCmd.Start(); err != nil { | 362 | if err := logCmd.Start(); err != nil { |
| 341 | log.Printf("build %d: log stream: %v", j.ID, err) | 363 | log.Printf("build %d: log stream: %v", j.ID, err) |
| 342 | return false | 364 | return &failure{Reason: "opening the log stream failed"} |
| 343 | } | 365 | } |
| 344 | // The server ends the log session with exit 3 when the build is | 366 | // The server ends the log session with exit 3 when the build is |
| 345 | // cancelled; any other end is a lost stream, which the sink absorbs. | 367 | // cancelled; any other end is a lost stream, which the sink absorbs. |
| @@ -384,7 +406,7 @@ func (r *runner) run(j job) bool { | |||
| 384 | select { | 406 | select { |
| 385 | case err := <-done: | 407 | case err := <-done: |
| 386 | if err != nil { | 408 | if err != nil { |
| 387 | return false, fmt.Sprintf("step failed: %v", err) | 409 | return false, exitReason(err) |
| 388 | } | 410 | } |
| 389 | return true, "" | 411 | return true, "" |
| 390 | case <-cancelled: | 412 | case <-cancelled: |
| @@ -425,7 +447,7 @@ func (r *runner) run(j job) bool { | |||
| 425 | cmd.Stdout, cmd.Stderr = sink, sink | 447 | cmd.Stdout, cmd.Stderr = sink, sink |
| 426 | if ok, why := runStep(cmd, deadline); !ok { | 448 | if ok, why := runStep(cmd, deadline); !ok { |
| 427 | fmt.Fprintf(sink, "git %s: %s\n", args[0], why) | 449 | fmt.Fprintf(sink, "git %s: %s\n", args[0], why) |
| 428 | return false | 450 | return &failure{Reason: "git " + args[0] + ": " + why} |
| 429 | } | 451 | } |
| 430 | } | 452 | } |
| 431 | 453 | ||
cmd/gitbay-runner/report.go +22 −2
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | "errors" | 4 | "errors" |
| 5 | "fmt" | 5 | "fmt" |
| 6 | "os/exec" | 6 | "os/exec" |
| 7 | "strconv" | ||
| 8 | "strings" | ||
| 7 | "time" | 9 | "time" |
| 8 | ) | 10 | ) |
| 9 | 11 | ||
| @@ -16,12 +18,30 @@ import ( | |||
| 16 | // Only a connection-level failure is retried — ssh exits 255 for those. | 18 | // Only a connection-level failure is retried — ssh exits 255 for those. |
| 17 | // Any other exit is the server's answer, and asking again would not | 19 | // Any other exit is the server's answer, and asking again would not |
| 18 | // change it. Four retries over about thirty seconds outlasts a restart. | 20 | // change it. Four retries over about thirty seconds outlasts a restart. |
| 19 | func (r *runner) reportDone(id int64, status string) error { | 21 | func (r *runner) reportDone(id int64, status string, f *failure) error { |
| 22 | args := doneArgs(id, status, f) | ||
| 20 | return reportWithRetry(func() (string, error) { | 23 | return reportWithRetry(func() (string, error) { |
| 21 | return r.ssh(nil, "runner", "done", fmt.Sprint(id), status) | 24 | return r.ssh(nil, args...) |
| 22 | }, id, retryDelays) | 25 | }, id, retryDelays) |
| 23 | } | 26 | } |
| 24 | 27 | ||
| 28 | // doneArgs is the runner done command for a build's outcome. The reason | ||
| 29 | // is single-quoted: ssh joins arguments with spaces, and the server | ||
| 30 | // splits the line again with POSIX rules. | ||
| 31 | func doneArgs(id int64, status string, f *failure) []string { | ||
| 32 | args := []string{"runner", "done", fmt.Sprint(id), status} | ||
| 33 | if f == nil { | ||
| 34 | return args | ||
| 35 | } | ||
| 36 | if f.Step > 0 { | ||
| 37 | args = append(args, "--step", strconv.Itoa(f.Step)) | ||
| 38 | } | ||
| 39 | if f.Reason != "" { | ||
| 40 | args = append(args, "--reason", "'"+strings.ReplaceAll(f.Reason, "'", `'\''`)+"'") | ||
| 41 | } | ||
| 42 | return args | ||
| 43 | } | ||
| 44 | |||
| 25 | var retryDelays = []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second} | 45 | var retryDelays = []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second} |
| 26 | 46 | ||
| 27 | func reportWithRetry(report func() (string, error), id int64, delays []time.Duration) error { | 47 | func reportWithRetry(report func() (string, error), id int64, delays []time.Duration) error { |
cmd/gitbay-runner/report_test.go +23
| @@ -3,8 +3,11 @@ package main | |||
| 3 | import ( | 3 | import ( |
| 4 | "errors" | 4 | "errors" |
| 5 | "os/exec" | 5 | "os/exec" |
| 6 | "strings" | ||
| 6 | "testing" | 7 | "testing" |
| 7 | "time" | 8 | "time" |
| 9 | |||
| 10 | "gitbay.org/gitbay/internal/protocol" | ||
| 8 | ) | 11 | ) |
| 9 | 12 | ||
| 10 | // exitErr fabricates the error ssh returns for a given exit status. | 13 | // exitErr fabricates the error ssh returns for a given exit status. |
| @@ -61,3 +64,23 @@ func TestReportGivesUp(t *testing.T) { | |||
| 61 | t.Fatalf("err=%v calls=%d, want three attempts then an error", err, calls) | 64 | t.Fatalf("err=%v calls=%d, want three attempts then an error", err, calls) |
| 62 | } | 65 | } |
| 63 | } | 66 | } |
| 67 | |||
| 68 | // The reason survives the trip: ssh joins arguments with spaces and the | ||
| 69 | // server splits the line again with POSIX rules (#266). | ||
| 70 | func TestDoneArgsNameTheFailedStep(t *testing.T) { | ||
| 71 | got := doneArgs(7, "failure", &failure{Step: 3, Reason: "can't: exit 1"}) | ||
| 72 | argv, err := protocol.Tokenize(strings.Join(got, " ")) | ||
| 73 | if err != nil { | ||
| 74 | t.Fatal(err) | ||
| 75 | } | ||
| 76 | want := []string{"runner", "done", "7", "failure", "--step", "3", "--reason", "can't: exit 1"} | ||
| 77 | if strings.Join(argv, "|") != strings.Join(want, "|") { | ||
| 78 | t.Fatalf("server reads %q, want %q", argv, want) | ||
| 79 | } | ||
| 80 | if got := doneArgs(7, "success", nil); strings.Join(got, " ") != "runner done 7 success" { | ||
| 81 | t.Fatalf("success: %q", got) | ||
| 82 | } | ||
| 83 | if got := doneArgs(7, "failure", &failure{Reason: "git clone: exit 128"}); strings.Contains(strings.Join(got, " "), "--step") { | ||
| 84 | t.Fatalf("a failure before any step sent a step: %q", got) | ||
| 85 | } | ||
| 86 | } | ||
cmd/gitbay-runner/steps_test.go added +33
| @@ -0,0 +1,33 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "os/exec" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | "time" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // The failing step is named by number in the log and in the outcome | ||
| 12 | // reported to the server (#266). | ||
| 13 | func TestRunStepsNamesTheFailedStep(t *testing.T) { | ||
| 14 | r := &runner{isolation: isolationNone} | ||
| 15 | run := func(cmd *exec.Cmd, _ time.Time) (bool, string) { | ||
| 16 | if err := cmd.Run(); err != nil { | ||
| 17 | return false, exitReason(err) | ||
| 18 | } | ||
| 19 | return true, "" | ||
| 20 | } | ||
| 21 | env := []string{"PATH=" + os.Getenv("PATH")} | ||
| 22 | var log strings.Builder | ||
| 23 | f := r.runSteps(job{Steps: []string{"true", "exit 3", "true"}}, t.TempDir(), env, &log, time.Now().Add(time.Minute), run) | ||
| 24 | if f == nil || f.Step != 2 || f.Reason != "exit 3" { | ||
| 25 | t.Fatalf("failure %+v, want step 2, exit 3", f) | ||
| 26 | } | ||
| 27 | if !strings.Contains(log.String(), "step 2/3 failed: exit 3\n") { | ||
| 28 | t.Fatalf("log does not name the step:\n%s", log.String()) | ||
| 29 | } | ||
| 30 | if f := r.runSteps(job{Steps: []string{"true"}}, t.TempDir(), env, &log, time.Now().Add(time.Minute), run); f != nil { | ||
| 31 | t.Fatalf("a passing job failed: %+v", f) | ||
| 32 | } | ||
| 33 | } | ||
e2e/ci_test.go +11 −1
| @@ -144,9 +144,19 @@ func TestCI(t *testing.T) { | |||
| 144 | t.Fatalf("ok log:\n%s", out) | 144 | t.Fatalf("ok log:\n%s", out) |
| 145 | } | 145 | } |
| 146 | out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN) | 146 | out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN) |
| 147 | if !strings.Contains(out, "step failed") { | 147 | if !strings.Contains(out, "step 1/1 failed: exit 1") { |
| 148 | t.Fatalf("broken log:\n%s", out) | 148 | t.Fatalf("broken log:\n%s", out) |
| 149 | } | 149 | } |
| 150 | out, _, _ = inst.ssh(t, aliceKey, "", "build", "show", "alice/app", brokenN) | ||
| 151 | if !strings.Contains(out, "1/1 false (exit 1)") { | ||
| 152 | t.Fatalf("build show does not name the failed step:\n%s", out) | ||
| 153 | } | ||
| 154 | if out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN, "--step", "failed"); strings.Contains(out, "git clone") || !strings.Contains(out, "exit 1") { | ||
| 155 | t.Fatalf("build log --step failed:\n%s", out) | ||
| 156 | } | ||
| 157 | if _, body := inst.get(t, "/alice/app/builds/"+brokenN); !strings.Contains(body, `id="failed" open`) { | ||
| 158 | t.Fatalf("build page does not open the failed step:\n%s", body) | ||
| 159 | } | ||
| 150 | // Statuses resolved, with target URLs pointing at the build pages. | 160 | // Statuses resolved, with target URLs pointing at the build pages. |
| 151 | out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha, "--json") | 161 | out, _, _ = inst.ssh(t, aliceKey, "", "status", "list", "alice/app", sha, "--json") |
| 152 | if !strings.Contains(out, `"ci/ok","state":"success"`) && !strings.Contains(out, `"state":"success"`) { | 162 | if !strings.Contains(out, `"ci/ok","state":"success"`) && !strings.Contains(out, `"state":"success"`) { |
internal/control/build.go +117 −17
| @@ -40,11 +40,13 @@ func init() { | |||
| 40 | ReadOnly: true, Run: runBuildShow}) | 40 | ReadOnly: true, Run: runBuildShow}) |
| 41 | register(Command{Path: []string{"build", "log"}, | 41 | register(Command{Path: []string{"build", "log"}, |
| 42 | Summary: "print a build's log, or follow it until the build ends", | 42 | Summary: "print a build's log, or follow it until the build ends", |
| 43 | Usage: "build log <owner/name> <n> [--follow]", | 43 | Usage: "build log <owner/name> <n> [--follow] [--step <step>|failed] [--tail <lines>]", |
| 44 | Flags: []Flag{ | 44 | Flags: []Flag{ |
| 45 | {"--follow", "", "stream the log until the build ends", ""}, | 45 | {"--follow", "", "stream the log until the build ends", ""}, |
| 46 | {"--step", "<step>|failed", "only one step's output: 0 for the setup, a step number, or the one that failed", ""}, | ||
| 47 | {"--tail", "<lines>", "only the last lines", ""}, | ||
| 46 | }, | 48 | }, |
| 47 | Examples: []string{"build log krz/gitbay 431 --follow"}, | 49 | Examples: []string{"build log krz/gitbay 431 --follow", "build log krz/gitbay 431 --step failed --tail 40"}, |
| 48 | ReadOnly: true, Run: runBuildLog}) | 50 | ReadOnly: true, Run: runBuildLog}) |
| 49 | 51 | ||
| 50 | register(Command{Path: []string{"build", "jobs"}, | 52 | register(Command{Path: []string{"build", "jobs"}, |
| @@ -101,9 +103,13 @@ func init() { | |||
| 101 | Examples: []string{"runner log 431"}, | 103 | Examples: []string{"runner log 431"}, |
| 102 | ReadsStdin: true, Run: runRunnerLog}) | 104 | ReadsStdin: true, Run: runRunnerLog}) |
| 103 | register(Command{Path: []string{"runner", "done"}, | 105 | register(Command{Path: []string{"runner", "done"}, |
| 104 | Summary: "finish a build", | 106 | Summary: "finish a build", |
| 105 | Usage: "runner done <build-id> success|failure", | 107 | Usage: "runner done <build-id> success|failure [--step <n>] [--reason <text>]", |
| 106 | Examples: []string{"runner done 431 success"}, | 108 | Flags: []Flag{ |
| 109 | {"--step", "<n>", "the 1-based step a failed build stopped at", ""}, | ||
| 110 | {"--reason", "<text>", "how it failed, one line", ""}, | ||
| 111 | }, | ||
| 112 | Examples: []string{"runner done 431 success", "runner done 431 failure --step 3 --reason 'exit 1'"}, | ||
| 107 | Run: runRunnerDone}) | 113 | Run: runRunnerDone}) |
| 108 | } | 114 | } |
| 109 | 115 | ||
| @@ -119,11 +125,22 @@ type BuildOut struct { | |||
| 119 | // names what it ran on rather than only its sha (#241). It is empty | 125 | // names what it ran on rather than only its sha (#241). It is empty |
| 120 | // when the commit is no longer in the repository. | 126 | // when the commit is no longer in the repository. |
| 121 | Subject string `json:"subject,omitempty"` | 127 | Subject string `json:"subject,omitempty"` |
| 128 | // FailedStep is the 1-based step a failed build stopped at, 0 when | ||
| 129 | // none; FailedReason says how ("exit 1") (#266). | ||
| 130 | FailedStep int `json:"failed_step,omitempty"` | ||
| 131 | FailedReason string `json:"failed_reason,omitempty"` | ||
| 132 | // DurationS is how long the build ran, once it has a start and a | ||
| 133 | // finish. | ||
| 134 | DurationS int64 `json:"duration_s,omitempty"` | ||
| 135 | // Steps are the job's commands; build show only. | ||
| 136 | Steps []string `json:"steps,omitempty"` | ||
| 122 | } | 137 | } |
| 123 | 138 | ||
| 124 | func buildToOut(b store.Build) BuildOut { | 139 | func buildToOut(b store.Build) BuildOut { |
| 125 | return BuildOut{Number: b.Number, Job: b.Job, Status: b.Status, SHA: b.SHA, | 140 | return BuildOut{Number: b.Number, Job: b.Job, Status: b.Status, SHA: b.SHA, |
| 126 | Ref: b.Ref, CreatedAt: b.CreatedAt, FinishedAt: b.FinishedAt} | 141 | Ref: b.Ref, CreatedAt: b.CreatedAt, FinishedAt: b.FinishedAt, |
| 142 | FailedStep: b.FailedStep, FailedReason: b.FailedReason, | ||
| 143 | DurationS: int64(b.Elapsed() / time.Second)} | ||
| 127 | } | 144 | } |
| 128 | 145 | ||
| 129 | func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) { | 146 | func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) { |
| @@ -225,7 +242,22 @@ func runBuildShow(c *Ctx, args []string) int { | |||
| 225 | return code | 242 | return code |
| 226 | } | 243 | } |
| 227 | d := buildToOut(b) | 244 | d := buildToOut(b) |
| 245 | json.Unmarshal([]byte(b.Steps), &d.Steps) | ||
| 228 | return c.emit(d, func(w io.Writer) { | 246 | return c.emit(d, func(w io.Writer) { |
| 247 | failedStep, failed := "", "" | ||
| 248 | if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) { | ||
| 249 | step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n") | ||
| 250 | failedStep = fmt.Sprintf("%d/%d %s", d.FailedStep, len(d.Steps), step) | ||
| 251 | if d.FailedReason != "" { | ||
| 252 | failedStep += " (" + d.FailedReason + ")" | ||
| 253 | } | ||
| 254 | } else { | ||
| 255 | failed = d.FailedReason | ||
| 256 | } | ||
| 257 | duration := "" | ||
| 258 | if d.DurationS > 0 { | ||
| 259 | duration = (time.Duration(d.DurationS) * time.Second).String() | ||
| 260 | } | ||
| 229 | v := c.view(w) | 261 | v := c.view(w) |
| 230 | v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status) | 262 | v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status) |
| 231 | v.fields( | 263 | v.fields( |
| @@ -233,13 +265,16 @@ func runBuildShow(c *Ctx, args []string) int { | |||
| 233 | "ref", d.Ref, | 265 | "ref", d.Ref, |
| 234 | "queued", c.when(d.CreatedAt), | 266 | "queued", c.when(d.CreatedAt), |
| 235 | "finished", c.when(d.FinishedAt), | 267 | "finished", c.when(d.FinishedAt), |
| 268 | "duration", duration, | ||
| 269 | "failed step", failedStep, | ||
| 270 | "failed", failed, | ||
| 236 | "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)), | 271 | "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)), |
| 237 | ) | 272 | ) |
| 238 | }) | 273 | }) |
| 239 | } | 274 | } |
| 240 | 275 | ||
| 241 | func runBuildLog(c *Ctx, args []string) int { | 276 | func runBuildLog(c *Ctx, args []string) int { |
| 242 | f, err := c.parseArgs(args, flagSpec{Bools: []string{"--follow"}, MaxPos: 2, Usage: c.Cmd.Usage}) | 277 | f, err := c.parseArgs(args, flagSpec{Bools: []string{"--follow"}, Values: []string{"--step", "--tail"}, MaxPos: 2, Usage: c.Cmd.Usage}) |
| 243 | if err != nil { | 278 | if err != nil { |
| 244 | return c.fail(protocol.ExitUsage, "%v", err) | 279 | return c.fail(protocol.ExitUsage, "%v", err) |
| 245 | } | 280 | } |
| @@ -248,12 +283,49 @@ func runBuildLog(c *Ctx, args []string) int { | |||
| 248 | return code | 283 | return code |
| 249 | } | 284 | } |
| 250 | if f.Has("--follow") { | 285 | if f.Has("--follow") { |
| 286 | if f.Has("--step") || f.Has("--tail") { | ||
| 287 | return c.fail(protocol.ExitUsage, "--step and --tail read the stored log; drop --follow") | ||
| 288 | } | ||
| 251 | return followBuildLog(c, repo, b) | 289 | return followBuildLog(c, repo, b) |
| 252 | } | 290 | } |
| 291 | tail := 0 | ||
| 292 | if f.Has("--tail") { | ||
| 293 | if tail, err = strconv.Atoi(f.Value("--tail")); err != nil || tail < 1 { | ||
| 294 | return c.fail(protocol.ExitUsage, "--tail takes a number of lines, 1 or more") | ||
| 295 | } | ||
| 296 | } | ||
| 253 | log, err := c.Store.BuildLog(b.ID) | 297 | log, err := c.Store.BuildLog(b.ID) |
| 254 | if err != nil { | 298 | if err != nil { |
| 255 | return c.fail(protocol.ExitFailure, "%v", err) | 299 | return c.fail(protocol.ExitFailure, "%v", err) |
| 256 | } | 300 | } |
| 301 | if f.Has("--step") { | ||
| 302 | var steps []string | ||
| 303 | json.Unmarshal([]byte(b.Steps), &steps) | ||
| 304 | sections := SplitBuildLog(string(log), steps) | ||
| 305 | at := -1 | ||
| 306 | if want := f.Value("--step"); want == "failed" { | ||
| 307 | if at = FailedSection(sections, b.Status, b.FailedStep); at < 0 { | ||
| 308 | return c.fail(protocol.ExitNotFound, "build %d did not fail", b.Number) | ||
| 309 | } | ||
| 310 | } else { | ||
| 311 | n, err := strconv.Atoi(want) | ||
| 312 | if err != nil || n < 0 || n > len(steps) { | ||
| 313 | return c.fail(protocol.ExitUsage, "--step takes 0 (the setup) to %d, or failed", len(steps)) | ||
| 314 | } | ||
| 315 | for i, s := range sections { | ||
| 316 | if s.N == n { | ||
| 317 | at = i | ||
| 318 | } | ||
| 319 | } | ||
| 320 | if at < 0 { | ||
| 321 | return c.fail(protocol.ExitNotFound, "build %d has no output for step %d", b.Number, n) | ||
| 322 | } | ||
| 323 | } | ||
| 324 | log = []byte(sections[at].Text) | ||
| 325 | } | ||
| 326 | if tail > 0 { | ||
| 327 | log = tailLines(log, tail) | ||
| 328 | } | ||
| 257 | c.Stdout.Write(log) | 329 | c.Stdout.Write(log) |
| 258 | return protocol.ExitOK | 330 | return protocol.ExitOK |
| 259 | } | 331 | } |
| @@ -675,12 +747,17 @@ func runRunnerDone(c *Ctx, args []string) int { | |||
| 675 | if code >= 0 { | 747 | if code >= 0 { |
| 676 | return code | 748 | return code |
| 677 | } | 749 | } |
| 678 | if len(args) != 2 || (args[1] != "success" && args[1] != "failure") { | 750 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--step", "--reason"}, MaxPos: 2, Usage: c.Cmd.Usage}) |
| 751 | if err != nil { | ||
| 752 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 753 | } | ||
| 754 | if len(f.Pos) != 2 || (f.Pos[1] != "success" && f.Pos[1] != "failure") { | ||
| 679 | return c.usage() | 755 | return c.usage() |
| 680 | } | 756 | } |
| 681 | id, err := strconv.ParseInt(args[0], 10, 64) | 757 | outcome := f.Pos[1] |
| 758 | id, err := strconv.ParseInt(f.Pos[0], 10, 64) | ||
| 682 | if err != nil { | 759 | if err != nil { |
| 683 | return c.fail(protocol.ExitUsage, "bad build id %q", args[0]) | 760 | return c.fail(protocol.ExitUsage, "bad build id %q", f.Pos[0]) |
| 684 | } | 761 | } |
| 685 | b, err := c.Store.BuildByID(id) | 762 | b, err := c.Store.BuildByID(id) |
| 686 | if err != nil { | 763 | if err != nil { |
| @@ -699,7 +776,20 @@ func runRunnerDone(c *Ctx, args []string) int { | |||
| 699 | fmt.Fprintf(w, "build %d was cancelled\n", b.Number) | 776 | fmt.Fprintf(w, "build %d was cancelled\n", b.Number) |
| 700 | }) | 777 | }) |
| 701 | } | 778 | } |
| 702 | if err := c.Store.FinishBuild(id, args[1]); err != nil { | 779 | if outcome == "failure" { |
| 780 | // A step the job does not have is recorded as none rather than | ||
| 781 | // refused: refusing would lose the outcome over a detail (#266). | ||
| 782 | var steps []string | ||
| 783 | json.Unmarshal([]byte(b.Steps), &steps) | ||
| 784 | step, _ := strconv.Atoi(f.Value("--step")) | ||
| 785 | if step < 0 || step > len(steps) { | ||
| 786 | step = 0 | ||
| 787 | } | ||
| 788 | if err := c.Store.SetBuildFailure(id, step, failureReason(f.Value("--reason"))); err != nil && !errors.Is(err, store.ErrNotFound) { | ||
| 789 | return c.fail(protocol.ExitFailure, "recording build %d's failure: %v", id, err) | ||
| 790 | } | ||
| 791 | } | ||
| 792 | if err := c.Store.FinishBuild(id, outcome); err != nil { | ||
| 703 | return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err) | 793 | return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err) |
| 704 | } | 794 | } |
| 705 | c.Store.RunnerDone(key.ID) | 795 | c.Store.RunnerDone(key.ID) |
| @@ -708,15 +798,15 @@ func runRunnerDone(c *Ctx, args []string) int { | |||
| 708 | return c.fail(protocol.ExitFailure, "%v", err) | 798 | return c.fail(protocol.ExitFailure, "%v", err) |
| 709 | } | 799 | } |
| 710 | url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number) | 800 | url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number) |
| 711 | desc := "build " + args[1] | 801 | desc := "build " + outcome |
| 712 | if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, args[1], desc, url, c.User.ID); err != nil { | 802 | if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, outcome, desc, url, c.User.ID); err != nil { |
| 713 | return c.fail(protocol.ExitFailure, "%v", err) | 803 | return c.fail(protocol.ExitFailure, "%v", err) |
| 714 | } | 804 | } |
| 715 | c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1], | 805 | c.Store.RecordEvent(repo.ID, c.User.ID, "build."+outcome, |
| 716 | fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA)) | 806 | fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA)) |
| 717 | // A red build mails the repo's notify targets with the log tail — a | 807 | // A red build mails the repo's notify targets with the log tail — a |
| 718 | // failed scheduled job must not wait to be noticed. | 808 | // failed scheduled job must not wait to be noticed. |
| 719 | if args[1] == "failure" { | 809 | if outcome == "failure" { |
| 720 | if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { | 810 | if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { |
| 721 | tail := "" | 811 | tail := "" |
| 722 | if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 { | 812 | if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 { |
| @@ -732,11 +822,21 @@ func runRunnerDone(c *Ctx, args []string) int { | |||
| 732 | path: fmt.Sprintf("%s/builds/%d", repo.Path(), b.Number)}) | 822 | path: fmt.Sprintf("%s/builds/%d", repo.Path(), b.Number)}) |
| 733 | } | 823 | } |
| 734 | } | 824 | } |
| 735 | return c.emit(map[string]any{"build": b.Number, "status": args[1]}, func(w io.Writer) { | 825 | return c.emit(map[string]any{"build": b.Number, "status": outcome}, func(w io.Writer) { |
| 736 | fmt.Fprintf(w, "build %d %s\n", b.Number, args[1]) | 826 | fmt.Fprintf(w, "build %d %s\n", b.Number, outcome) |
| 737 | }) | 827 | }) |
| 738 | } | 828 | } |
| 739 | 829 | ||
| 830 | // failureReason keeps a runner's reason to one line of at most 200 | ||
| 831 | // bytes: it is shown on the build page and by build show. | ||
| 832 | func failureReason(s string) string { | ||
| 833 | s = strings.Join(strings.Fields(s), " ") | ||
| 834 | if len(s) > 200 { | ||
| 835 | s = s[:200] | ||
| 836 | } | ||
| 837 | return strings.ToValidUTF8(s, "") | ||
| 838 | } | ||
| 839 | |||
| 740 | // QueueBranchBuilds reads .gitbay/ci.yml at sha and creates one pending | 840 | // QueueBranchBuilds reads .gitbay/ci.yml at sha and creates one pending |
| 741 | // build per push job, with a pending commit status the runner resolves. | 841 | // build per push job, with a pending commit status the runner resolves. |
| 742 | // A broken config surfaces as a failed "ci/config" status, not silence. | 842 | // A broken config surfaces as a failed "ci/config" status, not silence. |
internal/control/buildlog.go added +91
| @@ -0,0 +1,91 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import "strings" | ||
| 4 | |||
| 5 | // LogSection is one part of a build log: the setup before the first | ||
| 6 | // step (N 0), or one step and its output. | ||
| 7 | type LogSection struct { | ||
| 8 | N int // 0 for the setup, else the 1-based step | ||
| 9 | Step string // the step's command; "" for the setup | ||
| 10 | Text string | ||
| 11 | } | ||
| 12 | |||
| 13 | // SplitBuildLog cuts a log at the "$ <step>" line the runner writes | ||
| 14 | // before each step, matching the build's steps in order and only at a | ||
| 15 | // line start. Output before the first step is the setup section, left | ||
| 16 | // out when empty. A step with no line in the log — the build stopped | ||
| 17 | // before it — has no section, and neither has any step after it. | ||
| 18 | func SplitBuildLog(log string, steps []string) []LogSection { | ||
| 19 | var out []LogSection | ||
| 20 | cur := LogSection{} | ||
| 21 | start := 0 | ||
| 22 | for i, step := range steps { | ||
| 23 | marker := "$ " + step + "\n" | ||
| 24 | at := findLine(log, marker, start) | ||
| 25 | if at < 0 { | ||
| 26 | break | ||
| 27 | } | ||
| 28 | cur.Text = log[start:at] | ||
| 29 | if cur.N > 0 || cur.Text != "" { | ||
| 30 | out = append(out, cur) | ||
| 31 | } | ||
| 32 | cur = LogSection{N: i + 1, Step: step} | ||
| 33 | start = at + len(marker) | ||
| 34 | } | ||
| 35 | cur.Text = log[start:] | ||
| 36 | if cur.N > 0 || cur.Text != "" { | ||
| 37 | out = append(out, cur) | ||
| 38 | } | ||
| 39 | return out | ||
| 40 | } | ||
| 41 | |||
| 42 | // findLine is the index of line in log at or after from where it starts | ||
| 43 | // a line, or -1. | ||
| 44 | func findLine(log, line string, from int) int { | ||
| 45 | for i := from; i <= len(log)-len(line); { | ||
| 46 | j := strings.Index(log[i:], line) | ||
| 47 | if j < 0 { | ||
| 48 | return -1 | ||
| 49 | } | ||
| 50 | at := i + j | ||
| 51 | if at == 0 || log[at-1] == '\n' { | ||
| 52 | return at | ||
| 53 | } | ||
| 54 | i = at + 1 | ||
| 55 | } | ||
| 56 | return -1 | ||
| 57 | } | ||
| 58 | |||
| 59 | // FailedSection is the index of the section a failed build stopped in: | ||
| 60 | // the step the runner named, or the last section when it named none (an | ||
| 61 | // older runner, or a failure the runner could not tie to a step). -1 | ||
| 62 | // when the build did not fail or its log is empty. | ||
| 63 | func FailedSection(sections []LogSection, status string, failedStep int) int { | ||
| 64 | if status != "failure" || len(sections) == 0 { | ||
| 65 | return -1 | ||
| 66 | } | ||
| 67 | for i, s := range sections { | ||
| 68 | if failedStep > 0 && s.N == failedStep { | ||
| 69 | return i | ||
| 70 | } | ||
| 71 | } | ||
| 72 | return len(sections) - 1 | ||
| 73 | } | ||
| 74 | |||
| 75 | // tailLines is the last n lines of b; a final newline ends the last line | ||
| 76 | // rather than starting another. | ||
| 77 | func tailLines(b []byte, n int) []byte { | ||
| 78 | end := len(b) | ||
| 79 | if end > 0 && b[end-1] == '\n' { | ||
| 80 | end-- | ||
| 81 | } | ||
| 82 | for i := end - 1; i >= 0; i-- { | ||
| 83 | if b[i] == '\n' { | ||
| 84 | n-- | ||
| 85 | if n == 0 { | ||
| 86 | return b[i+1:] | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | return b | ||
| 91 | } | ||
internal/control/buildlog_test.go added +131
| @@ -0,0 +1,131 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "fmt" | ||
| 6 | "reflect" | ||
| 7 | "regexp" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | |||
| 11 | "gitbay.org/gitbay/internal/protocol" | ||
| 12 | "gitbay.org/gitbay/internal/store" | ||
| 13 | ) | ||
| 14 | |||
| 15 | func TestSplitBuildLog(t *testing.T) { | ||
| 16 | log := "$ git clone ssh://x/a.git (abc)\n" + | ||
| 17 | "$ go build ./...\n" + | ||
| 18 | "built\n" + | ||
| 19 | "$ go test ./...\n" + | ||
| 20 | "--- FAIL: TestX\n" + | ||
| 21 | "step 2/2 failed: exit 1\n" | ||
| 22 | got := SplitBuildLog(log, []string{"go build ./...", "go test ./..."}) | ||
| 23 | want := []LogSection{ | ||
| 24 | {N: 0, Text: "$ git clone ssh://x/a.git (abc)\n"}, | ||
| 25 | {N: 1, Step: "go build ./...", Text: "built\n"}, | ||
| 26 | {N: 2, Step: "go test ./...", Text: "--- FAIL: TestX\nstep 2/2 failed: exit 1\n"}, | ||
| 27 | } | ||
| 28 | if !reflect.DeepEqual(got, want) { | ||
| 29 | t.Fatalf("got %+v\nwant %+v", got, want) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | // A step's line inside other output, not at a line start, does not cut; | ||
| 34 | // a build that stopped before a step has no section for it; an empty | ||
| 35 | // setup is left out. | ||
| 36 | func TestSplitBuildLogStopsAtMissingStep(t *testing.T) { | ||
| 37 | got := SplitBuildLog("$ make\nrunning: $ make test\nerror\n", []string{"make", "make test"}) | ||
| 38 | want := []LogSection{{N: 1, Step: "make", Text: "running: $ make test\nerror\n"}} | ||
| 39 | if !reflect.DeepEqual(got, want) { | ||
| 40 | t.Fatalf("got %+v\nwant %+v", got, want) | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | func TestTailLines(t *testing.T) { | ||
| 45 | for _, tc := range []struct { | ||
| 46 | in string | ||
| 47 | n int | ||
| 48 | want string | ||
| 49 | }{ | ||
| 50 | {"a\nb\nc\n", 2, "b\nc\n"}, | ||
| 51 | {"a\nb\nc\n", 5, "a\nb\nc\n"}, | ||
| 52 | {"a\nb", 1, "b"}, | ||
| 53 | } { | ||
| 54 | if got := string(tailLines([]byte(tc.in), tc.n)); got != tc.want { | ||
| 55 | t.Errorf("tailLines(%q, %d) = %q, want %q", tc.in, tc.n, got, tc.want) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | // failedBuild is a finished failure whose second of two steps failed, | ||
| 61 | // having run 10m56s. | ||
| 62 | func failedBuild(t *testing.T) (*store.Store, store.Repo, int64, int64) { | ||
| 63 | t.Helper() | ||
| 64 | st, repo, uid := newQueueTestRepo(t) | ||
| 65 | n, err := st.CreateBuild(repo.ID, "unit", "abc", "main", `["go build ./...","go test ./..."]`, "", "", true) | ||
| 66 | if err != nil { | ||
| 67 | t.Fatal(err) | ||
| 68 | } | ||
| 69 | b, ok, err := st.ClaimBuild([]int64{repo.ID}, false) | ||
| 70 | if err != nil || !ok { | ||
| 71 | t.Fatalf("claim: ok=%v err=%v", ok, err) | ||
| 72 | } | ||
| 73 | st.AppendBuildLog(b.ID, []byte("$ git clone x (abc)\n$ go build ./...\nok\n$ go test ./...\none\n--- FAIL: TestX\nstep 2/2 failed: exit 1\n")) | ||
| 74 | if err := st.SetBuildFailure(b.ID, 2, "exit 1"); err != nil { | ||
| 75 | t.Fatal(err) | ||
| 76 | } | ||
| 77 | if err := st.FinishBuild(b.ID, "failure"); err != nil { | ||
| 78 | t.Fatal(err) | ||
| 79 | } | ||
| 80 | if _, err := st.DB.Exec(`UPDATE builds SET started_at = '2026-09-27T10:00:00Z', finished_at = '2026-09-27T10:10:56Z' WHERE id = ?`, b.ID); err != nil { | ||
| 81 | t.Fatal(err) | ||
| 82 | } | ||
| 83 | return st, repo, uid, n | ||
| 84 | } | ||
| 85 | |||
| 86 | func TestBuildLogStepAndTail(t *testing.T) { | ||
| 87 | st, repo, uid, n := failedBuild(t) | ||
| 88 | run := func(args ...string) (string, int) { | ||
| 89 | t.Helper() | ||
| 90 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid}) | ||
| 91 | code := Dispatch(c, append([]string{"build", "log", repo.Path(), fmt.Sprint(n)}, args...)) | ||
| 92 | return c.Stdout.(*bytes.Buffer).String() + errOut.String(), code | ||
| 93 | } | ||
| 94 | if out, _ := run("--step", "1"); out != "ok\n" { | ||
| 95 | t.Errorf("--step 1: %q", out) | ||
| 96 | } | ||
| 97 | if out, _ := run("--step", "failed", "--tail", "2"); out != "--- FAIL: TestX\nstep 2/2 failed: exit 1\n" { | ||
| 98 | t.Errorf("--step failed --tail 2: %q", out) | ||
| 99 | } | ||
| 100 | if out, _ := run("--tail", "1"); out != "step 2/2 failed: exit 1\n" { | ||
| 101 | t.Errorf("--tail 1: %q", out) | ||
| 102 | } | ||
| 103 | if _, code := run("--step", "3"); code != protocol.ExitUsage { | ||
| 104 | t.Errorf("--step past the job: exit %d", code) | ||
| 105 | } | ||
| 106 | if _, code := run("--follow", "--tail", "1"); code != protocol.ExitUsage { | ||
| 107 | t.Errorf("--follow with --tail: exit %d", code) | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | func TestBuildShowNamesFailedStepAndDuration(t *testing.T) { | ||
| 112 | st, repo, uid, n := failedBuild(t) | ||
| 113 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid}) | ||
| 114 | if code := Dispatch(c, []string{"build", "show", repo.Path(), fmt.Sprint(n)}); code != protocol.ExitOK { | ||
| 115 | t.Fatalf("exit %d: %s", code, errOut) | ||
| 116 | } | ||
| 117 | out := c.Stdout.(*bytes.Buffer).String() | ||
| 118 | for _, re := range []string{`failed step\s+2/2 go test \./\.\.\. \(exit 1\)`, `duration\s+10m56s`} { | ||
| 119 | if !regexp.MustCompile(re).MatchString(out) { | ||
| 120 | t.Errorf("build show missing %s:\n%s", re, out) | ||
| 121 | } | ||
| 122 | } | ||
| 123 | c, _ = pruneCtx(st, t.TempDir(), store.User{ID: uid}) | ||
| 124 | c.JSON = true | ||
| 125 | Dispatch(c, []string{"build", "show", repo.Path(), fmt.Sprint(n)}) | ||
| 126 | for _, want := range []string{`"failed_step":2`, `"failed_reason":"exit 1"`, `"duration_s":656`, `"steps":["go build ./...","go test ./..."]`} { | ||
| 127 | if !strings.Contains(c.Stdout.(*bytes.Buffer).String(), want) { | ||
| 128 | t.Errorf("build show --json missing %s", want) | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
internal/control/runnernext_test.go +41
| @@ -295,3 +295,44 @@ func TestRunnerNextCarriesPublicSSH(t *testing.T) { | |||
| 295 | } | 295 | } |
| 296 | } | 296 | } |
| 297 | } | 297 | } |
| 298 | |||
| 299 | // runner done records the failed step and a one-line reason (#266). | ||
| 300 | func TestRunnerDoneRecordsFailedStep(t *testing.T) { | ||
| 301 | st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t) | ||
| 302 | n, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", `["go build ./...","go test ./..."]`, "", "", true) | ||
| 303 | if err != nil { | ||
| 304 | t.Fatal(err) | ||
| 305 | } | ||
| 306 | b, ok, err := st.ClaimBuild([]int64{repo.ID}, false) | ||
| 307 | if err != nil || !ok { | ||
| 308 | t.Fatalf("claim: ok=%v err=%v", ok, err) | ||
| 309 | } | ||
| 310 | c, out := runnerCtx(st, uid, root) | ||
| 311 | if code := runRunnerDone(c, []string{fmt.Sprint(b.ID), "failure", "--step", "2", "--reason", "exit 1\n"}); code != protocol.ExitOK { | ||
| 312 | t.Fatalf("runner done: exit %d\n%s", code, out.String()) | ||
| 313 | } | ||
| 314 | got, _ := st.BuildByNumber(repo.ID, n) | ||
| 315 | if got.Status != "failure" || got.FailedStep != 2 || got.FailedReason != "exit 1" { | ||
| 316 | t.Fatalf("status %s step %d reason %q", got.Status, got.FailedStep, got.FailedReason) | ||
| 317 | } | ||
| 318 | } | ||
| 319 | |||
| 320 | // A report with no flags — an older runner — or with a step past the | ||
| 321 | // job's still finishes the build; the step is then recorded as 0. | ||
| 322 | func TestRunnerDoneToleratesMissingOrBadStep(t *testing.T) { | ||
| 323 | st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t) | ||
| 324 | for _, extra := range [][]string{nil, {"--step", "9"}} { | ||
| 325 | n, _ := st.CreateBuild(repo.ID, "unit", baseSHA, "main", `["true"]`, "", "", true) | ||
| 326 | b, ok, err := st.ClaimBuild([]int64{repo.ID}, false) | ||
| 327 | if err != nil || !ok { | ||
| 328 | t.Fatalf("claim: ok=%v err=%v", ok, err) | ||
| 329 | } | ||
| 330 | c, out := runnerCtx(st, uid, root) | ||
| 331 | if code := runRunnerDone(c, append([]string{fmt.Sprint(b.ID), "failure"}, extra...)); code != protocol.ExitOK { | ||
| 332 | t.Fatalf("runner done %v: exit %d\n%s", extra, code, out.String()) | ||
| 333 | } | ||
| 334 | if got, _ := st.BuildByNumber(repo.ID, n); got.Status != "failure" || got.FailedStep != 0 { | ||
| 335 | t.Fatalf("%v: status %s step %d", extra, got.Status, got.FailedStep) | ||
| 336 | } | ||
| 337 | } | ||
| 338 | } | ||
internal/httpd/buildpages_test.go +56
| @@ -198,3 +198,59 @@ func renderBuilds(t *testing.T, builds []control.BuildOut, filter buildFilter, o | |||
| 198 | } | 198 | } |
| 199 | return sb.String() | 199 | return sb.String() |
| 200 | } | 200 | } |
| 201 | |||
| 202 | // A failed build's page folds its log by step, opens the step that | ||
| 203 | // failed and links to it; no JavaScript (#266). | ||
| 204 | func TestBuildPageFoldsStepsAndOpensFailure(t *testing.T) { | ||
| 205 | b := control.BuildOut{Number: 61, Job: "test", Status: "failure", | ||
| 206 | SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "main", | ||
| 207 | CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:53:50Z", DurationS: 656, | ||
| 208 | Steps: []string{"go build ./...", "go test ./..."}, FailedStep: 2, FailedReason: "exit 1"} | ||
| 209 | log := "$ git clone x (ff6271a9d4)\n$ go build ./...\n$ go test ./...\n--- FAIL: TestCLI\nstep 2/2 failed: exit 1\n" | ||
| 210 | v := buildView{repoPage: testRepoPage(), Build: b, Log: log, Duration: "10m56s"} | ||
| 211 | v.Steps, v.Failed = logSteps(log, b) | ||
| 212 | var sb strings.Builder | ||
| 213 | if err := web.Render(&sb, "build.html", v); err != nil { | ||
| 214 | t.Fatalf("render: %v", err) | ||
| 215 | } | ||
| 216 | out := sb.String() | ||
| 217 | for _, want := range []string{ | ||
| 218 | `<details class="difffold buildstep" id="failed" open>`, | ||
| 219 | "step 2/2", "<code>go test ./...</code>", `href="#failed"`, "Jump to failure", | ||
| 220 | "ran 10m56s", "--- FAIL: TestCLI", | ||
| 221 | } { | ||
| 222 | if !strings.Contains(out, want) { | ||
| 223 | t.Errorf("build.html missing %q", want) | ||
| 224 | } | ||
| 225 | } | ||
| 226 | if n := strings.Count(out, `class="difffold buildstep"`); n != 3 { | ||
| 227 | t.Errorf("%d step folds, want 3 (setup and two steps)", n) | ||
| 228 | } | ||
| 229 | if n := strings.Count(out, `id="failed"`); n != 1 { | ||
| 230 | t.Errorf("%d failed anchors, want 1", n) | ||
| 231 | } | ||
| 232 | } | ||
| 233 | |||
| 234 | // A multi-line step's fold summary shows only its first line, as build | ||
| 235 | // show does; the whole step is in the log (#266). | ||
| 236 | func TestBuildPageStepSummaryIsFirstLine(t *testing.T) { | ||
| 237 | step := "set -e\ngo test ./..." | ||
| 238 | b := control.BuildOut{Number: 62, Job: "test", Status: "failure", | ||
| 239 | SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "main", | ||
| 240 | CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:54Z", DurationS: 60, | ||
| 241 | Steps: []string{step}, FailedStep: 1, FailedReason: "exit 1"} | ||
| 242 | log := "$ git clone x (ff6271a9d4)\n$ " + step + "\n--- FAIL: TestCLI\nstep 1/1 failed: exit 1\n" | ||
| 243 | v := buildView{repoPage: testRepoPage(), Build: b, Log: log, Duration: "1m0s"} | ||
| 244 | v.Steps, v.Failed = logSteps(log, b) | ||
| 245 | var sb strings.Builder | ||
| 246 | if err := web.Render(&sb, "build.html", v); err != nil { | ||
| 247 | t.Fatalf("render: %v", err) | ||
| 248 | } | ||
| 249 | out := sb.String() | ||
| 250 | if !strings.Contains(out, "<code>set -e</code>") { | ||
| 251 | t.Errorf("summary does not show the step's first line:\n%s", out) | ||
| 252 | } | ||
| 253 | if strings.Contains(out, "<code>set -e\ngo test") { | ||
| 254 | t.Errorf("summary shows the whole multi-line step") | ||
| 255 | } | ||
| 256 | } | ||
internal/httpd/builds.go +43 −2
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | "slices" | 10 | "slices" |
| 11 | "strconv" | 11 | "strconv" |
| 12 | "strings" | 12 | "strings" |
| 13 | "time" | ||
| 13 | 14 | ||
| 14 | "gitbay.org/gitbay/internal/control" | 15 | "gitbay.org/gitbay/internal/control" |
| 15 | "gitbay.org/gitbay/internal/protocol" | 16 | "gitbay.org/gitbay/internal/protocol" |
| @@ -282,18 +283,58 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) { | |||
| 282 | return | 283 | return |
| 283 | } | 284 | } |
| 284 | v.Log, _, _ = s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n}) | 285 | v.Log, _, _ = s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n}) |
| 286 | v.Steps, v.Failed = logSteps(v.Log, b) | ||
| 287 | if b.DurationS > 0 { | ||
| 288 | v.Duration = (time.Duration(b.DurationS) * time.Second).String() | ||
| 289 | } | ||
| 285 | s.render(w, "build.html", v) | 290 | s.render(w, "build.html", v) |
| 286 | } | 291 | } |
| 287 | 292 | ||
| 288 | type buildView struct { | 293 | type buildView struct { |
| 289 | repoPage | 294 | repoPage |
| 290 | Build control.BuildOut | 295 | Build control.BuildOut |
| 291 | Log string | 296 | Log string |
| 297 | // Steps is the finished log cut at its steps, nil when there is no | ||
| 298 | // step to cut at; Failed says whether one of them is marked failed. | ||
| 299 | Steps []logStep | ||
| 300 | Failed bool | ||
| 301 | Duration string | ||
| 292 | Live bool | 302 | Live bool |
| 293 | CanWrite bool | 303 | CanWrite bool |
| 294 | Notice string | 304 | Notice string |
| 295 | } | 305 | } |
| 296 | 306 | ||
| 307 | type logStep struct { | ||
| 308 | control.LogSection | ||
| 309 | // Head is the step's first line, which its fold's summary shows, as | ||
| 310 | // build show does; the full step is in the log below it. | ||
| 311 | Head string | ||
| 312 | Failed bool | ||
| 313 | } | ||
| 314 | |||
| 315 | // logSteps cuts a finished build's log at its steps and marks the one it | ||
| 316 | // failed at. Nil when no step's line is in the log — a build that | ||
| 317 | // stopped in the clone — which renders as one block. | ||
| 318 | func logSteps(log string, b control.BuildOut) ([]logStep, bool) { | ||
| 319 | sections := control.SplitBuildLog(log, b.Steps) | ||
| 320 | stepped := false | ||
| 321 | for _, s := range sections { | ||
| 322 | if s.N > 0 { | ||
| 323 | stepped = true | ||
| 324 | } | ||
| 325 | } | ||
| 326 | if !stepped { | ||
| 327 | return nil, false | ||
| 328 | } | ||
| 329 | failed := control.FailedSection(sections, b.Status, b.FailedStep) | ||
| 330 | out := make([]logStep, len(sections)) | ||
| 331 | for i, s := range sections { | ||
| 332 | head, _, _ := strings.Cut(s.Step, "\n") | ||
| 333 | out[i] = logStep{LogSection: s, Head: head, Failed: i == failed} | ||
| 334 | } | ||
| 335 | return out, failed >= 0 | ||
| 336 | } | ||
| 337 | |||
| 297 | // liveLogMarker stands in for the log when build.html is rendered for a | 338 | // liveLogMarker stands in for the log when build.html is rendered for a |
| 298 | // live build; streamBuild splits the page there and streams the log into | 339 | // live build; streamBuild splits the page there and streams the log into |
| 299 | // the gap. Git refs, paths and job names cannot hold the control byte. | 340 | // the gap. Git refs, paths and job names cannot hold the control byte. |
internal/store/builds.go +24 −2
| @@ -30,6 +30,11 @@ type Build struct { | |||
| 30 | // Trusted is false for a merge request head fetched from another | 30 | // Trusted is false for a merge request head fetched from another |
| 31 | // repository: its steps run without the target's secrets. | 31 | // repository: its steps run without the target's secrets. |
| 32 | Trusted bool | 32 | Trusted bool |
| 33 | // FailedStep is the 1-based step a failed build stopped at, 0 when it | ||
| 34 | // stopped before its first step or did not fail. FailedReason is the | ||
| 35 | // runner's one line: "exit 1", "build timed out after 45m0s". | ||
| 36 | FailedStep int | ||
| 37 | FailedReason string | ||
| 33 | } | 38 | } |
| 34 | 39 | ||
| 35 | // MaxBuildLog caps a build's stored log; appends past it are dropped. | 40 | // MaxBuildLog caps a build's stored log; appends past it are dropped. |
| @@ -65,14 +70,16 @@ func (s *Store) CreateBuild(repoID int64, job, sha, ref, stepsJSON, image, tree | |||
| 65 | } | 70 | } |
| 66 | 71 | ||
| 67 | const buildSelect = ` | 72 | const buildSelect = ` |
| 68 | SELECT id, repo_id, number, job, sha, ref, steps, image, tree, status, created_at, started_at, finished_at, log_closed_at, trusted | 73 | SELECT id, repo_id, number, job, sha, ref, steps, image, tree, status, created_at, started_at, finished_at, log_closed_at, trusted, |
| 74 | failed_step, failed_reason | ||
| 69 | FROM builds` | 75 | FROM builds` |
| 70 | 76 | ||
| 71 | func scanBuild(row interface{ Scan(...any) error }) (Build, error) { | 77 | func scanBuild(row interface{ Scan(...any) error }) (Build, error) { |
| 72 | var b Build | 78 | var b Build |
| 73 | var trusted int | 79 | var trusted int |
| 74 | err := row.Scan(&b.ID, &b.RepoID, &b.Number, &b.Job, &b.SHA, &b.Ref, &b.Steps, &b.Image, &b.Tree, | 80 | err := row.Scan(&b.ID, &b.RepoID, &b.Number, &b.Job, &b.SHA, &b.Ref, &b.Steps, &b.Image, &b.Tree, |
| 75 | &b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt, &b.LogClosedAt, &trusted) | 81 | &b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt, &b.LogClosedAt, &trusted, |
| 82 | &b.FailedStep, &b.FailedReason) | ||
| 76 | b.Trusted = trusted != 0 | 83 | b.Trusted = trusted != 0 |
| 77 | return b, err | 84 | return b, err |
| 78 | } | 85 | } |
| @@ -263,6 +270,21 @@ func (s *Store) FinishBuild(id int64, status string) error { | |||
| 263 | return nil | 270 | return nil |
| 264 | } | 271 | } |
| 265 | 272 | ||
| 273 | // SetBuildFailure records where a running build failed. The runner | ||
| 274 | // reports it with the outcome; it is written first, so a reader woken | ||
| 275 | // by the finish sees both. | ||
| 276 | func (s *Store) SetBuildFailure(id int64, step int, reason string) error { | ||
| 277 | res, err := s.DB.Exec(`UPDATE builds SET failed_step = ?, failed_reason = ? | ||
| 278 | WHERE id = ? AND status = 'running'`, step, reason, id) | ||
| 279 | if err != nil { | ||
| 280 | return err | ||
| 281 | } | ||
| 282 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 283 | return ErrNotFound | ||
| 284 | } | ||
| 285 | return nil | ||
| 286 | } | ||
| 287 | |||
| 266 | func (s *Store) BuildByID(id int64) (Build, error) { | 288 | func (s *Store) BuildByID(id int64) (Build, error) { |
| 267 | b, err := scanBuild(s.DB.QueryRow(buildSelect+" WHERE id = ?", id)) | 289 | b, err := scanBuild(s.DB.QueryRow(buildSelect+" WHERE id = ?", id)) |
| 268 | if errors.Is(err, sql.ErrNoRows) { | 290 | if errors.Is(err, sql.ErrNoRows) { |
internal/store/builds_test.go +35
| @@ -1,6 +1,7 @@ | |||
| 1 | package store | 1 | package store |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "errors" | ||
| 4 | "strings" | 5 | "strings" |
| 5 | "testing" | 6 | "testing" |
| 6 | ) | 7 | ) |
| @@ -566,3 +567,37 @@ func TestBuildLogFrom(t *testing.T) { | |||
| 566 | t.Fatalf("missing build: %v", err) | 567 | t.Fatalf("missing build: %v", err) |
| 567 | } | 568 | } |
| 568 | } | 569 | } |
| 570 | |||
| 571 | // Where a failed build stopped is recorded while it runs, before the | ||
| 572 | // outcome, and a finished build is not rewritten (#266). | ||
| 573 | func TestSetBuildFailure(t *testing.T) { | ||
| 574 | s := open(t) | ||
| 575 | if err := s.MigrateUp(); err != nil { | ||
| 576 | t.Fatal(err) | ||
| 577 | } | ||
| 578 | uid, _ := s.CreateUser("cmc", true) | ||
| 579 | repoID, _ := s.CreateRepo("user", uid, "app", "public") | ||
| 580 | if _, err := s.CreateBuild(repoID, "unit", "abc", "main", `["true","false"]`, "", "", true); err != nil { | ||
| 581 | t.Fatal(err) | ||
| 582 | } | ||
| 583 | b, ok, err := s.ClaimBuild(nil, false) | ||
| 584 | if err != nil || !ok { | ||
| 585 | t.Fatalf("claim: ok=%v err=%v", ok, err) | ||
| 586 | } | ||
| 587 | if err := s.SetBuildFailure(b.ID, 2, "exit 1"); err != nil { | ||
| 588 | t.Fatal(err) | ||
| 589 | } | ||
| 590 | if err := s.FinishBuild(b.ID, "failure"); err != nil { | ||
| 591 | t.Fatal(err) | ||
| 592 | } | ||
| 593 | got, err := s.BuildByID(b.ID) | ||
| 594 | if err != nil { | ||
| 595 | t.Fatal(err) | ||
| 596 | } | ||
| 597 | if got.FailedStep != 2 || got.FailedReason != "exit 1" { | ||
| 598 | t.Fatalf("failed step %d reason %q", got.FailedStep, got.FailedReason) | ||
| 599 | } | ||
| 600 | if err := s.SetBuildFailure(b.ID, 1, "late"); !errors.Is(err, ErrNotFound) { | ||
| 601 | t.Fatalf("rewrote a finished build: %v", err) | ||
| 602 | } | ||
| 603 | } | ||
internal/store/migrations/0065_build_failure.down.sql added +2
| @@ -0,0 +1,2 @@ | |||
| 1 | ALTER TABLE builds DROP COLUMN failed_reason; | ||
| 2 | ALTER TABLE builds DROP COLUMN failed_step; | ||
internal/store/migrations/0065_build_failure.up.sql added +4
| @@ -0,0 +1,4 @@ | |||
| 1 | -- Where a failed build stopped: the 1-based step, 0 when it stopped | ||
| 2 | -- before any step or did not fail, and the runner's one-line reason. | ||
| 3 | ALTER TABLE builds ADD COLUMN failed_step INTEGER NOT NULL DEFAULT 0; | ||
| 4 | ALTER TABLE builds ADD COLUMN failed_reason TEXT NOT NULL DEFAULT ''; | ||
internal/web/static/style.css +3 −1
| @@ -1113,7 +1113,9 @@ pre.matchline mark { background: color-mix(in srgb, var(--mark) 25%, transparent | |||
| 1113 | p.pager { margin-top: var(--sp-4); } | 1113 | p.pager { margin-top: var(--sp-4); } |
| 1114 | .snippetfile { margin-bottom: var(--sp-5); } | 1114 | .snippetfile { margin-bottom: var(--sp-5); } |
| 1115 | p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); } | 1115 | p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); } |
| 1116 | pre.buildlog { max-height: 40rem; overflow: auto; } | 1116 | pre.buildlog { max-height: 40rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } |
| 1117 | details.buildstep pre.buildlog { margin: 0; border: 0; border-radius: 0; } | ||
| 1118 | details.buildstep summary code { overflow-wrap: anywhere; } | ||
| 1117 | 1119 | ||
| 1118 | /* ---- rendered prose ---- */ | 1120 | /* ---- rendered prose ---- */ |
| 1119 | pre { | 1121 | pre { |
internal/web/templates/build.html +6 −1
| @@ -11,8 +11,13 @@ | |||
| 11 | </form> | 11 | </form> |
| 12 | {{end}} | 12 | {{end}} |
| 13 | </div> | 13 | </div> |
| 14 | <p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p> | 14 | <p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{with .Duration}} · ran {{.}}{{end}}{{end}}{{if .Failed}} · <a href="#failed">Jump to failure</a>{{end}}</p> |
| 15 | {{if .Live}}<p class="meta">Live: the log streams here until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p> | 15 | {{if .Live}}<p class="meta">Live: the log streams here until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p> |
| 16 | <pre class="code buildlog" tabindex="0">{{.Log}}</pre> | 16 | <pre class="code buildlog" tabindex="0">{{.Log}}</pre> |
| 17 | {{else if .Steps}}{{$total := len .Build.Steps}}{{range .Steps}} | ||
| 18 | <details class="difffold buildstep"{{if .Failed}} id="failed" open{{end}}> | ||
| 19 | <summary>{{if .N}}<span>step {{.N}}/{{$total}}</span> <code>{{.Head}}</code>{{else}}<span>setup</span>{{end}}{{if .Failed}} <span class="chip check-failure">failed</span>{{end}}</summary> | ||
| 20 | <pre class="code buildlog" tabindex="0">{{.Text}}</pre> | ||
| 21 | </details>{{end}} | ||
| 17 | {{else if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} | 22 | {{else if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} |
| 18 | {{end}} | 23 | {{end}} |