mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-10-29 03:16:43 +08:00
Extends the e2e suite with scenarios for host labels, local `workflow_call`, label routing, `runner.capacity`, a dropped and restored connection to Gitea, and the daemon's `--once`, `--labels` and `shutdown_timeout` behaviour. Stacked on https://gitea.com/gitea/runner/pulls/1346, so its commit shows here until that merges. The new scenarios pass locally against gitea/gitea:latest and main-nightly. Assisted-by: Codet:codet Co-authored-by: Zettat123 <zettat123@gmail.com> Co-authored-by: bircni <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/1347 Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
313 lines
9.1 KiB
Go
313 lines
9.1 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
//go:build e2e
|
|
|
|
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type syncBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *syncBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *syncBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|
|
|
|
type cliRunner struct {
|
|
api *GiteaAPI
|
|
repo string
|
|
bin string
|
|
cfgFile string
|
|
dir string
|
|
}
|
|
|
|
// newCLIRunner registers the built binary against a fresh repo with a single docker label.
|
|
func newCLIRunner(t *testing.T, label, extraRunnerConfig string) *cliRunner {
|
|
t.Helper()
|
|
api, repo := newScenario(t)
|
|
token, err := fixture.RegistrationToken(t.Context(), repo)
|
|
if err != nil {
|
|
t.Fatalf("get registration token: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
bin, err := runnerBinary()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg := fmt.Sprintf(`runner:
|
|
file: %s
|
|
fetch_interval: 250ms
|
|
fetch_interval_max: 250ms
|
|
state_report_interval: 500ms
|
|
log_report_interval: 500ms
|
|
%scache:
|
|
dir: %s
|
|
`, filepath.Join(dir, ".runner"), extraRunnerConfig, filepath.Join(dir, "cache"))
|
|
if fixture.network != "" {
|
|
cfg += "container:\n network: " + fixture.network + "\n"
|
|
}
|
|
c := &cliRunner{api: api, repo: repo, bin: bin, cfgFile: filepath.Join(dir, "config.yaml"), dir: dir}
|
|
if err := os.WriteFile(c.cfgFile, []byte(cfg), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
out, err := exec.CommandContext(t.Context(), c.bin, "register", "--no-interactive", "-c", c.cfgFile,
|
|
"--instance", fixture.baseURL, "--token", token, "--name", repo,
|
|
"--labels", label+":docker://"+os.Getenv("E2E_JOB_IMAGE")).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("register: %v\n%s", err, out)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, ".runner")); err != nil {
|
|
t.Fatalf("register did not write the runner file: %v\n%s", err, out)
|
|
}
|
|
return c
|
|
}
|
|
|
|
type cliDaemon struct {
|
|
cmd *exec.Cmd
|
|
log *syncBuffer
|
|
exited chan error
|
|
}
|
|
|
|
func (c *cliRunner) startDaemon(t *testing.T, args ...string) *cliDaemon {
|
|
t.Helper()
|
|
d := &cliDaemon{log: &syncBuffer{}, exited: make(chan error, 1)}
|
|
d.cmd = exec.Command(c.bin, append([]string{"daemon", "-c", c.cfgFile}, args...)...)
|
|
d.cmd.Stdout, d.cmd.Stderr = d.log, d.log
|
|
if err := d.cmd.Start(); err != nil {
|
|
t.Fatalf("start daemon: %v", err)
|
|
}
|
|
go func() { d.exited <- d.cmd.Wait() }()
|
|
t.Cleanup(func() {
|
|
_ = d.cmd.Process.Kill() // no-op once the daemon has exited
|
|
})
|
|
return d
|
|
}
|
|
|
|
// wait returns the daemon's exit error, failing the test when it is still running after timeout.
|
|
func (d *cliDaemon) wait(t *testing.T, timeout time.Duration) error {
|
|
t.Helper()
|
|
select {
|
|
case err := <-d.exited:
|
|
return err
|
|
case <-time.After(timeout):
|
|
t.Fatalf("daemon still running after %s\n%s", timeout, d.log)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (d *cliDaemon) terminate(t *testing.T) {
|
|
t.Helper()
|
|
if err := d.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
|
t.Fatalf("signal daemon: %v", err)
|
|
}
|
|
}
|
|
|
|
// requireSuccess waits for the run and dumps the daemon output next to the job logs on failure.
|
|
func (d *cliDaemon) requireSuccess(t *testing.T, c *cliRunner, runID int64) {
|
|
t.Helper()
|
|
completed, err := c.api.WaitForRunConclusion(t.Context(), c.repo, runID, runTimeout)
|
|
if err != nil || completed.Conclusion != "success" {
|
|
t.Logf("daemon output:\n%s", d.log)
|
|
requireSuccess(t, c.api, c.repo, runID)
|
|
}
|
|
}
|
|
|
|
// testCLIRegisterDaemon drives the built binary the way a user does: register, then daemon.
|
|
func testCLIRegisterDaemon(t *testing.T) {
|
|
t.Parallel()
|
|
c := newCLIRunner(t, "e2e-cli", "")
|
|
d := c.startDaemon(t)
|
|
|
|
pushWorkflow(t, c.api, c.repo, "cli.yml")
|
|
wfRun := waitForRun(t, c.api, c.repo)
|
|
d.requireSuccess(t, c, wfRun.ID)
|
|
if logs := runLogs(t, c.api, c.repo, wfRun.ID); commandRow(logs, "e2e-cli-job-ran") == "" {
|
|
t.Errorf("job logs are missing the marker:\n%s", logs)
|
|
}
|
|
|
|
d.terminate(t)
|
|
if err := d.wait(t, 30*time.Second); err != nil {
|
|
t.Errorf("daemon did not exit cleanly on SIGTERM: %v\n%s", err, d.log)
|
|
}
|
|
}
|
|
|
|
// testCLIOnce checks that --once takes a single job and then exits on its own.
|
|
func testCLIOnce(t *testing.T) {
|
|
t.Parallel()
|
|
c := newCLIRunner(t, "e2e-once", "")
|
|
d := c.startDaemon(t, "--once")
|
|
|
|
pushWorkflow(t, c.api, c.repo, "cli_once.yml")
|
|
wfRun := waitForRun(t, c.api, c.repo)
|
|
if err := d.wait(t, runTimeout); err != nil {
|
|
t.Errorf("daemon --once exited with %v\n%s", err, d.log)
|
|
}
|
|
|
|
jobs, err := c.api.Jobs(t.Context(), c.repo, wfRun.ID)
|
|
if err != nil {
|
|
t.Fatalf("list jobs: %v", err)
|
|
}
|
|
var succeeded, pending int
|
|
for _, job := range jobs {
|
|
switch {
|
|
case job.Conclusion == "success":
|
|
succeeded++
|
|
case job.Status != "in_progress" && job.Status != "completed":
|
|
pending++
|
|
}
|
|
}
|
|
if succeeded != 1 || pending != 1 {
|
|
t.Errorf("got %d succeeded and %d waiting jobs, want 1 and 1: %+v\n%s", succeeded, pending, jobs, d.log)
|
|
}
|
|
}
|
|
|
|
// testCLILabelsOverride checks that daemon --labels replaces the labels given at registration.
|
|
func testCLILabelsOverride(t *testing.T) {
|
|
t.Parallel()
|
|
c := newCLIRunner(t, "e2e-registered", "")
|
|
d := c.startDaemon(t, "--labels", "e2e-override:docker://"+os.Getenv("E2E_JOB_IMAGE"))
|
|
|
|
pushWorkflow(t, c.api, c.repo, "cli_labels.yml")
|
|
wfRun := waitForRun(t, c.api, c.repo)
|
|
d.requireSuccess(t, c, wfRun.ID)
|
|
|
|
runners, err := c.api.Runners(t.Context(), c.repo)
|
|
if err != nil {
|
|
t.Fatalf("list runners: %v", err)
|
|
}
|
|
for _, runner := range runners {
|
|
if runner.Name != c.repo {
|
|
continue
|
|
}
|
|
var labels []string
|
|
for _, label := range runner.Labels {
|
|
labels = append(labels, label.Name)
|
|
}
|
|
if !slices.Equal(labels, []string{"e2e-override"}) {
|
|
t.Errorf("runner has labels %v on the server, want only the --labels override", labels)
|
|
}
|
|
return
|
|
}
|
|
t.Errorf("runner %q not found among %+v", c.repo, runners)
|
|
}
|
|
|
|
// testCLIShutdownDrains checks that SIGTERM lets a running job finish within shutdown_timeout.
|
|
func testCLIShutdownDrains(t *testing.T) {
|
|
t.Parallel()
|
|
c := newCLIRunner(t, "e2e-shutdown", " shutdown_timeout: 1m\n")
|
|
d := c.startDaemon(t)
|
|
|
|
pushWorkflow(t, c.api, c.repo, "cli_shutdown.yml")
|
|
wfRun := waitForRun(t, c.api, c.repo)
|
|
waitForRunningJobLog(t, c.api, c.repo, wfRun.ID, "e2e-shutdown-started")
|
|
|
|
d.terminate(t)
|
|
if err := d.wait(t, runTimeout); err != nil {
|
|
t.Errorf("daemon did not exit cleanly: %v\n%s", err, d.log)
|
|
}
|
|
if strings.Contains(d.log.String(), "cancelled in progress jobs") {
|
|
t.Errorf("the running job was cancelled instead of drained\n%s", d.log)
|
|
}
|
|
d.requireSuccess(t, c, wfRun.ID)
|
|
if logs := runLogs(t, c.api, c.repo, wfRun.ID); commandRow(logs, "e2e-shutdown-finished") == "" {
|
|
t.Errorf("job did not run to the end:\n%s", logs)
|
|
}
|
|
}
|
|
|
|
// testCLIShutdownCancels checks that a job still running at shutdown_timeout is cancelled.
|
|
func testCLIShutdownCancels(t *testing.T) {
|
|
t.Parallel()
|
|
c := newCLIRunner(t, "e2e-shutdown", " shutdown_timeout: 1s\n")
|
|
d := c.startDaemon(t)
|
|
|
|
pushWorkflow(t, c.api, c.repo, "cli_shutdown.yml")
|
|
wfRun := waitForRun(t, c.api, c.repo)
|
|
waitForRunningJobLog(t, c.api, c.repo, wfRun.ID, "e2e-shutdown-started")
|
|
|
|
d.terminate(t)
|
|
if err := d.wait(t, runTimeout); err != nil {
|
|
t.Errorf("daemon did not exit cleanly after cancelling the job: %v\n%s", err, d.log)
|
|
}
|
|
if !strings.Contains(d.log.String(), "cancelled in progress jobs") {
|
|
t.Errorf("daemon did not report cancelling the running job\n%s", d.log)
|
|
}
|
|
completed, err := c.api.WaitForRunConclusion(t.Context(), c.repo, wfRun.ID, time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("cancelled run was never finalized: %v", err)
|
|
}
|
|
if completed.Conclusion == "success" {
|
|
t.Errorf("run concluded success although the job was cancelled")
|
|
}
|
|
}
|
|
|
|
func testExecArtifacts(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := t.Context()
|
|
|
|
bin, err := runnerBinary()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
workflow, err := os.ReadFile("testdata/workflows/exec_artifact.yml")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
workflows := t.TempDir()
|
|
for _, name := range []string{"a.yml", "b.yml"} {
|
|
if err := os.WriteFile(filepath.Join(workflows, name), workflow, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
workdir := t.TempDir()
|
|
for _, args := range [][]string{
|
|
{"init", "-q"},
|
|
{"remote", "add", "origin", "https://example.com/e2e/exec.git"},
|
|
{"-c", "user.name=e2e", "-c", "user.email=e2e@example.com", "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)
|
|
}
|
|
}
|
|
|
|
artifacts := t.TempDir()
|
|
args := []string{"exec", "-C", workdir, "-W", workflows, "-i", os.Getenv("E2E_JOB_IMAGE"), "--artifact-server-path", artifacts}
|
|
if fixture.network != "" {
|
|
args = append(args, "--network", fixture.network)
|
|
}
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
cmd.Env = slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") })
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("exec: %v\n%s", err, out)
|
|
}
|
|
if blobs, _ := filepath.Glob(filepath.Join(artifacts, "v4", "*", "*", "blob")); len(blobs) != 2 {
|
|
t.Fatalf("want one v4 artifact blob per workflow run, got %v", blobs)
|
|
}
|
|
}
|