Files
runner/e2e/modes_test.go
T
silverwindandbircni d34e4cf11a fix: stop a stalled docker daemon from hanging jobs and the e2e suite (#1404)
When the docker daemon stopped answering on https://gitea.com/gitea/runner/actions/runs/1204971, unbounded exec runs hung the e2e suite until the 20m `go test` panic, which skipped teardown. Exec runs and teardown are now bounded, and attached exec streams, including the runner's user ID probe, close with their context.

CLI daemons now stop with SIGTERM, so job volumes no longer leak.

_Written by Claude (Opus 5.5)._

---------

Co-authored-by: bircni <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1404
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
2026-11-07 08:07:41 +00:00

242 lines
6.8 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build e2e
package e2e
import (
"bufio"
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
)
type mode struct {
exec, kubernetes bool
}
var allModes = []mode{{}, {kubernetes: true}, {exec: true}, {exec: true, kubernetes: true}}
func (m mode) String() string {
driver, backend := "daemon", "docker"
if m.exec {
driver = "exec"
}
if m.kubernetes {
backend = "kubernetes"
}
return driver + "_" + backend
}
func inAllModes(scenario func(*testing.T, mode)) func(*testing.T) {
return func(t *testing.T) {
t.Parallel()
for _, m := range allModes {
t.Run(m.String(), func(t *testing.T) {
t.Parallel()
scenario(t, m)
})
}
}
}
type workflowSpec struct {
files []string
inputs map[string]string // dispatches files[0] instead of relying on push
execArgs []string
}
type workflowResult struct {
conclusion string
jobs map[string]string
lines []string
}
func (r *workflowResult) job(id string) string {
return cmp.Or(r.jobs[id], "skipped") // exec reports nothing for a job it skipped
}
func (r *workflowResult) hasLine(line string) bool {
return slices.Contains(r.lines, line)
}
func runWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
t.Helper()
run := daemonWorkflow
if m.exec {
run = execWorkflow
}
result := run(t, m, spec)
t.Cleanup(func() {
if t.Failed() {
t.Logf("%s concluded %q, jobs %v, logs:\n%s", m, result.conclusion, result.jobs, strings.Join(result.lines, "\n"))
}
})
return result
}
func daemonWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
t.Helper()
ctx := t.Context()
api, repo := newScenario(t)
if m.kubernetes {
cluster := requireCluster(t)
startRunner(t, repo, "e2e-kubernetes", runnerOptions{image: cluster.jobImage, kubernetes: cluster.kubernetes, capacity: 16})
}
files := map[string]string{}
for _, file := range spec.files {
content, err := os.ReadFile(filepath.Join("testdata", "workflows", file))
if err != nil {
t.Fatalf("read workflow fixture %s: %v", file, err)
}
if m.kubernetes {
content = bytes.ReplaceAll(content, []byte("runs-on: ubuntu-latest"), []byte("runs-on: e2e-kubernetes"))
}
files[".gitea/workflows/"+file] = string(content)
}
if err := api.CreateFiles(ctx, repo, files, "add workflows"); err != nil {
t.Fatalf("push workflows: %v", err)
}
if spec.inputs != nil {
branch, err := api.DefaultBranch(ctx, repo)
if err != nil {
t.Fatalf("get default branch: %v", err)
}
if err := waitForDispatch(ctx, api, repo, spec.files[0], branch, spec.inputs); err != nil {
t.Fatalf("dispatch workflow: %v", err)
}
}
wfRun := waitForRun(t, api, repo)
completed, err := api.WaitForRunConclusion(ctx, repo, wfRun.ID, runTimeout)
if err != nil {
dumpRunLogs(t, api, repo, wfRun.ID)
t.Fatalf("wait for run: %v", err)
}
jobs, err := api.Jobs(ctx, repo, wfRun.ID)
if err != nil {
t.Fatalf("list jobs: %v", err)
}
result := &workflowResult{conclusion: completed.Conclusion, jobs: map[string]string{}}
for _, job := range jobs {
result.jobs[job.Name] = job.Conclusion
}
for line := range strings.SplitSeq(runLogs(t, api, repo, wfRun.ID), "\n") {
if _, payload, found := strings.Cut(line, "Z "); found {
result.lines = append(result.lines, strings.TrimRight(payload, "\r"))
}
}
return result
}
func runExec(t *testing.T, env []string, args ...string) (stdout, stderr []byte, err error) {
t.Helper()
bin, err := runnerBinary()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(t.Context(), runTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, bin, append([]string{"exec"}, args...)...)
cmd.Env = append(slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") }), env...)
cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) }
cmd.WaitDelay = cleanupTimeout
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdoutBuf, &stderrBuf
err = cmd.Run()
if ctx.Err() != nil {
t.Fatalf("exec did not finish within %s\n%s%s", runTimeout, stdoutBuf.Bytes(), stderrBuf.Bytes())
}
return stdoutBuf.Bytes(), stderrBuf.Bytes(), err
}
func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
t.Helper()
ctx := t.Context()
workdir := t.TempDir()
workflows := filepath.Join(workdir, ".gitea", "workflows")
if err := os.MkdirAll(workflows, 0o755); err != nil {
t.Fatal(err)
}
for _, file := range spec.files {
content, err := os.ReadFile(filepath.Join("testdata", "workflows", file))
if err != nil {
t.Fatalf("read workflow fixture %s: %v", file, err)
}
if err := os.WriteFile(filepath.Join(workflows, file), content, 0o644); err != nil {
t.Fatal(err)
}
}
for _, args := range [][]string{
{"init", "-q"},
{"remote", "add", "origin", "https://example.com/e2e/exec.git"},
{"-c", "user.name=e2e", "-c", "[email protected]", "commit", "-q", "--allow-empty", "-m", "init"},
} {
if out, err := exec.CommandContext(ctx, "git", append([]string{"-C", workdir}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
args := []string{"--json", "-C", workdir, "-W", workflows, "--artifact-server-path", t.TempDir(), "--artifact-server-port", "0"}
var env []string
if m.kubernetes {
cluster := requireCluster(t)
args = append(args, "-i", "kubernetes://"+cluster.jobImage)
env = []string{"KUBECONFIG=" + cluster.kubernetes.Kubeconfig}
} else {
args = append(args, "-i", os.Getenv("E2E_JOB_IMAGE"), "--network", fixture.network)
}
if spec.inputs != nil {
args = append(args, "-E", "workflow_dispatch")
for name, value := range spec.inputs {
args = append(args, "--input", name+"="+value)
}
}
stdout, stderr, runErr := runExec(t, env, append(args, spec.execArgs...)...)
var exitErr *exec.ExitError
if runErr != nil && !errors.As(runErr, &exitErr) {
t.Fatalf("exec: %v\n%s", runErr, stderr)
}
result := &workflowResult{conclusion: "success", jobs: map[string]string{}}
if runErr != nil {
result.conclusion = "failure"
}
scanner := bufio.NewScanner(bytes.NewReader(stdout))
scanner.Buffer(nil, 1<<20)
for scanner.Scan() {
var entry struct {
JobID string `json:"jobID"`
JobResult string `json:"jobResult"`
Msg string `json:"msg"`
RawOutput bool `json:"raw_output"`
}
if json.Unmarshal(scanner.Bytes(), &entry) != nil {
continue
}
if entry.JobResult != "" {
result.jobs[entry.JobID] = entry.JobResult
}
if entry.RawOutput {
for line := range strings.SplitSeq(strings.TrimRight(entry.Msg, "\n"), "\n") {
result.lines = append(result.lines, strings.TrimRight(line, "\r"))
}
}
}
if len(result.jobs) == 0 {
t.Fatalf("exec ran no job: %v\n%s", runErr, stderr)
}
return result
}