mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-10-31 20:37:43 +08:00
Follow-up to https://gitea.com/gitea/runner/pulls/1362, whose crash e2e missed because it only drove the daemon on Docker. - `dispatch`, `failure_paths` and `reusable_workflow` run through the daemon and `exec`, each on Docker and one shared k3s cluster. - Fixed sleeps become gates the test releases, the job timeout uses a 15s runner deadline, k3s boots alongside Gitea, Gitea's data is on tmpfs. - Outside a container, `make test-e2e` runs `go test` in a Linux container on its own network like CI, so local runs take the same paths. Local wall time: | | Before | After | |---|---|---| | Suite | 84s | 27–35s | | `job_timeout` | 64s | 17s | | `reconnect` | 22s | 4.5s | | `capacity/serial` | 20s | 2s | | `cli_shutdown_drains` | 14s | 2.3s | | `kubernetes` | 31s | 21–23s | _Written by Claude (Opus 5.5)._ --------- Co-authored-by: bircni <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/1363 Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
226 lines
5.7 KiB
Go
226 lines
5.7 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
//go:build e2e
|
|
|
|
package e2e
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/internal/app/poll"
|
|
|
|
mobyclient "github.com/moby/moby/client"
|
|
)
|
|
|
|
const runTimeout = 3 * time.Minute
|
|
|
|
var nonRepoChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
|
|
|
var fixture *GiteaFixture
|
|
|
|
var skipReason string
|
|
|
|
var binDir string
|
|
|
|
var dockerCLI mobyclient.APIClient
|
|
|
|
var runnerBinary = sync.OnceValues(func() (string, error) {
|
|
bin := filepath.Join(binDir, "gitea-runner")
|
|
if out, err := exec.Command(cmp.Or(os.Getenv("GO"), "go"), "build", "-o", bin, "..").CombinedOutput(); err != nil {
|
|
return "", fmt.Errorf("build runner: %w\n%s", err, out)
|
|
}
|
|
return bin, nil
|
|
})
|
|
|
|
func TestMain(m *testing.M) {
|
|
os.Exit(runSuite(m))
|
|
}
|
|
|
|
func runSuite(m *testing.M) int {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
cli, err := dockerClient(ctx)
|
|
if err != nil {
|
|
skipReason = fmt.Sprintf("docker unavailable: %v", err)
|
|
return m.Run()
|
|
}
|
|
dockerCLI = cli
|
|
|
|
if binDir, err = os.MkdirTemp("", "gitea-runner-e2e-"); err != nil {
|
|
fmt.Fprintf(os.Stderr, "create binary dir: %v\n", err)
|
|
return 1
|
|
}
|
|
defer os.RemoveAll(binDir)
|
|
defer stopCluster()
|
|
if flag.Parse(); flag.Lookup("test.run").Value.String() == "" { // a filtered run starts the cluster only if it needs it
|
|
go func() { _, _ = k3s() }()
|
|
}
|
|
|
|
f, err := StartGitea(ctx, cli)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "start gitea fixture: %v\n", err)
|
|
return 1
|
|
}
|
|
fixture = f
|
|
defer func() { _ = fixture.Close(context.Background()) }()
|
|
|
|
fmt.Fprintf(os.Stderr, "gitea fixture: image=%s version=%s\n", fixture.image, fixture.version)
|
|
|
|
code := m.Run()
|
|
if err := checkClusterLeaks(); err != nil && code == 0 {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
code = 1
|
|
}
|
|
return code
|
|
}
|
|
|
|
func newScenario(t *testing.T) (*GiteaAPI, string) {
|
|
t.Helper()
|
|
|
|
repo := strings.ToLower(nonRepoChars.ReplaceAllString(t.Name(), "-"))
|
|
api := &GiteaAPI{baseURL: fixture.baseURL, token: fixture.adminToken}
|
|
if err := api.CreateRepo(t.Context(), repo); err != nil {
|
|
t.Fatalf("create repo: %v", err)
|
|
}
|
|
return api, repo
|
|
}
|
|
|
|
func startIsolatedScenario(t *testing.T, workflow, label string, options runnerOptions) (*GiteaAPI, string, *poll.Poller) {
|
|
t.Helper()
|
|
|
|
api, repo := newScenario(t)
|
|
poller := startRunner(t, repo, label, options)
|
|
pushWorkflow(t, api, repo, workflow)
|
|
return api, repo, poller
|
|
}
|
|
|
|
func pushWorkflow(t *testing.T, api *GiteaAPI, repo, workflow string) {
|
|
t.Helper()
|
|
|
|
content, err := os.ReadFile("testdata/workflows/" + workflow)
|
|
if err != nil {
|
|
t.Fatalf("read workflow fixture %s: %v", workflow, err)
|
|
}
|
|
if err := api.CreateFile(t.Context(), repo, ".gitea/workflows/"+workflow, string(content), "add "+workflow); err != nil {
|
|
t.Fatalf("push workflow %s: %v", workflow, err)
|
|
}
|
|
}
|
|
|
|
func requireSuccess(t *testing.T, api *GiteaAPI, repo string, runID int64) {
|
|
t.Helper()
|
|
|
|
completed, err := api.WaitForRunConclusion(t.Context(), repo, runID, runTimeout)
|
|
if err != nil {
|
|
dumpRunLogs(t, api, repo, runID)
|
|
t.Fatalf("wait for run: %v", err)
|
|
}
|
|
if completed.Conclusion != "success" {
|
|
dumpRunLogs(t, api, repo, runID)
|
|
t.Fatalf("run concluded %q, want success", completed.Conclusion)
|
|
}
|
|
}
|
|
|
|
func runLogs(t *testing.T, api *GiteaAPI, repo string, runID int64) string {
|
|
t.Helper()
|
|
ctx := t.Context()
|
|
|
|
jobs, err := api.Jobs(ctx, repo, runID)
|
|
if err != nil {
|
|
t.Fatalf("list jobs: %v", err)
|
|
}
|
|
var all strings.Builder
|
|
for _, job := range jobs {
|
|
logs, err := api.JobLogs(ctx, repo, job.ID)
|
|
var response statusError
|
|
if errors.As(err, &response) && response.code == http.StatusNotFound {
|
|
continue // skipped jobs and reusable workflow callers never ran a task, so have no log
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("job logs for %s: %v", job.Name, err)
|
|
}
|
|
all.WriteString(logs)
|
|
}
|
|
return all.String()
|
|
}
|
|
|
|
func dumpRunLogs(t *testing.T, api *GiteaAPI, repo string, runID int64) {
|
|
t.Helper()
|
|
ctx := t.Context()
|
|
|
|
jobs, err := api.Jobs(ctx, repo, runID)
|
|
if err != nil {
|
|
t.Logf("dump run %d: list jobs: %v", runID, err)
|
|
return
|
|
}
|
|
for _, job := range jobs {
|
|
logs, err := api.JobLogs(ctx, repo, job.ID)
|
|
if err != nil {
|
|
t.Logf("job %q (%s): logs unavailable: %v", job.Name, job.Conclusion, err)
|
|
continue
|
|
}
|
|
t.Logf("job %q concluded %q:\n%s", job.Name, job.Conclusion, logs)
|
|
}
|
|
}
|
|
|
|
// newGate serves a URL that answers 503 until release, for jobs to poll instead of sleeping.
|
|
func newGate(t *testing.T) (string, func()) {
|
|
t.Helper()
|
|
var released atomic.Bool
|
|
listener, err := net.Listen("tcp", ":0")
|
|
if err != nil {
|
|
t.Fatalf("listen for the gate: %v", err)
|
|
}
|
|
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
if !released.Load() {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}
|
|
}), ReadHeaderTimeout: time.Second}
|
|
go func() { _ = server.Serve(listener) }()
|
|
t.Cleanup(func() { _ = server.Close() })
|
|
return fmt.Sprintf("http://%s/", net.JoinHostPort(fixture.selfIP, strconv.Itoa(listener.Addr().(*net.TCPAddr).Port))), func() { released.Store(true) }
|
|
}
|
|
|
|
func waitForRun(t *testing.T, api *GiteaAPI, repo string) *ActionRun {
|
|
t.Helper()
|
|
return waitForRunAfter(t, api, repo, 0)
|
|
}
|
|
|
|
func waitForRunAfter(t *testing.T, api *GiteaAPI, repo string, previousID int64) *ActionRun {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
|
defer cancel()
|
|
|
|
for {
|
|
runs, err := api.Runs(ctx, repo)
|
|
if err != nil {
|
|
t.Fatalf("get latest run: %v", err)
|
|
}
|
|
if len(runs) > 0 && runs[0].ID > previousID {
|
|
return &runs[0]
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatalf("no run appeared for %s within timeout", repo)
|
|
case <-time.After(pollInterval):
|
|
}
|
|
}
|
|
}
|