mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-13 11:23:42 +00:00
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:
1 parent
923a0bfb59
commit
d882311ec6
107 files changed
+2408
-6500
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 := ®isterInputs{
|
||||
InstanceAddr: strings.TrimSpace(regArgs.InstanceAddr),
|
||||
|
||||
@@ -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(®isterArgs{
|
||||
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(®isterArgs{
|
||||
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(®isterArgs{
|
||||
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(®isterArgs{
|
||||
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(®isterArgs{
|
||||
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(®isterArgs{
|
||||
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(®isterArgs{
|
||||
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(®isterArgs{
|
||||
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(®isterArgs{
|
||||
|
||||
+17
-29
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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/")
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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 "".
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in new issue
Block a user