refactor: remove dead code and consolidate tests (#1335)

Removes dead code and redundant logic and consolidates tests, so there is less to read and maintain. No behavior change.

1. **act/runner**: inline single-use wrappers, compile regexes once instead of per call, embed the trampoline as a string, drop the unused legacy result of `parseActionCommand`
2. **act/container**: `sanitizeConfig` and `mergeContainerConfigs` stop returning the configs they already mutate in place, drop never-set option fields and the unused `user` parameter of `ExecWithCmdLine`
3. **act/common and cache server**: drop the unused `Trace`/`Tracef` loggers and the short SHA of `FindGitRevision`, `RegisterJob` releases through `RevokeJob`
4. **daemon, poller and reporter**: one shutdown wait for both poll modes, drop an unreachable fetch timeout branch, guard the reporter's last report time with its existing lock instead of an atomic
5. **build**: drop unused Makefile and release workflow variables, collapse the goreleaser ignore list to the same build targets
6. **tests**: fold near-duplicate tests into tables with shared setup

Throughout, hand-rolled logic gives way to `cmp.Or`, `strings.Cut`, `slices` and `min`/`max`.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1335
Reviewed-by: bircni <[email protected]>
Co-authored-by: silverwind <[email protected]>
This commit is contained in:
silverwind authored and silverwind committed 2026-10-22 19:41:10 +00:00
1 parent 923a0bfb59
commit d882311ec6
107 files changed
+2408 -6500

No files matched your search

+6 -23
View File
@@ -4,6 +4,7 @@
package cmd
import (
"cmp"
"errors"
"fmt"
@@ -33,32 +34,14 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
return errors.New("cache-server does not support cache.s3; set cache.s3 on the runners instead")
}
var (
dir = cfg.Cache.Dir
host = cfg.Cache.Host
port = cfg.Cache.Port
)
// cacheArgs has higher priority
if cacheArgs.Dir != "" {
dir = cacheArgs.Dir
}
if cacheArgs.Host != "" {
host = cacheArgs.Host
}
if cacheArgs.Port != 0 {
port = cacheArgs.Port
}
secret := cfg.Cache.ExternalSecret
if secret == "" {
if cfg.Cache.ExternalSecret == "" {
return errors.New("cache.external_secret (or cache.external_secret_file) must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
}
cacheHandler, err := artifactcache.StartHandler(artifactcache.Options{
Dir: dir,
OutboundIP: host,
Port: port,
InternalSecret: secret,
Dir: cmp.Or(cacheArgs.Dir, cfg.Cache.Dir),
OutboundIP: cmp.Or(cacheArgs.Host, cfg.Cache.Host),
Port: cmp.Or(cacheArgs.Port, cfg.Cache.Port),
InternalSecret: cfg.Cache.ExternalSecret,
Policy: run.CachePolicy(cfg),
Logger: logging.Default().With("module", "cache_request"),
})
+12 -28
View File
@@ -163,8 +163,8 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
}
}
if !slices.Equal(reg.Labels, ls.ToStrings()) {
reg.Labels = ls.ToStrings()
if labelStrings := ls.ToStrings(); !slices.Equal(reg.Labels, labelStrings) {
reg.Labels = labelStrings
if err := config.SaveRegistration(cfg.Runner.File, reg); err != nil {
return fmt.Errorf("failed to save runner config: %w", err)
}
@@ -244,26 +244,14 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
}
if daemArgs.Once || reg.Ephemeral {
done := make(chan struct{})
go func() {
defer close(done)
poller.PollOnce(func() { removeEphemeralRegistration(reg, cfg.Runner.File) })
}()
// shutdown when we complete a job or cancel is requested
select {
case <-ctx.Done():
case <-done:
}
go poller.PollOnce(func() { removeEphemeralRegistration(reg, cfg.Runner.File) })
} else {
go poller.Poll()
}
// Stop either on an external cancellation or when the poller shuts
// itself down (e.g. after the runner has been unregistered).
select {
case <-ctx.Done():
case <-poller.Done():
}
select {
case <-ctx.Done():
case <-poller.Done():
}
logging.Infof("runner: %s shutdown initiated, waiting %s for running jobs to complete before shutting down", resp.Runner.Name, cfg.Runner.ShutdownTimeout)
@@ -329,13 +317,8 @@ func splitLabels(s string) []string {
// initLogging sets up the process-wide logger.
func initLogging(cfg *config.Config) {
callPrettyfier := func(f *runtime.Frame) (string, string) {
// get function name
s := strings.Split(f.Function, ".")
funcname := "[" + s[len(s)-1] + "]"
// get file name and line number
_, filename := path.Split(f.File)
filename = "[" + filename + ":" + strconv.Itoa(f.Line) + "]"
return funcname, filename
return "[" + f.Function[strings.LastIndexByte(f.Function, '.')+1:] + "]", "[" + filename + ":" + strconv.Itoa(f.Line) + "]"
}
isTerm := term.IsTerminal(int(os.Stdout.Fd()))
@@ -407,11 +390,12 @@ func getDockerSocketPath(configDockerHost string) (string, error) {
}
for _, p := range commonSocketPaths {
if _, err := os.Lstat(os.ExpandEnv(p)); err == nil {
socket := os.ExpandEnv(p)
if _, err := os.Lstat(socket); err == nil {
if strings.HasPrefix(p, `\\.\`) {
return "npipe://" + filepath.ToSlash(os.ExpandEnv(p)), nil
return "npipe://" + filepath.ToSlash(socket), nil
}
return "unix://" + filepath.ToSlash(os.ExpandEnv(p)), nil
return "unix://" + filepath.ToSlash(socket), nil
}
}
+43 -75
View File
@@ -203,12 +203,8 @@ func parseKVAndFile(rawKVs []string, filePath string) map[string]string {
_ = readEnvs(filePath, result)
for _, raw := range rawKVs {
parts := strings.SplitN(raw, "=", 2)
if len(parts) == 2 {
result[parts[0]] = parts[1]
} else {
result[parts[0]] = ""
}
key, value, _ := strings.Cut(raw, "=")
result[key] = value
}
return result
}
@@ -265,39 +261,25 @@ func printList(plan *model.Plan) {
for i, stage := range plan.Stages {
for _, r := range stage.Runs {
jobID := r.JobID
line := lineInfoDef{
jobID: jobID,
jobID: r.JobID,
jobName: r.String(),
stage: strconv.Itoa(i),
wfName: r.Workflow.Name,
wfFile: r.Workflow.File,
events: strings.Join(r.Workflow.On(), `,`),
}
if _, ok := jobs[jobID]; ok {
if jobs[r.JobID] {
duplicateJobIDs = true
} else {
jobs[jobID] = true
}
jobs[r.JobID] = true
lineInfos = append(lineInfos, line)
if jobIDMaxWidth < len(line.jobID) {
jobIDMaxWidth = len(line.jobID)
}
if jobNameMaxWidth < len(line.jobName) {
jobNameMaxWidth = len(line.jobName)
}
if stageMaxWidth < len(line.stage) {
stageMaxWidth = len(line.stage)
}
if wfNameMaxWidth < len(line.wfName) {
wfNameMaxWidth = len(line.wfName)
}
if wfFileMaxWidth < len(line.wfFile) {
wfFileMaxWidth = len(line.wfFile)
}
if eventsMaxWidth < len(line.events) {
eventsMaxWidth = len(line.events)
}
jobIDMaxWidth = max(jobIDMaxWidth, len(line.jobID))
jobNameMaxWidth = max(jobNameMaxWidth, len(line.jobName))
stageMaxWidth = max(stageMaxWidth, len(line.stage))
wfNameMaxWidth = max(wfNameMaxWidth, len(line.wfName))
wfFileMaxWidth = max(wfFileMaxWidth, len(line.wfFile))
eventsMaxWidth = max(eventsMaxWidth, len(line.events))
}
}
@@ -356,21 +338,15 @@ func runExecList(planner model.WorkflowPlanner, execArgs *executeArgs) error {
case execArgs.job != "":
logging.Infof("Preparing plan with a job: %s", execArgs.job)
filterPlan, err = planner.PlanJob(execArgs.job)
if err != nil {
return err
}
case filterEventName != "":
logging.Infof("Preparing plan for a event: %s", filterEventName)
filterPlan, err = planner.PlanEvent(filterEventName)
if err != nil {
return err
}
default:
logging.Infof("Preparing plan with all jobs")
filterPlan, err = planner.PlanAll()
if err != nil {
return err
}
}
if err != nil {
return err
}
printList(filterPlan)
@@ -385,32 +361,31 @@ func (i *executeArgs) runnerConfig(eventName string, env, proxyEnv map[string]st
}
return &runner.Config{
Workdir: i.Workdir(),
BindWorkdir: false,
ForcePull: i.forcePull,
ForceRebuild: i.forceRebuild,
JSONLogger: i.jsonLogger,
Env: env,
ProxyEnv: proxyEnv,
Vars: i.LoadVars(),
Secrets: i.LoadSecrets(),
InsecureSecrets: i.insecureSecrets,
Privileged: i.privileged,
UsernsMode: i.usernsMode,
ContainerArchitecture: i.containerArchitecture,
ContainerDaemonSocket: i.containerDaemonSocket,
UseGitIgnore: i.useGitIgnore,
GitHubInstance: i.githubInstance,
ContainerCapAdd: i.containerCapAdd,
ContainerCapDrop: i.containerCapDrop,
ContainerOptions: i.containerOptions,
ArtifactServerPath: i.artifactServerPath,
ArtifactServerPort: i.artifactServerPort,
ArtifactServerAddr: i.artifactServerAddr,
NoSkipCheckout: i.noSkipCheckout,
EventName: eventName,
EventJSON: eventJSON,
// PresetGitHubContext: preset,
Workdir: i.Workdir(),
BindWorkdir: false,
ForcePull: i.forcePull,
ForceRebuild: i.forceRebuild,
JSONLogger: i.jsonLogger,
Env: env,
ProxyEnv: proxyEnv,
Vars: i.LoadVars(),
Secrets: i.LoadSecrets(),
InsecureSecrets: i.insecureSecrets,
Privileged: i.privileged,
UsernsMode: i.usernsMode,
ContainerArchitecture: i.containerArchitecture,
ContainerDaemonSocket: i.containerDaemonSocket,
UseGitIgnore: i.useGitIgnore,
GitHubInstance: i.githubInstance,
ContainerCapAdd: i.containerCapAdd,
ContainerCapDrop: i.containerCapDrop,
ContainerOptions: i.containerOptions,
ArtifactServerPath: i.artifactServerPath,
ArtifactServerPort: i.artifactServerPort,
ArtifactServerAddr: i.artifactServerAddr,
NoSkipCheckout: i.noSkipCheckout,
EventName: eventName,
EventJSON: eventJSON,
ContainerNamePrefix: "GITEA-ACTIONS-TASK-" + eventName,
ContainerMaxLifetime: maxLifetime,
ContainerNetworkMode: container.NetworkMode(i.network),
@@ -465,15 +440,12 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
if execArgs.job != "" {
logging.Infof("Planning job: %s", execArgs.job)
plan, err = planner.PlanJob(execArgs.job)
if err != nil {
return err
}
} else {
logging.Infof("Planning jobs for event: %s", eventName)
plan, err = planner.PlanEvent(eventName)
if err != nil {
return err
}
}
if err != nil {
return err
}
maxLifetime := 3 * time.Hour
@@ -548,11 +520,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
config.Env["ACT_EXEC"] = "true"
config.Secrets[actionsRuntimeTokenEnvName] = actionsRuntimeToken
if t := config.Secrets["GITEA_TOKEN"]; t != "" {
config.Token = t
} else if t := config.Secrets["GITHUB_TOKEN"]; t != "" {
config.Token = t
}
config.Token = cmp.Or(config.Secrets["GITEA_TOKEN"], config.Secrets["GITHUB_TOKEN"])
if !execArgs.debug {
logLevel := logging.LevelInfo
+6 -16
View File
@@ -117,10 +117,7 @@ func (r *registerInputs) validate() error {
if r.Token == "" {
return errors.New("token is empty")
}
if len(r.Labels) > 0 {
return validateLabels(r.Labels)
}
return nil
return validateLabels(r.Labels)
}
func validateLabels(ls []string) error {
@@ -141,20 +138,14 @@ func (r *registerInputs) stageValue(stage registerStage) string {
case StageInputRunnerName:
return r.RunnerName
case StageInputLabels:
if len(r.Labels) > 0 {
return strings.Join(r.Labels, ",")
}
return strings.Join(r.Labels, ",")
}
return ""
}
func (r *registerInputs) assignToNext(stage registerStage, value string, cfg *config.Config) registerStage {
// must set instance address and token.
// if empty, keep current stage.
if stage == StageInputInstance || stage == StageInputToken {
if value == "" {
return stage
}
if (stage == StageInputInstance || stage == StageInputToken) && value == "" {
return stage
}
// set hostname for runner name if empty
@@ -222,11 +213,10 @@ func initInputs(regArgs *registerArgs) (*registerInputs, error) {
case regArgs.Token != "":
token = regArgs.Token
default:
envToken, ok := os.LookupEnv(registerTokenEnvVar)
if !ok || envToken == "" {
token = os.Getenv(registerTokenEnvVar)
if token == "" {
return nil, fmt.Errorf("missing token, token-file argument, or %s environment variable", registerTokenEnvVar)
}
token = envToken
}
inputs := &registerInputs{
InstanceAddr: strings.TrimSpace(regArgs.InstanceAddr),
+54 -89
View File
@@ -65,7 +65,6 @@ func TestRegisterInputsValidate(t *testing.T) {
func TestValidateLabels(t *testing.T) {
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
// a colon that is not a supported schema is part of the label name
require.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"}))
require.Error(t, validateLabels([]string{"ubuntu:host", ""}))
}
@@ -149,97 +148,63 @@ func TestRegisterInputsAssignToNext(t *testing.T) {
}
func TestInitInputs(t *testing.T) {
t.Run("missing token", func(t *testing.T) {
_, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
for _, tc := range []struct {
name, tokenFile, wantErr string
env *string
}{
{name: "missing token", wantErr: "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable"},
{name: "empty token", env: new(""), wantErr: "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable"},
{
name: "invalid token file", tokenFile: "/tmp/nonexistent", env: new("from-env"),
wantErr: "cannot read the token file: /tmp/nonexistent, open /tmp/nonexistent: no such file or directory",
},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.env != nil {
t.Setenv(registerTokenEnvVar, *tc.env)
}
_, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
TokenFile: tc.tokenFile,
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.EqualError(t, err, tc.wantErr)
})
require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable")
})
}
t.Run("empty token", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "")
_, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
Token: "",
TokenFile: "",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
for _, tc := range []struct {
name, address, token, runnerName, wantToken string
file bool
}{
{name: "valid token", address: " http://localhost:3000 ", token: "from-plain-arg\n", runnerName: "runner\n", wantToken: "from-plain-arg"},
{name: "valid token file", address: "http://localhost:3000", runnerName: "runner", file: true, wantToken: "from-file"},
{name: "token from environment variable", address: "http://localhost:3000", runnerName: "runner", wantToken: "from-env"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
var tokenFile string
if tc.file {
tokenFile = filepath.Join(t.TempDir(), "from-file")
require.NoError(t, os.WriteFile(tokenFile, []byte("from-file\n"), 0o600))
}
inputs, err := initInputs(&registerArgs{
InstanceAddr: tc.address,
Token: tc.token,
TokenFile: tokenFile,
RunnerName: tc.runnerName,
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.NoError(t, err)
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, tc.wantToken, inputs.Token)
require.Equal(t, "runner", inputs.RunnerName)
require.True(t, inputs.Ephemeral)
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
})
require.EqualError(t, err, "missing token, token-file argument, or GITEA_RUNNER_REGISTRATION_TOKEN environment variable")
})
t.Run("invalid token file", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
_, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
TokenFile: "/tmp/nonexistent",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.EqualError(t, err, "cannot read the token file: /tmp/nonexistent, open /tmp/nonexistent: no such file or directory")
})
t.Run("valid token", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
inputs, err := initInputs(&registerArgs{
InstanceAddr: " http://localhost:3000 ",
Token: "from-plain-arg\n",
RunnerName: "runner\n",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.NoError(t, err)
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, "from-plain-arg", inputs.Token)
require.Equal(t, "runner", inputs.RunnerName)
require.True(t, inputs.Ephemeral)
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
})
t.Run("valid token file", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
tokenFile, createErr := os.CreateTemp(t.TempDir(), "from-file")
require.NoError(t, createErr)
defer tokenFile.Close()
_, writeErr := tokenFile.WriteString("from-file\n")
require.NoError(t, writeErr)
_ = tokenFile.Sync()
inputs, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
TokenFile: tokenFile.Name(),
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.NoError(t, err)
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, "from-file", inputs.Token)
require.Equal(t, "runner", inputs.RunnerName)
require.True(t, inputs.Ephemeral)
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
})
t.Run("token from environment variable", func(t *testing.T) {
t.Setenv(registerTokenEnvVar, "from-env")
inputs, err := initInputs(&registerArgs{
InstanceAddr: "http://localhost:3000",
RunnerName: "runner",
Ephemeral: true,
Labels: " ubuntu:host , ubuntu:docker://node:18 ",
})
require.NoError(t, err)
require.Equal(t, "http://localhost:3000", inputs.InstanceAddr)
require.Equal(t, "from-env", inputs.Token)
require.Equal(t, "runner", inputs.RunnerName)
require.True(t, inputs.Ephemeral)
require.Equal(t, []string{"ubuntu:host ", " ubuntu:docker://node:18"}, inputs.Labels)
})
}
t.Run("empty labels", func(t *testing.T) {
inputs, _ := initInputs(&registerArgs{
+17 -29
View File
@@ -83,8 +83,6 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller {
jobsCtx, shutdownJobs := context.WithCancel(context.Background())
done := make(chan struct{})
p := &Poller{
client: client,
runner: runner,
@@ -96,11 +94,11 @@ func New(cfg *config.Config, client client.Client, runner TaskRunner) *Poller {
jobsCtx: jobsCtx,
shutdownJobs: shutdownJobs,
done: done,
done: make(chan struct{}),
availabilityReady: true,
availabilityReason: "ok",
}
p.lastHealthyPoll.Store(time.Now().UnixNano())
p.availabilityReady = true
p.availabilityReason = "ok"
return p
}
@@ -132,8 +130,8 @@ func (p *Poller) Poll() {
continue
}
task, ok := p.fetchTask(p.pollingCtx, s)
if !ok {
task := p.fetchTask(p.pollingCtx, s)
if task == nil {
p.runIdleMaintenance()
<-sem
if !p.waitBackoff(s) {
@@ -166,8 +164,8 @@ func (p *Poller) PollOnce(onTaskFetched func()) {
}
continue
}
task, ok := p.fetchTask(p.pollingCtx, s)
if !ok {
task := p.fetchTask(p.pollingCtx, s)
if task == nil {
p.runIdleMaintenance()
if !p.waitBackoff(s) {
return
@@ -348,7 +346,7 @@ func (p *Poller) runTaskWithRecover(ctx context.Context, task *runnerv1.Task) {
}
}
func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task, bool) {
func (p *Poller) fetchTask(ctx context.Context, s *workerState) *runnerv1.Task {
reqCtx, cancel := context.WithTimeout(ctx, p.cfg.Runner.FetchTimeout)
defer cancel()
@@ -370,7 +368,7 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
}
s.consecutiveEmpty++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
return nil, false
return nil
}
s.fetchTimedOut = false
metrics.PollFetchDuration.Observe(time.Since(start).Seconds())
@@ -385,22 +383,17 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
p.unregistered.Store(true)
p.registrationRejected.Store(rejected)
p.shutdownPolling()
return nil, false
}
// Not a long poll, so a deadline can mean the server assigned a task
// this runner never received.
if errors.Is(err, context.DeadlineExceeded) {
logging.WithError(err).Errorf("fetching a task timed out after %s", p.cfg.Runner.FetchTimeout)
} else {
logging.WithError(err).Error("failed to fetch task")
return nil
}
logging.WithError(err).Error("failed to fetch task")
p.lastPollFailed.Store(true)
s.consecutiveErrors++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultError).Inc()
metrics.ClientErrors.WithLabelValues(metrics.LabelMethodFetchTask, connect.CodeOf(err).String()).Inc()
return nil, false
return nil
}
p.markHealthyPoll()
p.lastHealthyPoll.Store(time.Now().UnixNano())
p.lastPollFailed.Store(false)
// Successful response — reset error counter.
s.consecutiveErrors = 0
@@ -408,7 +401,7 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
if resp == nil {
s.consecutiveEmpty++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
return nil, false
return nil
}
if resp.TasksVersion > v {
@@ -418,17 +411,12 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
if resp.Task == nil {
s.consecutiveEmpty++
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultEmpty).Inc()
return nil, false
return nil
}
// got a task, set `tasksVersion` to zero to force query db in next request.
p.tasksVersion.CompareAndSwap(resp.TasksVersion, 0)
metrics.PollFetchTotal.WithLabelValues(metrics.LabelResultTask).Inc()
return resp.Task, true
}
func (p *Poller) markHealthyPoll() {
p.lastHealthyPoll.Store(time.Now().UnixNano())
p.lastPollFailed.Store(false)
return resp.Task
}
+42 -100
View File
@@ -22,14 +22,10 @@ import (
"github.com/stretchr/testify/require"
)
// TestPoller_WorkerStateCounters verifies that workerState correctly tracks
// consecutive empty responses independently per state instance, and that
// fetchTask increments only the relevant counter.
func TestPoller_WorkerStateCounters(t *testing.T) {
client := mocks.NewClient(t)
client.On("FetchTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *runnerv1.FetchTaskRequest) (*runnerv1.FetchTaskResponse, error) {
// Always return an empty response.
return &runnerv1.FetchTaskResponse{}, nil
},
)
@@ -42,24 +38,17 @@ func TestPoller_WorkerStateCounters(t *testing.T) {
s1 := &workerState{}
s2 := &workerState{}
// Each worker independently observes one empty response.
_, ok := p.fetchTask(ctx, s1)
require.False(t, ok)
_, ok = p.fetchTask(ctx, s2)
require.False(t, ok)
require.Nil(t, p.fetchTask(ctx, s1))
require.Nil(t, p.fetchTask(ctx, s2))
assert.Equal(t, int64(1), s1.consecutiveEmpty, "worker 1 should only count its own empty response")
assert.Equal(t, int64(1), s2.consecutiveEmpty, "worker 2 should only count its own empty response")
// Worker 1 sees a second empty; worker 2 stays at 1.
_, ok = p.fetchTask(ctx, s1)
require.False(t, ok)
require.Nil(t, p.fetchTask(ctx, s1))
assert.Equal(t, int64(2), s1.consecutiveEmpty)
assert.Equal(t, int64(1), s2.consecutiveEmpty, "worker 2's counter must not be affected by worker 1's empty fetches")
}
// TestPoller_FetchErrorIncrementsErrorsOnly verifies that a fetch error
// increments only the per-worker error counter, not the empty counter.
func TestPoller_FetchErrorIncrementsErrorsOnly(t *testing.T) {
client := mocks.NewClient(t)
client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, errors.New("network unreachable"))
@@ -69,15 +58,11 @@ func TestPoller_FetchErrorIncrementsErrorsOnly(t *testing.T) {
p := &Poller{client: client, cfg: cfg}
s := &workerState{}
_, ok := p.fetchTask(t.Context(), s)
require.False(t, ok)
require.Nil(t, p.fetchTask(t.Context(), s))
assert.Equal(t, int64(1), s.consecutiveErrors)
assert.Equal(t, int64(0), s.consecutiveEmpty)
}
// A deadline is the idle path once the server holds the request open, and a
// symptom before then, so it must neither reset the error count nor assert that
// the server is reachable.
func TestPoller_FetchTimeoutIsNoSignal(t *testing.T) {
client := mocks.NewClient(t)
client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, context.DeadlineExceeded)
@@ -90,20 +75,15 @@ func TestPoller_FetchTimeoutIsNoSignal(t *testing.T) {
hook := logtest.SetDefault(t)
s := &workerState{consecutiveErrors: 2}
_, ok := p.fetchTask(t.Context(), s)
require.False(t, ok)
require.Nil(t, p.fetchTask(t.Context(), s))
assert.Equal(t, int64(2), s.consecutiveErrors)
assert.Equal(t, int64(1), s.consecutiveEmpty)
assert.True(t, p.lastPollFailed.Load(), "a timeout must not clear a known failure")
// An idle runner against a server holding the request open must not flood.
_, _ = p.fetchTask(t.Context(), s)
p.fetchTask(t.Context(), s)
assert.Len(t, hook.AllRecords(), 1)
}
// TestPoller_FetchUnauthenticatedStopsPolling verifies that an Unauthenticated
// response marks the runner as unregistered and cancels the polling context so
// the daemon can exit instead of retrying forever.
func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
for name, tc := range map[string]struct {
fetchErr error
@@ -120,8 +100,7 @@ func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
require.NoError(t, err)
p := New(cfg, client, nil)
s := &workerState{}
_, ok := p.fetchTask(t.Context(), s)
require.False(t, ok)
require.Nil(t, p.fetchTask(t.Context(), s))
assert.True(t, p.Unregistered(), "runner should be marked unregistered")
assert.Equal(t, tc.rejected, p.RegistrationRejected())
@@ -135,8 +114,6 @@ func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
}
}
// TestPoller_CalculateInterval verifies the exponential backoff math is
// correctly driven by the workerState counters.
func TestPoller_CalculateInterval(t *testing.T) {
cfg, err := config.LoadDefault("")
require.NoError(t, err)
@@ -165,7 +142,6 @@ func TestPoller_CalculateInterval(t *testing.T) {
}
}
// atomicMax atomically updates target to max(target, val).
func atomicMax(target *atomic.Int64, val int64) {
for {
old := target.Load()
@@ -205,7 +181,6 @@ func TestPollerReady(t *testing.T) {
require.NoError(t, err)
poller := New(cfg, nil, &availabilityRunner{})
// /readyz reuses the availability the poll loop last recorded.
ready, reason := poller.Ready(time.Second)
assert.True(t, ready)
assert.Equal(t, "ok", reason)
@@ -291,63 +266,45 @@ func (m *idleAwareRunner) OnIdle(_ context.Context) {
m.idleCalls.Add(1)
}
func TestPollerPollCallsOnIdle(t *testing.T) {
cli := mocks.NewClient(t)
cli.On("FetchTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *runnerv1.FetchTaskRequest) (*runnerv1.FetchTaskResponse, error) {
return &runnerv1.FetchTaskResponse{}, nil
},
)
func TestPollerCallsOnIdle(t *testing.T) {
for _, pollOnce := range []bool{false, true} {
name := "Poll"
if pollOnce {
name = "PollOnce"
}
t.Run(name, func(t *testing.T) {
cli := mocks.NewClient(t)
cli.On("FetchTask", mock.Anything, mock.Anything).Return(&runnerv1.FetchTaskResponse{}, nil)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.Runner.Capacity = 1
cfg.Runner.FetchInterval = 10 * time.Millisecond
cfg.Runner.FetchIntervalMax = 10 * time.Millisecond
cfg, err := config.LoadDefault("")
require.NoError(t, err)
if !pollOnce {
cfg.Runner.Capacity = 1
}
cfg.Runner.FetchInterval = 10 * time.Millisecond
cfg.Runner.FetchIntervalMax = 10 * time.Millisecond
runner := &idleAwareRunner{}
poller := New(cfg, cli, runner)
runner := &idleAwareRunner{}
poller := New(cfg, cli, runner)
var wg sync.WaitGroup
wg.Go(func() {
if pollOnce {
poller.PollOnce(func() {})
} else {
poller.Poll()
}
})
var wg sync.WaitGroup
wg.Go(poller.Poll)
require.Eventually(t, func() bool {
return runner.idleCalls.Load() > 0
}, time.Second, 10*time.Millisecond)
require.Eventually(t, func() bool {
return runner.idleCalls.Load() > 0
}, time.Second, 10*time.Millisecond)
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
require.NoError(t, poller.Shutdown(ctx))
wg.Wait()
}
func TestPollerPollOnceCallsOnIdle(t *testing.T) {
cli := mocks.NewClient(t)
cli.On("FetchTask", mock.Anything, mock.Anything).Return(
func(_ context.Context, _ *runnerv1.FetchTaskRequest) (*runnerv1.FetchTaskResponse, error) {
return &runnerv1.FetchTaskResponse{}, nil
},
)
cfg, err := config.LoadDefault("")
require.NoError(t, err)
cfg.Runner.FetchInterval = 10 * time.Millisecond
cfg.Runner.FetchIntervalMax = 10 * time.Millisecond
runner := &idleAwareRunner{}
poller := New(cfg, cli, runner)
var wg sync.WaitGroup
wg.Go(func() { poller.PollOnce(func() {}) })
require.Eventually(t, func() bool {
return runner.idleCalls.Load() > 0
}, time.Second, 10*time.Millisecond)
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
require.NoError(t, poller.Shutdown(ctx))
wg.Wait()
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
require.NoError(t, poller.Shutdown(ctx))
wg.Wait()
})
}
}
func TestPollerPollOnceCallsOnTaskFetchedBeforeRunningIt(t *testing.T) {
@@ -362,9 +319,6 @@ func TestPollerPollOnceCallsOnTaskFetchedBeforeRunningIt(t *testing.T) {
assert.Equal(t, int64(1), runner.totalCompleted.Load())
}
// TestPoller_ConcurrencyLimitedByCapacity verifies that with capacity=3 and
// 6 available tasks, at most 3 tasks run concurrently, and FetchTask is
// never called concurrently (single poller).
func TestPoller_ConcurrencyLimitedByCapacity(t *testing.T) {
const (
capacity = 3
@@ -427,13 +381,6 @@ func TestPoller_ConcurrencyLimitedByCapacity(t *testing.T) {
"all tasks should have been executed")
}
// TestPoller_ShutdownForcesJobsOnTimeout locks in the fix for a
// pre-existing bug where Shutdown's timeout branch used a blocking
// `<-p.done` receive, leaving p.shutdownJobs() unreachable. With a
// task parked on jobsCtx and a Shutdown deadline shorter than the
// task's natural completion, Shutdown must force-cancel via
// shutdownJobs() and return ctx.Err() promptly — not block until the
// task would have finished on its own.
func TestPoller_ShutdownForcesJobsOnTimeout(t *testing.T) {
var served atomic.Bool
cli := mocks.NewClient(t)
@@ -448,8 +395,6 @@ func TestPoller_ShutdownForcesJobsOnTimeout(t *testing.T) {
},
)
// delay >> Shutdown timeout: Run only returns when jobsCtx is
// cancelled by shutdownJobs().
runner := &mockRunner{delay: 30 * time.Second}
cfg, err := config.LoadDefault("")
@@ -474,9 +419,6 @@ func TestPoller_ShutdownForcesJobsOnTimeout(t *testing.T) {
elapsed := time.Since(start)
require.ErrorIs(t, err, context.DeadlineExceeded)
// With the fix, Shutdown returns shortly after the deadline once
// the forced job unwinds. Without the fix, the blocking <-p.done
// would hang for the full 30s mockRunner delay.
assert.Less(t, elapsed, 5*time.Second,
"Shutdown must not block on the parked task; shutdownJobs() must run on timeout")
+1 -4
View File
@@ -33,7 +33,7 @@ func (r *Runner) checkConfiguredHealth(ctx context.Context) (bool, string) {
}
env := processEnvironment()
maps.Copy(env, r.cloneEnvs())
maps.Copy(env, r.envs)
env["GITEA_RUNNER_HEALTH_CHECK"] = "true"
env["GITEA_RUNNER_NAME"] = r.name
if r.client != nil {
@@ -84,9 +84,6 @@ func executeHealthCheck(ctx context.Context, script string, timeout time.Duratio
if errors.Is(checkCtx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("timed out after %s", timeout)
}
if err == nil {
return nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
return fmt.Errorf("exited with code %d", exitErr.ExitCode())
}
+5 -12
View File
@@ -6,7 +6,6 @@ package run
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"strconv"
@@ -69,15 +68,9 @@ func (r *Runner) runPostTaskScript(ctx context.Context, reporter *report.Reporte
// copiers have finished (cmd.Wait, bounded by WaitDelay above, guarantees it).
common.FlushWriter(stdout)
common.FlushWriter(stderr)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
logging.Warnf("post-task script %q for task %d: %v", script, task.Id, err)
return
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
logging.Warnf("post-task script %q for task %d exited with code %d", script, task.Id, exitErr.ExitCode())
return
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
logging.Warnf("post-task script %q for task %d exited with code %d", script, task.Id, exitErr.ExitCode())
} else if err != nil {
logging.Warnf("post-task script %q for task %d: %v", script, task.Id, err)
}
}
@@ -111,8 +104,8 @@ func (r *Runner) postTaskScriptEnv(reporter *report.Reporter, task *runnerv1.Tas
func envListFromMap(env map[string]string) []string {
envList := make([]string, 0, len(env))
for k, v := range env {
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
for key, value := range env {
envList = append(envList, key+"="+value)
}
return envList
}
+2 -5
View File
@@ -120,7 +120,7 @@ func proxyPasswords() []string {
var passwords []string
for _, raw := range []string{cfg.HTTPProxy, cfg.HTTPSProxy} {
parsed, err := url.Parse(raw)
if err != nil || parsed.User == nil {
if err != nil {
continue
}
if password, ok := parsed.User.Password(); ok && password != "" && !slices.Contains(passwords, password) {
@@ -152,11 +152,8 @@ func appendNoProxy(noProxy string, hosts ...string) string {
// hostOf returns the host of a URL without its port, the form a no_proxy entry takes. It is
// empty for anything without a network host, such as a unix socket.
func hostOf(raw string) string {
if raw == "" {
return ""
}
parsed, err := url.Parse(strings.TrimSuffix(raw, "/"))
if err != nil || parsed.Hostname() == "" {
if err != nil {
return ""
}
return parsed.Hostname()
+10 -33
View File
@@ -99,7 +99,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
envs := make(map[string]string, len(cfg.Runner.Envs))
maps.Copy(envs, cfg.Runner.Envs)
var cacheHandler *artifactcache.Handler
if cacheEnabled(cfg) {
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
warnIgnoredCachePolicy(cfg)
warnIgnoredCacheSecret(cfg)
handler, err := artifactcache.StartHandler(artifactcache.Options{
@@ -121,8 +121,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
}
// set artifact gitea api
artifactGiteaAPI := strings.TrimSuffix(cli.Address(), "/") + "/api/actions_pipeline/"
envs["ACTIONS_RUNTIME_URL"] = artifactGiteaAPI
envs["ACTIONS_RUNTIME_URL"] = strings.TrimSuffix(cli.Address(), "/") + "/api/actions_pipeline/"
envs["ACTIONS_RESULTS_URL"] = strings.TrimSuffix(cli.Address(), "/")
// Set specific environments to distinguish between Gitea and GitHub
@@ -169,9 +168,7 @@ func (r *Runner) OnIdle(ctx context.Context) {
}
// Bind-workdir mode: reclaim stale per-task workspace dirs (numeric task IDs).
if r.cfg.Container.BindWorkdir {
workdirParent := strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")
workdirRoot := filepath.FromSlash("/" + workdirParent)
r.cleanupStaleDirs(ctx, workdirRoot, isTaskIDDir)
r.cleanupStaleDirs(ctx, filepath.FromSlash("/"+strings.TrimLeft(r.cfg.Container.WorkdirParent, "/")), isTaskIDDir)
}
// Host mode: reclaim per-job scratch dirs left behind when HostEnvironment
// cleanup timed out (e.g. a delete stalled by an AV/EDR filter driver). They
@@ -270,10 +267,7 @@ func (r *Runner) cleanupStaleDirs(ctx context.Context, root string, match func(n
if err := ctx.Err(); err != nil {
return
}
if !entry.IsDir() {
continue
}
if !match(entry.Name()) {
if !entry.IsDir() || !match(entry.Name()) {
continue
}
info, err := entry.Info()
@@ -452,7 +446,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
}()
workflowCallInputs := restoreCalledWorkflowContext(task)
r.reportSetup(reporter, task)
for _, line := range r.setupLines(task) {
reporter.Logf("%s", line)
}
workflow, jobID, err := generateWorkflow(task)
if err != nil {
@@ -511,11 +507,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
telemetry.AddJobEnv(ctx, envs)
preset := model.GithubContextFromMap(task.Context.AsMap())
if t := task.Secrets["GITEA_TOKEN"]; t != "" {
preset.Token = t
} else if t := task.Secrets["GITHUB_TOKEN"]; t != "" {
preset.Token = t
}
preset.Token = cmp.Or(task.Secrets["GITEA_TOKEN"], task.Secrets["GITHUB_TOKEN"], preset.Token)
if actionsIDTokenRequestURL := taskContext["actions_id_token_request_url"].GetStringValue(); actionsIDTokenRequestURL != "" {
envs["ACTIONS_ID_TOKEN_REQUEST_URL"] = actionsIDTokenRequestURL
@@ -589,7 +581,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
ForcePull: r.cfg.Container.ForcePull,
ForceRebuild: r.cfg.Container.ForceRebuild,
JSONLogger: false,
Env: envs,
ProxyEnv: proxyEnv,
Secrets: task.Secrets,
@@ -672,18 +663,8 @@ func (r *Runner) builtInCacheURL() string {
return r.envs["ACTIONS_CACHE_URL"]
}
func cacheEnabled(cfg *config.Config) bool {
return cfg.Cache.Enabled == nil || *cfg.Cache.Enabled
}
// cacheServiceV2 reports whether jobs are told the cache service speaks v2. It is all cache.v2
// turns off: the bundle edit that reaches it is what the artifact actions need too.
func (r *Runner) cacheServiceV2() bool {
return r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2
}
func (r *Runner) setResultsService(envs map[string]string, resultsURL string) {
v2 := resultsURL != "" && r.cacheServiceV2()
v2 := resultsURL != "" && (r.cfg.Cache.V2 == nil || *r.cfg.Cache.V2) // all cache.v2 turns off, the bundle edit reaching it is what the artifact actions need too
if v2 {
envs[runner.CacheServiceV2Env] = "true"
} else {
@@ -902,11 +883,7 @@ func warnIgnoredCachePolicy(cfg *config.Config) {
// warnIgnoredCacheSecret flags an external cache server secret configured on a runner that uses the built-in cache server.
func warnIgnoredCacheSecret(cfg *config.Config) {
if cfg.Cache.ExternalServer != "" {
return
}
// Not using an external cache server, so any configured secret is ignored.
if cfg.Cache.ExternalSecret == "" {
if cfg.Cache.ExternalServer != "" || cfg.Cache.ExternalSecret == "" {
return
}
// LoadDefault resolves external_secret_file into ExternalSecret, so report whichever key the operator actually wrote.
+29 -58
View File
@@ -4,7 +4,6 @@
package run
import (
"bytes"
"encoding/json/v2"
"fmt"
"io"
@@ -22,53 +21,34 @@ import (
"github.com/stretchr/testify/require"
)
func emptyCfg() *config.Config { return &config.Config{} }
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
for token, handler := range map[string]*artifactcache.Handler{"tok": nil, "": {}} {
r := &Runner{cfg: emptyCfg(), cacheHandler: handler}
r := &Runner{cacheHandler: handler}
unregister, _ := r.registerCacheForTask(token, "owner/repo", "", nil)
require.NotNil(t, unregister, token)
unregister()
}
}
// Locks in @actions/cache's wire protocol: bearer on reserve/upload/commit
// /find, no auth on the signed archiveLocation download.
func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
dir := filepath.Join(t.TempDir(), "artifactcache")
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.1"})
require.NoError(t, err)
defer handler.Close()
r := &Runner{cfg: emptyCfg(), cacheHandler: handler, envs: map[string]string{"ACTIONS_RESULTS_URL": "https://gitea.example"}}
r := &Runner{cfg: &config.Config{}, cacheHandler: handler, envs: map[string]string{"ACTIONS_RESULTS_URL": "https://gitea.example"}}
const publicURL = "http://a1b2c3d4e5f6:8088"
token := "full-flow-token"
unregister, resultsURL := r.registerCacheForTask(token, "owner/repo", publicURL, nil)
assert.Equal(t, publicURL, resultsURL)
base := handler.ExternalURL() + "/_apis/artifactcache"
do := func(method, url, contentType, contentRange, body string) *http.Response {
req, err := http.NewRequest(method, url, strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if contentRange != "" {
req.Header.Set("Content-Range", contentRange)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return resp
}
key := "toolkit-flow"
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
body := `hello-cache-body`
// reserve
resp := do(http.MethodPost, base+"/caches", "application/json", "",
resp := cacheRequest(t, token, http.MethodPost, base+"/caches", "application/json", "",
fmt.Sprintf(`{"key":"%s","version":"%s","cacheSize":%d}`, key, version, len(body)))
require.Equal(t, http.StatusOK, resp.StatusCode)
var reserved struct {
@@ -77,19 +57,16 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
require.NoError(t, decodeJSON(resp, &reserved))
require.NotZero(t, reserved.CacheID)
// upload
resp = do(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID),
resp = cacheRequest(t, token, http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID),
"application/octet-stream", fmt.Sprintf("bytes 0-%d/*", len(body)-1), body)
require.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
// commit
resp = do(http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", "", "")
resp = cacheRequest(t, token, http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", "", "")
require.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
// find — @actions/cache always sends comma-separated keys here
resp = do(http.MethodGet,
resp = cacheRequest(t, token, http.MethodGet,
fmt.Sprintf("%s/cache?keys=%s,fallback&version=%s", base, key, version), "", "", "")
require.Equal(t, http.StatusOK, resp.StatusCode)
var hit struct {
@@ -100,8 +77,6 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
require.Equal(t, key, hit.CacheKey)
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
// download — toolkit does NOT attach Authorization here; the signature
// in the URL must be enough.
dl, err := http.Get(strings.Replace(hit.ArchiveLocation, publicURL, handler.ExternalURL(), 1))
require.NoError(t, err)
defer dl.Body.Close()
@@ -111,18 +86,32 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
assert.Equal(t, body, string(got[:n]))
unregister()
resp = do(http.MethodGet, fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version), "", "", "")
resp = cacheRequest(t, token, http.MethodGet, fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version), "", "", "")
resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
}
func cacheRequest(t *testing.T, token, method, url, contentType, contentRange, body string) *http.Response {
t.Helper()
req, err := http.NewRequest(method, url, strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if contentRange != "" {
req.Header.Set("Content-Range", contentRange)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return resp
}
func decodeJSON(resp *http.Response, v any) error {
defer resp.Body.Close()
return json.UnmarshalRead(resp.Body, v)
}
// End-to-end through a runner to a remote cache-server: token unknown → 401, register →
// reserve/upload/commit/find/download all OK, revoke → 401 again.
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
const secret = "shared-secret-for-tests"
remote, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InternalSecret: secret})
@@ -154,10 +143,7 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
repo := "owner/repoX"
base := r.envs["ACTIONS_CACHE_URL"] + "_apis/artifactcache"
probe := func() int {
req, _ := http.NewRequest(http.MethodGet, base+"/cache?keys=k&version=v", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp := cacheRequest(t, token, http.MethodGet, base+"/cache?keys=k&version=v", "", "", "")
resp.Body.Close()
return resp.StatusCode
}
@@ -181,15 +167,9 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
require.NoError(t, decodeJSON(forwarded, &artifactResult))
assert.True(t, artifactResult["ok"])
// Full reserve→upload→commit→find→download cycle, identical to what
// @actions/cache does, through the runner to the remote server.
body := []byte("payload-from-task")
reserveBody, _ := json.Marshal(&artifactcache.Request{Key: "ext-key", Version: "v", Size: int64(len(body))})
req, _ := http.NewRequest(http.MethodPost, base+"/caches", bytes.NewReader(reserveBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp := cacheRequest(t, token, http.MethodPost, base+"/caches", "application/json", "", string(reserveBody))
require.Equal(t, http.StatusOK, resp.StatusCode)
var reserved struct {
CacheID uint64 `json:"cacheId"`
@@ -197,25 +177,16 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
require.NoError(t, decodeJSON(resp, &reserved))
require.NotZero(t, reserved.CacheID)
req, _ = http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Range", fmt.Sprintf("bytes 0-%d/*", len(body)-1))
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
resp = cacheRequest(t, token, http.MethodPatch, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID),
"", fmt.Sprintf("bytes 0-%d/*", len(body)-1), string(body))
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
resp = cacheRequest(t, token, http.MethodPost, fmt.Sprintf("%s/caches/%d", base, reserved.CacheID), "", "", "")
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
req, _ = http.NewRequest(http.MethodGet, base+"/cache?keys=ext-key&version=v", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = http.DefaultClient.Do(req)
require.NoError(t, err)
resp = cacheRequest(t, token, http.MethodGet, base+"/cache?keys=ext-key&version=v", "", "", "")
require.Equal(t, http.StatusOK, resp.StatusCode)
var hit struct {
ArchiveLocation string `json:"archiveLocation"`
+5 -26
View File
@@ -8,31 +8,8 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRunnerCloneEnvsReturnsTaskLocalCopy(t *testing.T) {
r := &Runner{
envs: map[string]string{
"ACTIONS_CACHE_URL": "http://cache.example",
"ACTIONS_RUNTIME_URL": "http://runner.example",
},
}
cloned := r.cloneEnvs()
require.Equal(t, r.envs, cloned)
cloned["ACTIONS_RUNTIME_TOKEN"] = "task-token"
cloned["ACTIONS_ID_TOKEN_REQUEST_URL"] = "http://oidc.example"
assert.NotContains(t, r.envs, "ACTIONS_RUNTIME_TOKEN")
assert.NotContains(t, r.envs, "ACTIONS_ID_TOKEN_REQUEST_URL")
assert.Equal(t, "http://cache.example", r.envs["ACTIONS_CACHE_URL"])
}
// Regression test for #958: concurrent tasks writing task-specific env keys
// used to race on the shared r.envs map and crash the runner with
// "fatal error: concurrent map writes". Each task must mutate its own clone.
func TestRunnerCloneEnvsConcurrentMutation(t *testing.T) {
r := &Runner{
envs: map[string]string{
@@ -48,6 +25,7 @@ func TestRunnerCloneEnvsConcurrentMutation(t *testing.T) {
go func() {
defer wg.Done()
envs := r.cloneEnvs()
assert.Equal(t, r.envs, envs)
envs["ACTIONS_RUNTIME_TOKEN"] = "task-token"
envs["ACTIONS_ID_TOKEN_REQUEST_URL"] = "http://oidc.example"
envs["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] = "oidc-token"
@@ -55,7 +33,8 @@ func TestRunnerCloneEnvsConcurrentMutation(t *testing.T) {
}
wg.Wait()
assert.NotContains(t, r.envs, "ACTIONS_RUNTIME_TOKEN")
assert.NotContains(t, r.envs, "ACTIONS_ID_TOKEN_REQUEST_URL")
assert.NotContains(t, r.envs, "ACTIONS_ID_TOKEN_REQUEST_TOKEN")
assert.Equal(t, map[string]string{
"ACTIONS_CACHE_URL": "http://cache.example",
"ACTIONS_RUNTIME_URL": "http://runner.example",
}, r.envs)
}
+4 -20
View File
@@ -12,7 +12,6 @@ import (
"strconv"
"strings"
"gitea.com/gitea/runner/internal/pkg/report"
"gitea.com/gitea/runner/internal/pkg/ver"
runnerv1 "gitea.dev/actionslib/runner/v1"
@@ -23,14 +22,6 @@ import (
// falls back to the Go runtime alone. A var so tests can point it at a fixture.
var osReleasePath = "/etc/os-release"
// reportSetup opens the job log the way actions/runner opens its "Set up job" step. The action
// downloads and the closing job name are written later, as the job starts.
func (r *Runner) reportSetup(reporter *report.Reporter, task *runnerv1.Task) {
for _, line := range r.setupLines(task) {
reporter.Logf("%s", line)
}
}
// setupLines names the runner, then reports what it was asked to run and the host it runs on, each
// in its own group.
func (r *Runner) setupLines(task *runnerv1.Task) []string {
@@ -52,8 +43,10 @@ func (r *Runner) setupLines(task *runnerv1.Task) []string {
)
lines = append(lines, inputLines(fields)...)
lines = append(lines, "::group::Operating System")
lines = append(lines, osInfo()...)
return append(lines, "::endgroup::")
if name := prettyOSName(); name != "" {
lines = append(lines, name)
}
return append(lines, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), "::endgroup::")
}
// inputLines lists the inputs of the job: those Gitea resolved for a called workflow's job,
@@ -75,15 +68,6 @@ func inputLines(fields map[string]*structpb.Value) []string {
return append(lines, "::endgroup::")
}
// osInfo describes the host the runner executes on.
func osInfo() []string {
lines := make([]string, 0, 2)
if name := prettyOSName(); name != "" {
lines = append(lines, name)
}
return append(lines, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH))
}
// prettyOSName reads PRETTY_NAME (e.g. "Ubuntu 24.04.4 LTS") from os-release, or "" when absent.
func prettyOSName() string {
data, err := os.ReadFile(osReleasePath)
+13 -31
View File
@@ -96,33 +96,23 @@ func TestSetupLinesInputs(t *testing.T) {
}
func TestPrettyOSName(t *testing.T) {
tests := map[string]struct {
for _, tt := range []struct {
name string
osRelease string
want string
missing bool
}{
"quoted value": {
osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nPRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n",
want: "Ubuntu 24.04.4 LTS",
},
"unquoted value": {
osRelease: "PRETTY_NAME=Alpine Linux v3.21\n",
want: "Alpine Linux v3.21",
},
"no pretty name": {
osRelease: "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\n",
want: "",
},
// A key that merely ends in PRETTY_NAME must not be mistaken for it.
"similar key": {
osRelease: "IMAGE_PRETTY_NAME=\"Ubuntu Core 24\"\n",
want: "",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
{"quoted value", "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\nPRETTY_NAME=\"Ubuntu 24.04.4 LTS\"\n", "Ubuntu 24.04.4 LTS", false},
{"unquoted value", "PRETTY_NAME=Alpine Linux v3.21\n", "Alpine Linux v3.21", false},
{"no pretty name", "NAME=\"Ubuntu\"\nVERSION_ID=\"24.04\"\n", "", false},
{"similar key", "IMAGE_PRETTY_NAME=\"Ubuntu Core 24\"\n", "", false},
{"missing file", "", "", true},
} {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "os-release")
require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600))
if !tt.missing {
require.NoError(t, os.WriteFile(path, []byte(tt.osRelease), 0o600))
}
original := osReleasePath
osReleasePath = path
@@ -131,12 +121,4 @@ func TestPrettyOSName(t *testing.T) {
assert.Equal(t, tt.want, prettyOSName())
})
}
t.Run("missing file", func(t *testing.T) {
original := osReleasePath
osReleasePath = filepath.Join(t.TempDir(), "absent")
defer func() { osReleasePath = original }()
assert.Empty(t, prettyOSName())
})
}
+7 -13
View File
@@ -6,7 +6,7 @@ package run
import (
"bytes"
"fmt"
"sort"
"slices"
"strings"
"gitea.dev/actionslib/pkg/model"
@@ -26,27 +26,21 @@ func generateWorkflow(task *runnerv1.Task) (*model.Workflow, string, error) {
}
jobID := jobIDs[0]
needJobIDs := make([]string, 0, len(task.Needs))
rawNeeds := yaml.Node{
Kind: yaml.SequenceNode,
Content: make([]*yaml.Node, 0, len(task.Needs)),
}
for id, need := range task.Needs {
needJobIDs = append(needJobIDs, id)
needJob := &model.Job{
workflow.Jobs[id] = &model.Job{
Outputs: need.Outputs,
Result: strings.ToLower(strings.TrimPrefix(need.Result.String(), "RESULT_")),
}
workflow.Jobs[id] = needJob
}
sort.Strings(needJobIDs)
rawNeeds := yaml.Node{
Kind: yaml.SequenceNode,
Content: make([]*yaml.Node, 0, len(needJobIDs)),
}
for _, id := range needJobIDs {
rawNeeds.Content = append(rawNeeds.Content, &yaml.Node{
Kind: yaml.ScalarNode,
Value: id,
})
}
slices.SortFunc(rawNeeds.Content, func(left, right *yaml.Node) int { return strings.Compare(left.Value, right.Value) })
workflow.Jobs[jobID].RawNeeds = rawNeeds
+63 -112
View File
@@ -13,21 +13,17 @@ import (
)
func Test_generateWorkflow(t *testing.T) {
type args struct {
task *runnerv1.Task
}
tests := []struct {
name string
args args
payload string
needs map[string]*runnerv1.TaskNeed
assert func(t *testing.T, wf *model.Workflow)
want1 string
jobID string
wantErr bool
}{
{
name: "has needs",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Build and deploy
on: push
@@ -39,34 +35,29 @@ jobs:
- uses: actions/checkout@v3
- run: ./deploy --build ${{ needs.job1.outputs.output1 }}
- run: ./deploy --build ${{ needs.job2.outputs.output2 }}
`),
Needs: map[string]*runnerv1.TaskNeed{
"job1": {
Outputs: map[string]string{
"output1": "output1 value",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"job2": {
Outputs: map[string]string{
"output2": "output2 value",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
`,
needs: map[string]*runnerv1.TaskNeed{
"job1": {
Outputs: map[string]string{
"output1": "output1 value",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"job2": {
Outputs: map[string]string{
"output2": "output2 value",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
},
assert: func(t *testing.T, wf *model.Workflow) {
require.Equal(t, []string{"job1", "job2"}, wf.GetJob("job9").Needs())
},
want1: "job9",
wantErr: false,
jobID: "job9",
},
{
name: "no needs",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Simple workflow
on: push
@@ -76,23 +67,18 @@ jobs:
steps:
- uses: actions/checkout@v3
- run: echo "hello"
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
`,
needs: map[string]*runnerv1.TaskNeed{},
assert: func(t *testing.T, wf *model.Workflow) {
job := wf.GetJob("test")
require.Equal(t, []string{}, job.Needs())
require.Len(t, job.Steps, 2)
},
want1: "test",
wantErr: false,
jobID: "test",
},
{
name: "needs list",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Workflow with list needs
on: push
@@ -102,40 +88,34 @@ jobs:
runs-on: ubuntu-latest
steps:
- run: echo "deploying"
`),
Needs: map[string]*runnerv1.TaskNeed{
"build": {
Outputs: map[string]string{},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"test": {
Outputs: map[string]string{
"coverage": "80%",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"lint": {
Outputs: map[string]string{},
Result: runnerv1.Result_RESULT_FAILURE,
},
`,
needs: map[string]*runnerv1.TaskNeed{
"build": {
Outputs: map[string]string{},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"test": {
Outputs: map[string]string{
"coverage": "80%",
},
Result: runnerv1.Result_RESULT_SUCCESS,
},
"lint": {
Outputs: map[string]string{},
Result: runnerv1.Result_RESULT_FAILURE,
},
},
assert: func(t *testing.T, wf *model.Workflow) {
job := wf.GetJob("deploy")
needs := job.Needs()
require.Equal(t, []string{"build", "lint", "test"}, needs)
require.Equal(t, []string{"build", "lint", "test"}, job.Needs())
require.Equal(t, "80%", wf.Jobs["test"].Outputs["coverage"])
require.Equal(t, "failure", wf.Jobs["lint"].Result)
},
want1: "deploy",
wantErr: false,
jobID: "deploy",
},
{
name: "workflow env and defaults",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Complex workflow
on:
push:
@@ -159,25 +139,19 @@ jobs:
node-version: "18"
- run: npm ci
- run: npm run build
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
`,
needs: map[string]*runnerv1.TaskNeed{},
assert: func(t *testing.T, wf *model.Workflow) {
require.Equal(t, "Complex workflow", wf.Name)
require.Equal(t, "production", wf.Env["NODE_ENV"])
require.Equal(t, "true", wf.Env["CI"])
job := wf.GetJob("build")
require.Len(t, job.Steps, 4)
require.Len(t, wf.GetJob("build").Steps, 4)
},
want1: "build",
wantErr: false,
jobID: "build",
},
{
name: "job with container and services",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Integration tests
on: push
@@ -192,24 +166,18 @@ jobs:
steps:
- uses: actions/checkout@v3
- run: npm test
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
`,
needs: map[string]*runnerv1.TaskNeed{},
assert: func(t *testing.T, wf *model.Workflow) {
job := wf.GetJob("integration")
container := job.Container()
require.Equal(t, "node:18", container.Image)
require.Equal(t, "node:18", job.Container().Image)
require.Equal(t, "postgres:15", job.Services["postgres"].Image)
},
want1: "integration",
wantErr: false,
jobID: "integration",
},
{
name: "job with matrix strategy",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Matrix build
on: push
@@ -222,40 +190,28 @@ jobs:
steps:
- uses: actions/checkout@v3
- run: go test ./...
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
`,
needs: map[string]*runnerv1.TaskNeed{},
assert: func(t *testing.T, wf *model.Workflow) {
job := wf.GetJob("test")
matrixes, err := job.GetMatrixes()
matrixes, err := wf.GetJob("test").GetMatrixes()
require.NoError(t, err)
require.Len(t, matrixes, 2)
},
want1: "test",
wantErr: false,
jobID: "test",
},
{
name: "special yaml characters in values",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte("name: \"Special: characters & test\"\non: push\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: 'echo \"hello: world\"'\n - run: 'echo \"quotes & ampersands\"'\n - run: |\n echo \"multiline\"\n echo \"script\"\n"),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
name: "special yaml characters in values",
payload: "name: \"Special: characters & test\"\non: push\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: 'echo \"hello: world\"'\n - run: 'echo \"quotes & ampersands\"'\n - run: |\n echo \"multiline\"\n echo \"script\"\n",
needs: map[string]*runnerv1.TaskNeed{},
assert: func(t *testing.T, wf *model.Workflow) {
require.Equal(t, "Special: characters & test", wf.Name)
job := wf.GetJob("test")
require.Len(t, job.Steps, 3)
require.Len(t, wf.GetJob("test").Steps, 3)
},
want1: "test",
wantErr: false,
jobID: "test",
},
{
name: "invalid yaml",
args: args{
task: &runnerv1.Task{
WorkflowPayload: []byte(`
payload: `
name: Bad workflow
on: push
jobs:
@@ -264,25 +220,21 @@ jobs:
steps:
- run: echo "ok"
bad-indent: true
`),
Needs: map[string]*runnerv1.TaskNeed{},
},
},
assert: nil,
want1: "",
`,
needs: map[string]*runnerv1.TaskNeed{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, got1, err := generateWorkflow(tt.args.task)
got, got1, err := generateWorkflow(&runnerv1.Task{WorkflowPayload: []byte(tt.payload), Needs: tt.needs})
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
tt.assert(t, got)
require.Equal(t, tt.want1, got1)
require.Equal(t, tt.jobID, got1)
})
}
}
@@ -321,7 +273,6 @@ func Test_yamlV4NodeRoundTrip(t *testing.T) {
})
t.Run("node kind constants", func(t *testing.T) {
// Verify yaml/v4 node kind constants are usable (same API as v3)
require.NotEqual(t, yaml.ScalarNode, yaml.SequenceNode)
require.NotEqual(t, yaml.SequenceNode, yaml.MappingNode)
})
+1 -5
View File
@@ -56,7 +56,7 @@ func Main(ctx context.Context, c *action.Context) (err error) {
ref := c.Inputs["ref"]
sshKey := strings.TrimSpace(c.Inputs["ssh-key"])
isWorkflowRepo := strings.EqualFold(repository, ghc.Repository)
local := isWorkflowRepo && slices.Contains([]string{"", shortBranch(ghc.Ref), ghc.Sha}, shortBranch(ref)) && !c.NoSkipCheckout && sshKey == ""
local := isWorkflowRepo && slices.Contains([]string{"", strings.TrimPrefix(ghc.Ref, "refs/heads/"), ghc.Sha}, strings.TrimPrefix(ref, "refs/heads/")) && !c.NoSkipCheckout && sshKey == ""
if local && (!c.BindWorkdir || dst == c.Workspace) {
if !c.BindWorkdir {
if err := c.Container.CopyDir(dst, c.HostWorkdir+string(filepath.Separator)+".", c.UseGitIgnore, false)(ctx); err != nil {
@@ -470,7 +470,3 @@ func fetchDepth(input string) (int, error) {
}
return depth, nil
}
func shortBranch(ref string) string { // full tag refs stay exact
return strings.TrimPrefix(ref, "refs/heads/")
}
+5 -13
View File
@@ -246,7 +246,7 @@ type Config struct {
func LoadDefault(file string) (*Config, error) {
// Seeded before the file is read, so a written 0 can mean off.
cfg := &Config{Cache: DefaultCache(), Log: Log{Job: LogJob{Retention: 7 * 24 * time.Hour, MaxSize: 1024 * 1024 * 1024}}}
definedRunnerKeys := map[string]bool{}
var definedRunnerKeys map[string]bool
if file != "" {
content, err := os.ReadFile(file)
if err != nil {
@@ -307,16 +307,13 @@ func LoadDefault(file string) (*Config, error) {
cfg.Runner.Timeout = 3 * time.Hour
}
if cfg.Runner.ActionShallowClone == nil {
b := true
cfg.Runner.ActionShallowClone = &b
cfg.Runner.ActionShallowClone = new(true)
}
if cfg.Runner.SetActEnv == nil {
b := true
cfg.Runner.SetActEnv = &b
cfg.Runner.SetActEnv = new(true)
}
if cfg.Cache.Enabled == nil {
b := true
cfg.Cache.Enabled = &b
cfg.Cache.Enabled = new(true)
}
// Resolved regardless of cache.enabled, because the `cache-server` command reads the secret from the same key without checking cache.enabled.
if err := resolveSecretFile("cache.external_secret", &cfg.Cache.ExternalSecret, cfg.Cache.ExternalSecretFile); err != nil {
@@ -435,12 +432,7 @@ func LoadDefault(file string) (*Config, error) {
// although `container.network_mode` will be deprecated, but we have to be compatible with it for now.
if cfg.Container.NetworkMode != "" && cfg.Container.Network == "" {
logging.Warn("You are trying to use deprecated configuration item of `container.network_mode`, please use `container.network` instead.")
if cfg.Container.NetworkMode == "bridge" {
// Previously, if the value of `container.network_mode` is `bridge`, we will create a new network for job.
// But “bridge” is easily confused with the bridge network created by Docker by default.
// So we set the value of `container.network` to empty string to make `runner` automatically create a new network for job.
cfg.Container.Network = ""
} else {
if cfg.Container.NetworkMode != "bridge" { // "bridge" meant a per-job network, which an empty container.network creates
cfg.Container.Network = cfg.Container.NetworkMode
}
}
+104 -284
View File
@@ -13,6 +13,7 @@ import (
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -22,34 +23,6 @@ import (
"github.com/stretchr/testify/require"
)
func TestLoadDefault_RejectsExternalServerWithoutSecret(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
`), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "external_secret")
}
func TestLoadDefault_AcceptsExternalServerWithSecret(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret: "shh"
`), 0o600))
_, err := LoadDefault(path)
require.NoError(t, err)
}
func TestClientCertIsValidatedAndAbsoluteOnLoadReadOnUseAndOfferedOnlyWhenAccepted(t *testing.T) {
require.True(t, (&Runner{Insecure: true}).TLSConfig().InsecureSkipVerify)
@@ -156,13 +129,8 @@ cache:
}
func TestLoadDefault_ToolCacheMode(t *testing.T) {
cfg, err := LoadDefault("")
require.NoError(t, err)
assert.Equal(t, ToolCacheModeNone, cfg.Runner.ToolCacheMode)
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(path, []byte("runner:\n tool_cache_mode: shared\n"), 0o600))
cfg, err = LoadDefault(path)
path := write(t, "runner:\n tool_cache_mode: shared\n")
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, ToolCacheModeShared, cfg.Runner.ToolCacheMode)
@@ -178,9 +146,7 @@ func TestLoadDefault_RejectsInvalidLogReportLevel(t *testing.T) {
}
func TestLoadDefault_LoadsExtraHeaders(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(path, []byte("runner:\n extra_headers:\n X-Proxy-Token: proxy-token\n"), 0o600))
path := write(t, "runner:\n extra_headers:\n X-Proxy-Token: proxy-token\n")
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, map[string]string{"X-Proxy-Token": "proxy-token"}, cfg.Runner.ExtraHeaders)
@@ -196,17 +162,13 @@ func TestLoadDefault_LoadsExtraHeaders(t *testing.T) {
}
}
func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) {
func TestLoadDefault_Defaults(t *testing.T) {
cfg, err := LoadDefault("")
require.NoError(t, err)
assert.Equal(t, 24*time.Hour, cfg.Runner.WorkdirCleanupAge)
assert.Equal(t, 10*time.Minute, cfg.Runner.IdleCleanupInterval)
assert.Equal(t, DefaultImage, cfg.Runner.DefaultImage)
}
func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) {
cfg, err := LoadDefault("")
require.NoError(t, err)
assert.Equal(t, ToolCacheModeNone, cfg.Runner.ToolCacheMode)
assert.False(t, cfg.HealthCheck.Enabled)
assert.Equal(t, int64(1024), cfg.HealthCheck.MinFreeDiskSpaceMB)
assert.Empty(t, cfg.HealthCheck.Script)
@@ -216,9 +178,7 @@ func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) {
}
func TestLoadDefault_DiskAndReadinessSettings(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cfg, err := LoadDefault(write(t, `
health_check:
enabled: true
min_free_disk_space_mb: 4096
@@ -227,9 +187,7 @@ health_check:
timeout: 3s
metrics:
readiness_grace: 45s
`), 0o600))
cfg, err := LoadDefault(path)
`))
require.NoError(t, err)
assert.True(t, cfg.HealthCheck.Enabled)
assert.Equal(t, int64(4096), cfg.HealthCheck.MinFreeDiskSpaceMB)
@@ -239,88 +197,40 @@ metrics:
assert.Equal(t, 45*time.Second, cfg.Metrics.ReadinessGrace)
}
func TestLoadDefault_UsesConfiguredWorkdirCleanupAge(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
workdir_cleanup_age: 2h30m
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, 150*time.Minute, cfg.Runner.WorkdirCleanupAge)
}
func TestLoadDefault_UsesConfiguredIdleCleanupInterval(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
idle_cleanup_interval: 45m
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, 45*time.Minute, cfg.Runner.IdleCleanupInterval)
}
func TestLoadDefault_AllowsDisablingWorkdirCleanup(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
workdir_cleanup_age: 0s
idle_cleanup_interval: 0s
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, time.Duration(0), cfg.Runner.WorkdirCleanupAge)
assert.Equal(t, time.Duration(0), cfg.Runner.IdleCleanupInterval)
}
func TestLoadDefault_AllowsNegativeWorkdirCleanupValues(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
workdir_cleanup_age: -1s
idle_cleanup_interval: -1s
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, -1*time.Second, cfg.Runner.WorkdirCleanupAge)
assert.Equal(t, -1*time.Second, cfg.Runner.IdleCleanupInterval)
func TestLoadDefault_WorkdirCleanup(t *testing.T) {
for _, tc := range []struct {
name, yaml string
age, interval time.Duration
}{
{"configured age", "\nrunner:\n workdir_cleanup_age: 2h30m\n", 150 * time.Minute, 10 * time.Minute},
{"configured interval", "\nrunner:\n idle_cleanup_interval: 45m\n", 24 * time.Hour, 45 * time.Minute},
{"disabled", "\nrunner:\n workdir_cleanup_age: 0s\n idle_cleanup_interval: 0s\n", 0, 0},
{"negative", "\nrunner:\n workdir_cleanup_age: -1s\n idle_cleanup_interval: -1s\n", -time.Second, -time.Second},
} {
t.Run(tc.name, func(t *testing.T) {
cfg, err := LoadDefault(write(t, tc.yaml))
require.NoError(t, err)
assert.Equal(t, tc.age, cfg.Runner.WorkdirCleanupAge)
assert.Equal(t, tc.interval, cfg.Runner.IdleCleanupInterval)
})
}
}
func TestLoadDefault_LoadsPostTaskScript(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
post_task_script: /usr/local/bin/post-task.sh
post_task_script_timeout: 2m
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, "/usr/local/bin/post-task.sh", cfg.Runner.PostTaskScript)
assert.Equal(t, 2*time.Minute, cfg.Runner.PostTaskScriptTimeout)
}
func TestLoadDefault_DefaultsPostTaskScriptTimeout(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
runner:
post_task_script: /usr/local/bin/post-task.sh
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, 5*time.Minute, cfg.Runner.PostTaskScriptTimeout)
for _, tc := range []struct {
name, timeout string
want time.Duration
}{
{"configured timeout", " post_task_script_timeout: 2m\n", 2 * time.Minute},
{"default timeout", "", 5 * time.Minute},
} {
t.Run(tc.name, func(t *testing.T) {
cfg, err := LoadDefault(write(t, "\nrunner:\n post_task_script: /usr/local/bin/post-task.sh\n"+tc.timeout))
require.NoError(t, err)
assert.Equal(t, "/usr/local/bin/post-task.sh", cfg.Runner.PostTaskScript)
assert.Equal(t, tc.want, cfg.Runner.PostTaskScriptTimeout)
})
}
}
func write(t *testing.T, body string) string {
@@ -363,43 +273,28 @@ func TestLoadDefault_LoadsJobLogs(t *testing.T) {
}
func TestLoadDefault_LoadsJobHooks(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cfg, err := LoadDefault(write(t, `
runner:
hooks:
job_started: /hooks/started.sh
job_completed: /hooks/completed.sh
`), 0o600))
cfg, err := LoadDefault(path)
`))
require.NoError(t, err)
assert.Equal(t, "/hooks/started.sh", cfg.Runner.Hooks.JobStarted)
assert.Equal(t, "/hooks/completed.sh", cfg.Runner.Hooks.JobCompleted)
}
// TestLoadDefault_MalformedYAMLReturnsParseError pins the error surfaced for
// invalid YAML to the canonical "parse config file" message rather than the
// "for defaults metadata" variant — i.e. the main yaml.Unmarshal runs first.
func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte("runner:\n capacity: [unterminated\n"), 0o600))
_, err := LoadDefault(path)
_, err := LoadDefault(write(t, "runner:\n capacity: [unterminated\n"))
require.Error(t, err)
assert.Contains(t, err.Error(), "parse config file")
assert.NotContains(t, err.Error(), "defaults metadata")
}
func TestLoadDefault_WarnsOnUnknownKeysButStillLoads(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte("container:\n volumes:\n - /host:/ctr\n privileged: true\n"), 0o600))
hook := logtest.SetDefault(t)
cfg, err := LoadDefault(path)
cfg, err := LoadDefault(write(t, "container:\n volumes:\n - /host:/ctr\n privileged: true\n"))
require.NoError(t, err)
assert.True(t, cfg.Container.Privileged)
require.Len(t, hook.AllRecords(), 1)
@@ -407,142 +302,70 @@ func TestLoadDefault_WarnsOnUnknownKeysButStillLoads(t *testing.T) {
}
func TestContainerNetworkCreateOptions(t *testing.T) {
// Verify that the enable_ipv4/enable_ipv6 YAML keys unmarshal into the *bool fields,
// distinguishing an explicit true/false from an omitted key (nil). A nil here is
// forwarded as-is to Docker, which applies its own default.
loadOptions := func(t *testing.T, yaml string) ContainerNetworkCreateOptions {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(yaml), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
return cfg.Container.NetworkCreateOptions
for _, tc := range []struct {
name, yaml string
want ContainerNetworkCreateOptions
}{
{"enable_ipv6 true unmarshals to non-nil true", "container:\n network_create_options:\n enable_ipv6: true\n", ContainerNetworkCreateOptions{EnableIPv6: new(true)}},
{"enable_ipv6 false unmarshals to non-nil false", "container:\n network_create_options:\n enable_ipv6: false\n", ContainerNetworkCreateOptions{EnableIPv6: new(false)}},
{"enable_ipv4 false unmarshals to non-nil false", "container:\n network_create_options:\n enable_ipv4: false\n", ContainerNetworkCreateOptions{EnableIPv4: new(false)}},
{"omitted keys stay nil", "container:\n network_create_options:\n enable_ipv4: true\n", ContainerNetworkCreateOptions{EnableIPv4: new(true)}},
{"omitted block leaves both nil", "container:\n network: \"\"\n", ContainerNetworkCreateOptions{}},
} {
t.Run(tc.name, func(t *testing.T) {
cfg, err := LoadDefault(write(t, tc.yaml))
require.NoError(t, err)
require.Equal(t, tc.want, cfg.Container.NetworkCreateOptions)
})
}
t.Run("enable_ipv6 true unmarshals to non-nil true", func(t *testing.T) {
opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv6: true\n")
require.NotNil(t, opts.EnableIPv6)
assert.True(t, *opts.EnableIPv6)
})
t.Run("enable_ipv6 false unmarshals to non-nil false", func(t *testing.T) {
opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv6: false\n")
require.NotNil(t, opts.EnableIPv6)
assert.False(t, *opts.EnableIPv6)
})
t.Run("enable_ipv4 false unmarshals to non-nil false", func(t *testing.T) {
opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv4: false\n")
require.NotNil(t, opts.EnableIPv4)
assert.False(t, *opts.EnableIPv4)
})
t.Run("omitted keys stay nil", func(t *testing.T) {
opts := loadOptions(t, "container:\n network_create_options:\n enable_ipv4: true\n")
require.NotNil(t, opts.EnableIPv4)
assert.True(t, *opts.EnableIPv4)
assert.Nil(t, opts.EnableIPv6, "an omitted enable_ipv6 must remain nil so Docker's default applies")
})
t.Run("omitted block leaves both nil", func(t *testing.T) {
opts := loadOptions(t, "container:\n network: \"\"\n")
assert.Nil(t, opts.EnableIPv4)
assert.Nil(t, opts.EnableIPv6)
})
}
func TestLoadDefault_ReadsExternalSecretFromFile(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "cache.secret")
require.NoError(t, os.WriteFile(secretPath, []byte(" s3cr3t\n"), 0o600))
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret_file: "`+secretPath+`"
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret)
func TestLoadDefault_ExternalCacheSecret(t *testing.T) {
const externalCache = "\ncache:\n enabled: true\n external_server: \"http://cache.invalid/\"\n"
for _, tc := range []struct {
name, yaml, fileContent, wantSecret, wantErr string
}{
{name: "without secret", yaml: externalCache, wantErr: "external_secret"},
{name: "inline secret", yaml: externalCache + " external_secret: \"shh\"\n"},
{
name: "from file", yaml: externalCache + " external_secret_file: \"$DIR/cache.secret\"\n",
fileContent: " s3cr3t\n", wantSecret: "s3cr3t",
},
{
name: "from file when disabled", yaml: "\ncache:\n enabled: false\n external_secret_file: \"$DIR/cache.secret\"\n",
fileContent: "s3cr3t", wantSecret: "s3cr3t",
},
{
name: "inline and file conflict", yaml: externalCache + " external_secret: \"inline\"\n external_secret_file: \"$DIR/cache.secret\"\n",
fileContent: "s3cr3t", wantErr: "both set",
},
{name: "missing file", yaml: externalCache + " external_secret_file: \"$DIR/absent.secret\"\n", wantErr: "read cache.external_secret_file"},
{
name: "empty file", yaml: externalCache + " external_secret_file: \"$DIR/cache.secret\"\n",
fileContent: "\n \n", wantErr: "contains no secret",
},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if tc.fileContent != "" {
require.NoError(t, os.WriteFile(filepath.Join(dir, "cache.secret"), []byte(tc.fileContent), 0o600))
}
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(strings.ReplaceAll(tc.yaml, "$DIR", dir)), 0o600))
cfg, err := LoadDefault(path)
if tc.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
} else {
require.NoError(t, err)
if tc.wantSecret != "" {
assert.Equal(t, tc.wantSecret, cfg.Cache.ExternalSecret)
}
}
})
}
}
func TestLoadDefault_ReadsExternalSecretFromFileWhenCacheDisabled(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "cache.secret")
require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600))
// the file has to be resolved even when cache is disabled
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: false
external_secret_file: "`+secretPath+`"
`), 0o600))
cfg, err := LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret)
}
func TestLoadDefault_RejectsBothExternalSecretAndFile(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "cache.secret")
require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600))
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret: "inline"
external_secret_file: "`+secretPath+`"
`), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "both set")
}
func TestLoadDefault_RejectsMissingExternalSecretFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret_file: "`+filepath.Join(dir, "absent.secret")+`"
`), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "read cache.external_secret_file")
}
func TestLoadDefault_RejectsEmptyExternalSecretFile(t *testing.T) {
dir := t.TempDir()
secretPath := filepath.Join(dir, "cache.secret")
require.NoError(t, os.WriteFile(secretPath, []byte("\n \n"), 0o600))
path := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cache:
enabled: true
external_server: "http://cache.invalid/"
external_secret_file: "`+secretPath+`"
`), 0o600))
_, err := LoadDefault(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "contains no secret")
}
// The shipped configs must parse, hold no key the config does not know, and leave
// every option at its default, as all of their values are commented out.
func TestLoadDefault_ShippedConfigsChangeNothing(t *testing.T) {
hook := logtest.SetDefault(t)
@@ -562,13 +385,10 @@ func TestLoadDefault_ShippedConfigsChangeNothing(t *testing.T) {
}
func TestLoadDefault_ClampsFetchTimeout(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
cfg, err := LoadDefault(write(t, `
runner:
fetch_timeout: 120s
`), 0o600))
cfg, err := LoadDefault(path)
`))
require.NoError(t, err)
assert.Equal(t, RequestTimeout, cfg.Runner.FetchTimeout)
}
+1 -10
View File
@@ -277,7 +277,7 @@ func mappingValue(mapping *yaml.Node, key string) *yaml.Node {
}
func renderNode(node *yaml.Node) (string, error) {
if !allScalars(node.Content) {
if slices.ContainsFunc(node.Content, func(child *yaml.Node) bool { return child.Kind != yaml.ScalarNode }) {
encoded, err := encodeYAML(node)
if err != nil {
return "", err
@@ -303,15 +303,6 @@ func renderNode(node *yaml.Node) (string, error) {
}
}
func allScalars(nodes []*yaml.Node) bool {
for _, node := range nodes {
if node.Kind != yaml.ScalarNode {
return false
}
}
return true
}
// encodeYAML never wraps lines, re-encoding must not reflow long values.
func encodeYAML(node *yaml.Node) ([]byte, error) {
return yaml.Dump(node, yaml.WithV3Defaults(), yaml.WithIndent(2), yaml.WithLineWidth(-1))
+6 -17
View File
@@ -5,6 +5,7 @@ package labels
import (
"errors"
"slices"
"strings"
)
@@ -31,17 +32,10 @@ func Parse(str string) (*Label, error) {
return nil, errors.New("empty label")
}
splits := strings.SplitN(str, ":", 3)
label := &Label{
Name: splits[0],
Schema: SchemeHost,
Arg: "",
}
if len(splits) >= 2 {
label.Schema = splits[1]
}
if len(splits) >= 3 {
label.Arg = splits[2]
name, remainder, hasSchema := strings.Cut(str, ":")
label := &Label{Name: name, Schema: SchemeHost}
if hasSchema {
label.Schema, label.Arg, _ = strings.Cut(remainder, ":")
}
kubernetes := label.Schema == SchemeKubernetes && strings.HasPrefix(label.Arg, "//") && len(label.Arg) > 2
if label.Schema != SchemeHost && label.Schema != SchemeDocker && !kubernetes {
@@ -58,12 +52,7 @@ func Parse(str string) (*Label, error) {
type Labels []*Label
func (l Labels) RequireDocker() bool {
for _, label := range l {
if label.Schema == SchemeDocker {
return true
}
}
return false
return slices.ContainsFunc(l, func(label *Label) bool { return label.Schema == SchemeDocker })
}
// PickPlatform returns the platform of the first runs-on entry this runner has a label for, or "".
+9 -58
View File
@@ -11,67 +11,20 @@ import (
func TestParse(t *testing.T) {
tests := []struct {
args string
want *Label
wantErr bool
args string
want *Label
}{
{
args: "ubuntu:docker://node:18",
want: &Label{
Name: "ubuntu",
Schema: "docker",
Arg: "//node:18",
},
wantErr: false,
},
{
args: "ubuntu:host",
want: &Label{
Name: "ubuntu",
Schema: "host",
Arg: "",
},
wantErr: false,
},
{
args: "ubuntu",
want: &Label{
Name: "ubuntu",
Schema: "host",
Arg: "",
},
wantErr: false,
},
{
args: "pool:e57e18d4-10d4-406f-93bf-60f127221bdd",
want: &Label{
Name: "pool:e57e18d4-10d4-406f-93bf-60f127221bdd",
Schema: "host",
Arg: "",
Opaque: true,
},
wantErr: false,
},
{
args: "ubuntu:vm:ubuntu-18.04",
want: &Label{
Name: "ubuntu:vm:ubuntu-18.04",
Schema: "host",
Arg: "",
Opaque: true,
},
wantErr: false,
},
{
args: "",
want: nil,
wantErr: true,
},
{"ubuntu:docker://node:18", &Label{Name: "ubuntu", Schema: "docker", Arg: "//node:18"}},
{"ubuntu:host", &Label{Name: "ubuntu", Schema: "host"}},
{"ubuntu", &Label{Name: "ubuntu", Schema: "host"}},
{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd", &Label{Name: "pool:e57e18d4-10d4-406f-93bf-60f127221bdd", Schema: "host", Opaque: true}},
{"ubuntu:vm:ubuntu-18.04", &Label{Name: "ubuntu:vm:ubuntu-18.04", Schema: "host", Opaque: true}},
{"", nil},
}
for _, tt := range tests {
t.Run(tt.args, func(t *testing.T) {
got, err := Parse(tt.args)
if tt.wantErr {
if tt.want == nil {
require.Error(t, err)
return
}
@@ -81,7 +34,6 @@ func TestParse(t *testing.T) {
}
}
// mustParse parses the given label strings, failing the test on any error.
func mustParse(t *testing.T, strs ...string) Labels {
t.Helper()
ls := make(Labels, 0, len(strs))
@@ -157,7 +109,6 @@ func TestToStrings(t *testing.T) {
}, ls.ToStrings())
}
// a colon-containing name must survive a write to and read back from the .runner file
func TestOpaqueLabelRoundTrip(t *testing.T) {
const raw = "pool:e57e18d4-10d4-406f-93bf-60f127221bdd"
+11 -34
View File
@@ -6,49 +6,26 @@
package lock
import (
"errors"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestTryLock(t *testing.T) {
runnerFile := filepath.Join(t.TempDir(), ".runner")
release, err := TryLock(runnerFile)
if err != nil {
t.Fatalf("first TryLock failed: %v", err)
}
// A second lock on the same file must be refused while the first is held.
if _, err := TryLock(runnerFile); !errors.Is(err, ErrLocked) {
t.Fatalf("second TryLock: want ErrLocked, got %v", err)
}
if err := release(); err != nil {
t.Fatalf("release failed: %v", err)
}
// After release the lock is available again.
release2, err := TryLock(runnerFile)
if err != nil {
t.Fatalf("TryLock after release failed: %v", err)
}
if err := release2(); err != nil {
t.Fatalf("second release failed: %v", err)
for range 2 {
release, err := TryLock(runnerFile)
require.NoError(t, err)
_, err = TryLock(runnerFile)
require.ErrorIs(t, err, ErrLocked)
require.NoError(t, release())
}
}
// TestTryLockUncreatable ensures a lock file that cannot be created reports a
// non-ErrLocked error, so callers can tell "already locked by another process"
// apart from "couldn't lock" and degrade gracefully (e.g. a read-only mount).
func TestTryLockUncreatable(t *testing.T) {
runnerFile := filepath.Join(t.TempDir(), "missing-dir", ".runner")
_, err := TryLock(runnerFile)
if err == nil {
t.Fatal("TryLock on an uncreatable lock file: want error, got nil")
}
if errors.Is(err, ErrLocked) {
t.Fatal("TryLock on an uncreatable lock file: want non-ErrLocked error, got ErrLocked")
}
_, err := TryLock(filepath.Join(t.TempDir(), "missing-dir", ".runner"))
require.Error(t, err)
require.NotErrorIs(t, err, ErrLocked)
}
+10 -19
View File
@@ -11,33 +11,24 @@ import (
"github.com/stretchr/testify/assert"
)
func TestReadyEndpoint(t *testing.T) {
func TestHealthAndReadyEndpoints(t *testing.T) {
tests := []struct {
name string
ready bool
reason string
status int
name, path string
ready bool
reason, body string
status int
}{
{"ready", true, "ok", http.StatusOK},
{"not ready", false, "low disk space", http.StatusServiceUnavailable},
{"ready", "/readyz", true, "ok", "ok", http.StatusOK},
{"not ready", "/readyz", false, "low disk space", "low disk space", http.StatusServiceUnavailable},
{"live when not ready", "/healthz", false, "low disk space", "ok", http.StatusOK},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := NewHTTPHandler(func() (bool, string) { return test.ready, test.reason })
request := httptest.NewRequest(http.MethodGet, "/readyz", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, test.path, nil))
assert.Equal(t, test.status, response.Code)
assert.Equal(t, test.reason, response.Body.String())
assert.Equal(t, test.body, response.Body.String())
})
}
}
func TestHealthEndpointStaysLiveWhenNotReady(t *testing.T) {
handler := NewHTTPHandler(func() (bool, string) { return false, "low disk space" })
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
assert.Equal(t, "ok", response.Body.String())
}
+3 -29
View File
@@ -5,11 +5,8 @@ package process
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -17,7 +14,6 @@ import (
"golang.org/x/sys/windows"
)
// processAlive reports whether pid refers to a still-running process.
func processAlive(pid int) bool {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
@@ -28,21 +24,13 @@ func processAlive(pid int) bool {
if err := windows.GetExitCodeProcess(h, &code); err != nil {
return false
}
const stillActive = 259 // STILL_ACTIVE
const stillActive = 259
return code == stillActive
}
// TestKillerKillsTree verifies that a process assigned to the Job Object is
// terminated together with a child it spawns afterwards. This mirrors a step or
// post-task script that launches a child which spawns further processes, where
// cancelling must take down the whole tree, not just the direct child.
func TestKillerKillsTree(t *testing.T) {
dir := t.TempDir()
pidFile := filepath.Join(dir, "child.pid")
pidFile := filepath.Join(t.TempDir(), "child.pid")
// Parent powershell spawns a detached, long-lived child powershell (writing
// its PID to a file) and then sleeps. The child is launched AFTER the parent
// has been assigned to the job, so it must be captured by the job too.
script := fmt.Sprintf(
`$c = Start-Process powershell -PassThru -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 600'; `+
`Set-Content -LiteralPath %q -Value $c.Id; Start-Sleep -Seconds 600`, pidFile)
@@ -54,22 +42,8 @@ func TestKillerKillsTree(t *testing.T) {
require.NoError(t, err)
defer killer.Close()
// Wait for the child PID to be reported.
var childPID int
require.Eventually(t, func() bool {
b, e := os.ReadFile(pidFile)
if e != nil {
return false
}
s := strings.TrimSpace(string(b))
if s == "" {
return false
}
childPID, _ = strconv.Atoi(s)
return childPID > 0 && processAlive(childPID)
}, 20*time.Second, 200*time.Millisecond, "child process should start")
childPID := awaitChildPID(t, pidFile)
// Killing the job must terminate both the parent and the detached child.
require.NoError(t, killer.Kill())
require.Eventually(t, func() bool {
+5 -13
View File
@@ -8,22 +8,14 @@ import (
"strings"
)
// pathWithin is the OS-agnostic core of dirContainsPath: dir and target must
// already be cleaned, and fold folds case.
func pathWithin(dir, target string, fold bool) bool {
if dir == "" || target == "" || dir == "." || target == "." {
func dirContainsPath(dir, target string) bool {
dir = strings.ToLower(filepath.Clean(dir))
target = strings.ToLower(filepath.Clean(target))
if dir == "." || target == "." {
return false
}
if fold {
dir = strings.ToLower(dir)
target = strings.ToLower(target)
}
if dir == target {
return true
}
sep := string(filepath.Separator)
if !strings.HasSuffix(dir, sep) {
dir += sep
}
return strings.HasPrefix(target, dir)
return strings.HasPrefix(target, strings.TrimSuffix(dir, string(filepath.Separator))+string(filepath.Separator))
}
+13 -15
View File
@@ -8,33 +8,31 @@ import (
"testing"
)
func TestPathWithin(t *testing.T) {
func TestDirContainsPath(t *testing.T) {
base := filepath.Join("base", "job1")
cases := []struct {
name string
dir string
target string
fold bool
want bool
}{
{"same dir", base, base, false, true},
{"direct child", base, filepath.Join(base, "app.exe"), false, true},
{"deep child", base, filepath.Join(base, "sub", "sub", "app.exe"), false, true},
{"parent is not within child", filepath.Join(base, "sub"), base, false, false},
{"name-prefix sibling spared", base, filepath.Join("base", "job10", "app.exe"), false, false},
{"unrelated", base, filepath.Join("other", "app.exe"), false, false},
{"empty dir", "", base, false, false},
{"empty target", base, "", false, false},
{"dot dir", ".", base, false, false},
{"case-sensitive miss", base, filepath.Join("base", "JOB1", "app.exe"), false, false},
{"case-insensitive hit", base, filepath.Join("base", "JOB1", "app.exe"), true, true},
{"same dir", base, base, true},
{"direct child", base, filepath.Join(base, "app.exe"), true},
{"deep child", base, filepath.Join(base, "sub", "sub", "app.exe"), true},
{"parent is not within child", filepath.Join(base, "sub"), base, false},
{"name-prefix sibling spared", base, filepath.Join("base", "job10", "app.exe"), false},
{"unrelated", base, filepath.Join("other", "app.exe"), false},
{"empty dir", "", base, false},
{"empty target", base, "", false},
{"dot dir", ".", base, false},
{"case-insensitive hit", base, filepath.Join("base", "JOB1", "app.exe"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := pathWithin(filepath.Clean(tc.dir), filepath.Clean(tc.target), tc.fold)
got := dirContainsPath(tc.dir, tc.target)
if got != tc.want {
t.Fatalf("pathWithin(%q, %q, fold=%v) = %v, want %v", tc.dir, tc.target, tc.fold, got, tc.want)
t.Fatalf("dirContainsPath(%q, %q) = %v, want %v", tc.dir, tc.target, got, tc.want)
}
})
}
-7
View File
@@ -8,18 +8,11 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"unsafe"
"golang.org/x/sys/windows"
)
// dirContainsPath reports whether target is dir or lives beneath it,
// case-insensitively, so a name-prefix sibling (job1 vs job10) is not a match.
func dirContainsPath(dir, target string) bool {
return pathWithin(filepath.Clean(dir), filepath.Clean(target), true)
}
// KillProcessesWithCWDUnder terminates every process in the runner's session
// whose cwd is one of dirs or below, catching leftovers the path scan misses
// (Win32_Process exposes no cwd) that still pin a handle. Best-effort.
+30 -64
View File
@@ -12,7 +12,6 @@ import (
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"gitea.com/gitea/runner/act/common/logging"
@@ -80,9 +79,7 @@ type Reporter struct {
heartbeatStop chan struct{}
heartbeatStopOnce sync.Once
// Unix-nanos of the last successful UpdateTask. Atomic so the heartbeat
// guard in ReportState reads it without contending stateMu.
lastReportedAtNanos atomic.Int64
lastReportedAtNanos int64 // guarded by clientM
// Adaptive batching control
logReportInterval time.Duration
@@ -110,14 +107,8 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
for _, v := range extraMasks {
oldnew = runner.AppendSecretMasker(oldnew, v)
}
if v := task.Context.Fields["token"].GetStringValue(); v != "" {
oldnew = runner.AppendSecretMasker(oldnew, v)
}
if v := task.Context.Fields["gitea_runtime_token"].GetStringValue(); v != "" {
oldnew = runner.AppendSecretMasker(oldnew, v)
}
if v := task.Context.Fields["actions_id_token_request_token"].GetStringValue(); v != "" {
oldnew = runner.AppendSecretMasker(oldnew, v)
for _, key := range []string{"token", "gitea_runtime_token", "actions_id_token_request_token"} {
oldnew = runner.AppendSecretMasker(oldnew, task.Context.Fields[key].GetStringValue())
}
oldnew = runner.AppendSecretMaskers(oldnew, task.Secrets)
@@ -239,10 +230,7 @@ func (r *Reporter) reportedStepOf(entry logging.Record) (*runnerv1.StepState, bo
default:
return r.currentReportedStep, false
}
main, ok := r.reportedSteps[reportedStepKey{runnerv1.StepStage_STEP_STAGE_MAIN, number}]
if !ok {
return r.currentReportedStep, false
}
main := r.reportedSteps[reportedStepKey{runnerv1.StepStage_STEP_STAGE_MAIN, number}]
if evaluated, _ := recordAttr[string](entry, "step"); evaluated != "" {
// evaluated as GitHub shows it, and masked as an expression can name a secret
main.Name = r.mask(stepDisplayName(r.stepModels[number], evaluated))
@@ -358,16 +346,10 @@ func attrArgs(attrs []logging.Attr) []any {
return args
}
// isJobStepEntry is used to not report composite step results incorrectly as step result
// returns true if the logentry is on job level
// returns false for composite action step messages
// isJobStepEntry is false for composite action steps, whose results are not job step results.
func isJobStepEntry(rec logging.Record) bool {
if v, ok := rec.Attr("stepID"); ok {
if v, ok := v.([]string); ok && len(v) > 1 {
return false
}
}
return true
stepID, _ := recordAttr[[]string](rec, "stepID")
return len(stepID) <= 1
}
// notifyLog sends a non-blocking signal that new log rows are available.
@@ -421,7 +403,7 @@ func (r *Reporter) Handle(_ context.Context, entry logging.Record) error {
urgentState = true
}
if v, ok := entry.Attr("stepResult"); ok && own && isJobStepEntry(entry) {
if stepResult, ok := r.parseResult(v); ok {
if stepResult, ok := parseResult(v); ok {
reported.Result = stepResult
reported.StoppedAt = timestamppb.New(timestamp)
urgentState = true
@@ -433,7 +415,7 @@ func (r *Reporter) Handle(_ context.Context, entry logging.Record) error {
if stage != runner.StageMain {
if v, ok := entry.Attr("jobResult"); ok {
if jobResult, ok := r.parseResult(v); ok {
if jobResult, ok := parseResult(v); ok {
// We need to ensure log upload before this upload
r.state.Result = jobResult
r.state.StoppedAt = timestamppb.New(timestamp)
@@ -476,24 +458,19 @@ func (r *Reporter) Handle(_ context.Context, entry logging.Record) error {
}
// Force reporting log errors as raw output to prevent silent failures
v, ok := entry.Attr("raw_output")
if entry.Level >= logging.LevelError {
v, ok = true, true
}
if ok {
if rawOutput, ok := v.(bool); ok && rawOutput {
if step.LogLength == 0 {
step.LogIndex = int64(r.logOffset + len(r.logRows))
}
step.LogLength++
r.appendLogRow(logRow(entry))
v, hasRawOutput := entry.Attr("raw_output")
rawOutput, _ := v.(bool)
if entry.Level >= logging.LevelError || rawOutput {
if step.LogLength == 0 {
step.LogIndex = int64(r.logOffset + len(r.logRows))
}
} else if r.shouldAppendLogRow(entry) {
step.LogLength++
r.appendLogRow(logRow(entry))
} else if !hasRawOutput && r.shouldAppendLogRow(entry) {
r.appendLogRow(logRow(entry))
}
if v, ok := entry.Attr("stepResult"); ok && isJobStepEntry(entry) {
if stepResult, ok := r.parseResult(v); ok {
if stepResult, ok := parseResult(v); ok {
if step.LogLength == 0 {
step.LogIndex = int64(r.logOffset + len(r.logRows))
}
@@ -535,18 +512,6 @@ func (r *Reporter) StopHeartbeats() {
})
}
func (r *Reporter) stopLatencyTimer(active *bool, timer *time.Timer) {
if *active {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
*active = false
}
}
func (r *Reporter) runDaemonLoop() {
defer close(r.daemon)
@@ -556,10 +521,12 @@ func (r *Reporter) runDaemonLoop() {
// maxLatencyTimer ensures the first buffered log row is sent within logReportMaxLatency.
// Start inactive — it is armed when the first log row arrives in an empty buffer.
maxLatencyTimer := time.NewTimer(0)
if !maxLatencyTimer.Stop() {
<-maxLatencyTimer.C
}
maxLatencyTimer.Stop()
maxLatencyActive := false
stopLatencyTimer := func() {
maxLatencyTimer.Stop()
maxLatencyActive = false
}
defer logTicker.Stop()
defer stateTicker.Stop()
@@ -569,7 +536,7 @@ func (r *Reporter) runDaemonLoop() {
select {
case <-logTicker.C:
r.reportLogWithState()
r.stopLatencyTimer(&maxLatencyActive, maxLatencyTimer)
stopLatencyTimer()
case <-stateTicker.C:
r.noteReport(metrics.LabelMethodUpdateTask, r.ReportState(false))
@@ -581,7 +548,7 @@ func (r *Reporter) runDaemonLoop() {
if n >= r.logBatchSize {
r.reportLogWithState()
r.stopLatencyTimer(&maxLatencyActive, maxLatencyTimer)
stopLatencyTimer()
} else if !maxLatencyActive && n > 0 {
maxLatencyTimer.Reset(r.logReportMaxLatency)
maxLatencyActive = true
@@ -591,7 +558,7 @@ func (r *Reporter) runDaemonLoop() {
// Step transition or job result — flush both immediately for frontend UX.
r.noteReport(metrics.LabelMethodUpdateLog, r.ReportLog(false))
r.noteReport(metrics.LabelMethodUpdateTask, r.ReportState(false))
r.stopLatencyTimer(&maxLatencyActive, maxLatencyTimer)
stopLatencyTimer()
case <-maxLatencyTimer.C:
maxLatencyActive = false
@@ -882,15 +849,14 @@ func (r *Reporter) ReportState(reportResult bool) error {
// below so a log record handled during UpdateTask isn't silently lost.
// Heartbeat at stateReportInterval even when nothing changed, so the server
// doesn't time out long-running silent jobs as orphaned (#826).
last := r.lastReportedAtNanos.Load()
last := r.lastReportedAtNanos
withinHeartbeatInterval := last != 0 && time.Since(time.Unix(0, last)) < r.stateReportInterval
r.stateMu.Lock()
if !reportResult && !r.stateChanged && len(outputs) == 0 && withinHeartbeatInterval {
r.stateMu.Unlock()
return nil
}
state := &runnerv1.TaskState{}
proto.Merge(state, r.state)
state := proto.CloneOf(r.state)
r.stateChanged = false
r.stateMu.Unlock()
@@ -916,7 +882,7 @@ func (r *Reporter) ReportState(reportResult bool) error {
return err
}
metrics.ReportStateTotal.WithLabelValues(metrics.LabelResultSuccess).Inc()
r.lastReportedAtNanos.Store(time.Now().UnixNano())
r.lastReportedAtNanos = time.Now().UnixNano()
var noSent []string
r.outputsMu.Lock()
@@ -972,7 +938,7 @@ var stringToResult = map[string]runnerv1.Result{
"cancelled": runnerv1.Result_RESULT_CANCELLED,
}
func (r *Reporter) parseResult(result any) (runnerv1.Result, bool) {
func parseResult(result any) (runnerv1.Result, bool) {
str := ""
if v, ok := result.(string); ok { // for jobResult
str = v
File diff suppressed because it is too large. Load diff