mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-09 16:50:28 +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
@@ -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
|
||||
|
||||
@@ -50,7 +50,6 @@ func TestReporter_CompositeStepResultDoesNotFinishStep(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReporter_LogLevelFiltering(t *testing.T) {
|
||||
// Set global level to Info so Debug entries should be filtered.
|
||||
origLevel := logging.GetLevel()
|
||||
logging.SetLevel(logging.LevelInfo)
|
||||
defer logging.SetLevel(origLevel)
|
||||
@@ -65,12 +64,9 @@ func TestReporter_LogLevelFiltering(t *testing.T) {
|
||||
return &runnerv1.UpdateTaskResponse{}, nil
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
cfg.Runner.LogReportLevel = "warn"
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
assert.False(t, reporter.shouldAppendLogRow(logging.Record{Level: logging.LevelInfo}))
|
||||
assert.True(t, reporter.shouldAppendLogRow(logging.Record{Level: logging.LevelWarn}))
|
||||
reporter.RunDaemon()
|
||||
@@ -82,16 +78,13 @@ func TestReporter_LogLevelFiltering(t *testing.T) {
|
||||
dataStep0 := map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true}
|
||||
dataStep0Internal := map[string]any{"stage": "Main", "stepNumber": 0}
|
||||
|
||||
// raw_output entries always appear in job log regardless of level.
|
||||
require.NoError(t, fire(reporter, logEntry{Message: "step output", Data: dataStep0, Level: logging.LevelInfo}))
|
||||
require.NoError(t, fire(reporter, logEntry{Message: "step debug output", Data: dataStep0, Level: logging.LevelDebug}))
|
||||
assert.Equal(t, int64(2), reporter.state.Steps[0].LogLength, "raw_output entries must always be forwarded")
|
||||
|
||||
// Non-raw_output entries during steps are not added to logRows regardless of level.
|
||||
require.NoError(t, fire(reporter, logEntry{Message: "internal info", Data: dataStep0Internal, Level: logging.LevelInfo}))
|
||||
require.NoError(t, fire(reporter, logEntry{Message: "internal debug", Data: dataStep0Internal, Level: logging.LevelDebug}))
|
||||
|
||||
// stepResult at DebugLevel (skipped step) must still update state even when filtered from log.
|
||||
require.NoError(t, fire(reporter, logEntry{
|
||||
Message: "Skipping step",
|
||||
Data: map[string]any{
|
||||
@@ -105,14 +98,6 @@ func TestReporter_LogLevelFiltering(t *testing.T) {
|
||||
"stepResult at DebugLevel must update step state even when log entry is filtered from job log output")
|
||||
}
|
||||
|
||||
// TestReporter_EphemeralRunnerDeletion reproduces the exact scenario from
|
||||
// https://gitea.com/gitea/runner/issues/793:
|
||||
//
|
||||
// 1. RunDaemon calls ReportLog(false) — runner is still alive
|
||||
// 2. Close() updates state to Result=FAILURE (between RunDaemon's ReportLog and ReportState)
|
||||
// 3. RunDaemon's ReportState() would clone the completed state and send it,
|
||||
// but the fix makes ReportState return early when closed, preventing this
|
||||
// 4. Close's ReportLog(true) succeeds because the runner was not deleted
|
||||
func TestReporter_EphemeralRunnerDeletion(t *testing.T) {
|
||||
runnerDeleted := false
|
||||
|
||||
@@ -129,7 +114,6 @@ func TestReporter_EphemeralRunnerDeletion(t *testing.T) {
|
||||
)
|
||||
client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(
|
||||
func(_ context.Context, req *runnerv1.UpdateTaskRequest) (*runnerv1.UpdateTaskResponse, error) {
|
||||
// Server deletes ephemeral runner when it receives a completed state
|
||||
if req.State != nil && req.State.Result != runnerv1.Result_RESULT_UNSPECIFIED {
|
||||
runnerDeleted = true
|
||||
}
|
||||
@@ -137,25 +121,17 @@ func TestReporter_EphemeralRunnerDeletion(t *testing.T) {
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
|
||||
// Fire a log entry to create pending data
|
||||
require.NoError(t, fire(reporter, logEntry{
|
||||
Message: "build output",
|
||||
Data: map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true},
|
||||
}))
|
||||
|
||||
// Step 1: RunDaemon calls ReportLog(false) — runner is still alive
|
||||
require.NoError(t, reporter.ReportLog(false))
|
||||
|
||||
// Step 2: Close() updates state — sets Result=FAILURE and marks steps cancelled.
|
||||
// In the real race, this happens while RunDaemon is between ReportLog and ReportState.
|
||||
reporter.stateMu.Lock()
|
||||
reporter.closed = true
|
||||
for _, v := range reporter.state.Steps {
|
||||
@@ -171,20 +147,13 @@ func TestReporter_EphemeralRunnerDeletion(t *testing.T) {
|
||||
reporter.state.StoppedAt = timestamppb.Now()
|
||||
reporter.stateMu.Unlock()
|
||||
|
||||
// Step 3: RunDaemon's ReportState() — with the fix, this returns early
|
||||
// because closed=true, preventing the server from deleting the runner.
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
assert.False(t, runnerDeleted, "runner must not be deleted by RunDaemon's ReportState")
|
||||
|
||||
// Step 4: Close's final log upload succeeds because the runner is still alive.
|
||||
// Flush pending rows first, then send the noMore signal (matching Close's retry behavior).
|
||||
require.NoError(t, reporter.ReportLog(false))
|
||||
// Acknowledge Close as done in daemon
|
||||
close(reporter.daemon)
|
||||
err = reporter.ReportLog(true)
|
||||
require.NoError(t, err, "final log upload must not fail: runner should not be deleted before Close finishes sending logs")
|
||||
err = reporter.ReportState(true)
|
||||
require.NoError(t, err, "final state update should work: runner should not be deleted before Close finishes sending logs")
|
||||
require.NoError(t, reporter.ReportLog(true), "final log upload must not fail: runner should not be deleted before Close finishes sending logs")
|
||||
require.NoError(t, reporter.ReportState(true), "final state update should work: runner should not be deleted before Close finishes sending logs")
|
||||
}
|
||||
|
||||
func TestReporter_RunDaemonClose_Race(t *testing.T) {
|
||||
@@ -211,125 +180,56 @@ func TestReporter_RunDaemonClose_Race(t *testing.T) {
|
||||
}, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
|
||||
// Start the daemon loop — RunDaemon spawns a goroutine internally.
|
||||
reporter.RunDaemon()
|
||||
|
||||
// Close concurrently — this races with the daemon goroutine on r.closed.
|
||||
require.NoError(t, reporter.Close(""))
|
||||
|
||||
// Cancel context so the daemon goroutine exits cleanly.
|
||||
cancel()
|
||||
}
|
||||
|
||||
// TestReporter_MaxLatencyTimer verifies that the maxLatencyTimer flushes a
|
||||
// single buffered log row before the periodic logTicker fires.
|
||||
//
|
||||
// Setup: logReportInterval=10s (effectively never), maxLatency=100ms.
|
||||
// Fire one log line, then assert UpdateLog is called within 500ms.
|
||||
func TestReporter_MaxLatencyTimer(t *testing.T) {
|
||||
var updateLogCalls atomic.Int64
|
||||
func TestReporter_LogFlush(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
maxLatency time.Duration
|
||||
batchSize int
|
||||
messages []string
|
||||
}{
|
||||
{"max latency timer", 100 * time.Millisecond, 1000, []string{"single log line"}},
|
||||
{"batch size", 10 * time.Second, 5, []string{"log line 0", "log line 1", "log line 2", "log line 3", "log line 4"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var updateLogCalls atomic.Int64
|
||||
client := mocks.NewClient(t)
|
||||
client.On("UpdateLog", mock.Anything, mock.Anything).Return(
|
||||
func(_ctx context.Context, req *runnerv1.UpdateLogRequest) (*runnerv1.UpdateLogResponse, error) {
|
||||
updateLogCalls.Add(1)
|
||||
return &runnerv1.UpdateLogResponse{AckIndex: req.Index + int64(len(req.Rows))}, nil
|
||||
},
|
||||
)
|
||||
client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(&runnerv1.UpdateTaskResponse{}, nil)
|
||||
|
||||
client := mocks.NewClient(t)
|
||||
client.On("UpdateLog", mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, req *runnerv1.UpdateLogRequest) (*runnerv1.UpdateLogResponse, error) {
|
||||
updateLogCalls.Add(1)
|
||||
return &runnerv1.UpdateLogResponse{
|
||||
AckIndex: req.Index + int64(len(req.Rows)),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(
|
||||
func(_ context.Context, _ *runnerv1.UpdateTaskRequest) (*runnerv1.UpdateTaskResponse, error) {
|
||||
return &runnerv1.UpdateTaskResponse{}, nil
|
||||
},
|
||||
)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
cfg.Runner.LogReportInterval = 10 * time.Second
|
||||
cfg.Runner.LogReportMaxLatency = tc.maxLatency
|
||||
cfg.Runner.LogReportBatchSize = tc.batchSize
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
reporter.RunDaemon()
|
||||
defer func() { require.NoError(t, reporter.Close("")) }()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Custom config: logTicker=10s (won't fire during test), maxLatency=100ms
|
||||
cfg, _ := config.LoadDefault("")
|
||||
cfg.Runner.LogReportInterval = 10 * time.Second
|
||||
cfg.Runner.LogReportMaxLatency = 100 * time.Millisecond
|
||||
cfg.Runner.LogReportBatchSize = 1000 // won't trigger batch flush
|
||||
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
reporter.RunDaemon()
|
||||
defer func() {
|
||||
_ = reporter.Close("")
|
||||
}()
|
||||
|
||||
// Fire a single log line — not enough to trigger batch flush
|
||||
require.NoError(t, fire(reporter, logEntry{
|
||||
Message: "single log line",
|
||||
Data: map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true},
|
||||
}))
|
||||
|
||||
// maxLatencyTimer should flush within ~100ms. Wait up to 500ms.
|
||||
assert.Eventually(t, func() bool {
|
||||
return updateLogCalls.Load() > 0
|
||||
}, 500*time.Millisecond, 10*time.Millisecond,
|
||||
"maxLatencyTimer should have flushed the log before logTicker (10s)")
|
||||
}
|
||||
|
||||
// TestReporter_BatchSizeFlush verifies that reaching logBatchSize triggers
|
||||
// an immediate log flush without waiting for any timer.
|
||||
func TestReporter_BatchSizeFlush(t *testing.T) {
|
||||
var updateLogCalls atomic.Int64
|
||||
|
||||
client := mocks.NewClient(t)
|
||||
client.On("UpdateLog", mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, req *runnerv1.UpdateLogRequest) (*runnerv1.UpdateLogResponse, error) {
|
||||
updateLogCalls.Add(1)
|
||||
return &runnerv1.UpdateLogResponse{
|
||||
AckIndex: req.Index + int64(len(req.Rows)),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(
|
||||
func(_ context.Context, _ *runnerv1.UpdateTaskRequest) (*runnerv1.UpdateTaskResponse, error) {
|
||||
return &runnerv1.UpdateTaskResponse{}, nil
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Custom config: large timers, small batch size
|
||||
cfg, _ := config.LoadDefault("")
|
||||
cfg.Runner.LogReportInterval = 10 * time.Second
|
||||
cfg.Runner.LogReportMaxLatency = 10 * time.Second
|
||||
cfg.Runner.LogReportBatchSize = 5
|
||||
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
reporter.RunDaemon()
|
||||
defer func() {
|
||||
_ = reporter.Close("")
|
||||
}()
|
||||
|
||||
// Fire exactly batchSize log lines
|
||||
for i := range 5 {
|
||||
require.NoError(t, fire(reporter, logEntry{
|
||||
Message: fmt.Sprintf("log line %d", i),
|
||||
Data: map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true},
|
||||
}))
|
||||
for _, message := range tc.messages {
|
||||
require.NoError(t, fire(reporter, logEntry{
|
||||
Message: message,
|
||||
Data: map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true},
|
||||
}))
|
||||
}
|
||||
assert.Eventually(t, func() bool {
|
||||
return updateLogCalls.Load() > 0
|
||||
}, 500*time.Millisecond, 10*time.Millisecond, "logs must flush before the periodic timers")
|
||||
})
|
||||
}
|
||||
|
||||
// Batch threshold should trigger immediate flush
|
||||
assert.Eventually(t, func() bool {
|
||||
return updateLogCalls.Load() > 0
|
||||
}, 500*time.Millisecond, 10*time.Millisecond,
|
||||
"batch size threshold should have triggered immediate flush")
|
||||
}
|
||||
|
||||
// TestReporter_StateChangedNotLostDuringReport asserts that a log record arriving
|
||||
// mid-UpdateTask re-dirties the flag so the change is picked up by the next report.
|
||||
func TestReporter_StateChangedNotLostDuringReport(t *testing.T) {
|
||||
var updateTaskCalls atomic.Int64
|
||||
inFlight := make(chan struct{})
|
||||
@@ -340,7 +240,6 @@ func TestReporter_StateChangedNotLostDuringReport(t *testing.T) {
|
||||
func(_ context.Context, _ *runnerv1.UpdateTaskRequest) (*runnerv1.UpdateTaskResponse, error) {
|
||||
n := updateTaskCalls.Add(1)
|
||||
if n == 1 {
|
||||
// Signal that the first UpdateTask is in flight, then block until released.
|
||||
close(inFlight)
|
||||
<-release
|
||||
}
|
||||
@@ -348,53 +247,38 @@ func TestReporter_StateChangedNotLostDuringReport(t *testing.T) {
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
reporter.ResetSteps(newTestSteps(2))
|
||||
|
||||
// Mark stateChanged=true so the first ReportState proceeds to UpdateTask.
|
||||
reporter.stateMu.Lock()
|
||||
reporter.stateChanged = true
|
||||
reporter.stateMu.Unlock()
|
||||
|
||||
// Kick off the first ReportState in a goroutine — it will block in UpdateTask.
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- reporter.ReportState(false)
|
||||
}()
|
||||
|
||||
// Wait until UpdateTask is in flight (snapshot taken, flag consumed).
|
||||
<-inFlight
|
||||
|
||||
// A concurrent log record modifies state — must re-flip stateChanged so the
|
||||
// change is not lost when the in-flight ReportState finishes.
|
||||
require.NoError(t, fire(reporter, logEntry{
|
||||
Message: "step starts",
|
||||
Data: map[string]any{"stage": "Main", "stepNumber": 1, "raw_output": true},
|
||||
}))
|
||||
|
||||
// Release the in-flight UpdateTask and wait for it to return.
|
||||
close(release)
|
||||
require.NoError(t, <-done)
|
||||
|
||||
// stateChanged must still be true so the next ReportState picks up the
|
||||
// concurrent record's change instead of skipping via the early-return path.
|
||||
reporter.stateMu.RLock()
|
||||
changed := reporter.stateChanged
|
||||
reporter.stateMu.RUnlock()
|
||||
assert.True(t, changed, "stateChanged must remain true after a concurrent log record during in-flight ReportState")
|
||||
|
||||
// And the next ReportState must actually send a second UpdateTask.
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
assert.Equal(t, int64(2), updateTaskCalls.Load(), "concurrent log record change must trigger a second UpdateTask, not be silently lost")
|
||||
}
|
||||
|
||||
// TestReporter_StateChangedRestoredOnError verifies that when UpdateTask fails,
|
||||
// the dirty flag is restored so the snapshotted change isn't silently lost.
|
||||
func TestReporter_StateChangedRestoredOnError(t *testing.T) {
|
||||
var updateTaskCalls atomic.Int64
|
||||
|
||||
@@ -409,19 +293,14 @@ func TestReporter_StateChangedRestoredOnError(t *testing.T) {
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
|
||||
reporter.stateMu.Lock()
|
||||
reporter.stateChanged = true
|
||||
reporter.stateMu.Unlock()
|
||||
|
||||
// First ReportState fails — flag must be restored to true.
|
||||
require.Error(t, reporter.ReportState(false))
|
||||
|
||||
reporter.stateMu.RLock()
|
||||
@@ -429,13 +308,10 @@ func TestReporter_StateChangedRestoredOnError(t *testing.T) {
|
||||
reporter.stateMu.RUnlock()
|
||||
assert.True(t, changed, "stateChanged must be restored to true after UpdateTask error so the change is retried")
|
||||
|
||||
// The next ReportState should still issue a request because the flag was restored.
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
assert.Equal(t, int64(2), updateTaskCalls.Load())
|
||||
}
|
||||
|
||||
// TestReporter_StateNotifyFlush verifies that step transitions trigger
|
||||
// an immediate state flush via the stateNotify channel.
|
||||
func TestReporter_StateNotifyFlush(t *testing.T) {
|
||||
var updateTaskCalls atomic.Int64
|
||||
|
||||
@@ -454,41 +330,28 @@ func TestReporter_StateNotifyFlush(t *testing.T) {
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Custom config: large state interval so only stateNotify can trigger
|
||||
cfg, _ := config.LoadDefault("")
|
||||
cfg.Runner.StateReportInterval = 10 * time.Second
|
||||
cfg.Runner.LogReportInterval = 10 * time.Second
|
||||
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
reporter.RunDaemon()
|
||||
defer func() {
|
||||
_ = reporter.Close("")
|
||||
}()
|
||||
|
||||
// Fire a log entry that starts a step — this triggers stateNotify
|
||||
require.NoError(t, fire(reporter, logEntry{
|
||||
Message: "step starting",
|
||||
Data: map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true},
|
||||
}))
|
||||
|
||||
// stateNotify should trigger immediate UpdateTask call
|
||||
assert.Eventually(t, func() bool {
|
||||
return updateTaskCalls.Load() > 0
|
||||
}, 500*time.Millisecond, 10*time.Millisecond,
|
||||
"step transition should have triggered immediate state flush via stateNotify")
|
||||
}
|
||||
|
||||
// Regression test for https://gitea.com/gitea/runner/issues/950: Close() must
|
||||
// always send a final UpdateLog with NoMore=true carrying at least one row,
|
||||
// otherwise the server's len(Rows)==0 short-circuit skips TransferLogs.
|
||||
// TODO: Remove after https://github.com/go-gitea/gitea/pull/37631 is in all
|
||||
// supported branches, e.g. v1.28+.
|
||||
func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) {
|
||||
var lastReq atomic.Pointer[runnerv1.UpdateLogRequest]
|
||||
var noMoreCalls atomic.Int64
|
||||
@@ -511,25 +374,16 @@ func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) {
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Intervals large enough that no daemon-driven flush fires during the test.
|
||||
cfg, _ := config.LoadDefault("")
|
||||
cfg.Runner.LogReportInterval = 10 * time.Second
|
||||
cfg.Runner.LogReportMaxLatency = 10 * time.Second
|
||||
cfg.Runner.StateReportInterval = 10 * time.Second
|
||||
cfg.Runner.LogReportBatchSize = 1000
|
||||
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
reporter.RunDaemon()
|
||||
|
||||
// Simulate a successful job whose log buffer was already drained by the
|
||||
// daemon (logOffset > 0, logRows empty, terminal Result set). This is the
|
||||
// state Close() lands in for the typical successful job under #819.
|
||||
reporter.stateMu.Lock()
|
||||
reporter.logOffset = 5
|
||||
reporter.state.Result = runnerv1.Result_RESULT_SUCCESS
|
||||
@@ -546,11 +400,6 @@ func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) {
|
||||
assert.NotEmpty(t, final.Rows, "final UpdateLog must carry at least one row")
|
||||
}
|
||||
|
||||
// TestReporter_StateHeartbeat verifies that ReportState sends a heartbeat
|
||||
// UpdateTask once stateReportInterval has elapsed since the last successful
|
||||
// report, even when nothing has changed. Without this, long-running silent
|
||||
// jobs (no log output, no step transitions) cause the server to time the
|
||||
// task out and cancel it (#826).
|
||||
func TestReporter_StateHeartbeat(t *testing.T) {
|
||||
var updateTaskCalls atomic.Int64
|
||||
|
||||
@@ -562,39 +411,25 @@ func TestReporter_StateHeartbeat(t *testing.T) {
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
cfg.Runner.StateReportInterval = 50 * time.Millisecond
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
|
||||
// First call has no prior report — sends to seed lastReportedAt.
|
||||
reporter.stateMu.Lock()
|
||||
reporter.stateChanged = true
|
||||
reporter.stateMu.Unlock()
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
require.Equal(t, int64(1), updateTaskCalls.Load())
|
||||
|
||||
// Second call immediately after with nothing changed — must skip.
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
assert.Equal(t, int64(1), updateTaskCalls.Load(), "no-op ReportState within stateReportInterval must skip")
|
||||
|
||||
// After stateReportInterval elapses, a heartbeat must fire even with no changes.
|
||||
time.Sleep(2 * cfg.Runner.StateReportInterval)
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
assert.Equal(t, int64(2), updateTaskCalls.Load(), "ReportState must heartbeat after stateReportInterval even with no state change")
|
||||
}
|
||||
|
||||
// TestReporter_ServerCancelStillFlushesFinal asserts that when the Gitea server
|
||||
// returns RESULT_CANCELLED on an in-flight UpdateTask (which causes the
|
||||
// reporter to cancel the task context), Close() still successfully sends the
|
||||
// final UpdateLog{NoMore:true} and the final UpdateTask carrying the populated
|
||||
// final state. Before the fix this final flush used r.ctx, which was just
|
||||
// cancelled, so retry-go aborted on its context check and Gitea never received
|
||||
// the runner's acknowledgement of the cancel.
|
||||
func TestReporter_ServerCancelStillFlushesFinal(t *testing.T) {
|
||||
var (
|
||||
updateTaskCalls atomic.Int64
|
||||
@@ -613,10 +448,6 @@ func TestReporter_ServerCancelStillFlushesFinal(t *testing.T) {
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
// The first UpdateTask returns RESULT_CANCELLED — modelling a server-side
|
||||
// cancellation; the reporter must call r.cancel() in response. The final
|
||||
// UpdateTask issued by Close() must still arrive even though r.ctx is now
|
||||
// cancelled.
|
||||
client.On("UpdateTask", mock.Anything, mock.Anything).Return(
|
||||
func(_ context.Context, req *runnerv1.UpdateTaskRequest) (*runnerv1.UpdateTaskResponse, error) {
|
||||
n := updateTaskCalls.Add(1)
|
||||
@@ -642,42 +473,27 @@ func TestReporter_ServerCancelStillFlushesFinal(t *testing.T) {
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
|
||||
// Force the first ReportState to actually call UpdateTask.
|
||||
reporter.stateMu.Lock()
|
||||
reporter.stateChanged = true
|
||||
reporter.stateMu.Unlock()
|
||||
|
||||
// First ReportState — server returns RESULT_CANCELLED, reporter cancels r.ctx.
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
require.Equal(t, int64(1), updateTaskCalls.Load())
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Expected: reporter called cancel() because the server reported the task as cancelled.
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected r.ctx to be cancelled after server returned RESULT_CANCELLED")
|
||||
}
|
||||
|
||||
// The test does not start the daemon goroutine; close(r.daemon) so Close()
|
||||
// proceeds without waiting on its 60s timeout.
|
||||
close(reporter.daemon)
|
||||
|
||||
// Now Close() runs. Before the fix, both final RPCs aborted on the cancelled
|
||||
// r.ctx via retry.Context. After the fix, Close() uses a detached context and
|
||||
// the per-RPC rpcCtx() falls back to a fresh ctx, so both calls succeed.
|
||||
require.NoError(t, reporter.Close("cancelled"))
|
||||
|
||||
assert.True(t, finalLogNoMoreSeen.Load(), "Close() must send a final UpdateLog{NoMore:true} even after server-side cancellation")
|
||||
assert.True(t, finalTaskStateSeen.Load(), "Close() must send a final UpdateTask with the populated final state even after server-side cancellation")
|
||||
}
|
||||
|
||||
// TestReporter_CloseReportsCancelledOnCanceledCtx asserts that when Close()
|
||||
// runs on a reporter whose state has not been finalised AND whose context has
|
||||
// been cancelled, the synthesised final state carries RESULT_CANCELLED and
|
||||
// the appended log row reads "Cancelled" — not RESULT_FAILURE / "Early
|
||||
// termination". This is the runner-side half of the Running -> Cancelling ->
|
||||
// Cancelled flow: it gives Gitea an explicit cancel acknowledgement rather
|
||||
// than a generic failure when the job is torn down on the cancel path.
|
||||
func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) {
|
||||
var finalState atomic.Pointer[runnerv1.TaskState]
|
||||
var finalLogRows atomic.Pointer[[]*runnerv1.LogRow]
|
||||
@@ -710,13 +526,10 @@ func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) {
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
|
||||
// Simulate the cancellation path: r.ctx is cancelled before Close() runs.
|
||||
cancel()
|
||||
|
||||
// Skip the daemon wait inside Close().
|
||||
close(reporter.daemon)
|
||||
|
||||
// Empty lastWords so Close() picks the synthesised value.
|
||||
require.NoError(t, reporter.Close(""))
|
||||
|
||||
got := finalState.Load()
|
||||
@@ -742,8 +555,6 @@ func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) {
|
||||
assert.False(t, foundEarlyTermination, "final log must not contain 'Early termination' on the cancel path")
|
||||
}
|
||||
|
||||
// TestReporter_StopHeartbeats verifies that StopHeartbeats ends periodic
|
||||
// UpdateTask heartbeats while Close() still flushes the final state.
|
||||
func TestReporter_StopHeartbeats(t *testing.T) {
|
||||
var updateTaskCalls atomic.Int64
|
||||
|
||||
@@ -762,17 +573,12 @@ func TestReporter_StopHeartbeats(t *testing.T) {
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := config.LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
cfg.Runner.StateReportInterval = 20 * time.Millisecond
|
||||
cfg.Runner.LogReportInterval = time.Hour
|
||||
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
reporter.RunDaemon()
|
||||
|
||||
@@ -810,7 +616,6 @@ func TestAppendLogRow(t *testing.T) {
|
||||
require.Len(t, r.logRows, 1)
|
||||
assert.Equal(t, "hello ***", r.logRows[0].Content)
|
||||
|
||||
// repairing the invalid byte spells out the secret, so the repair has to come first
|
||||
r = &Reporter{logReplacer: strings.NewReplacer("a?b", "***")}
|
||||
r.appendLogRow(&runnerv1.LogRow{Time: timestamppb.Now(), Content: "a\xffb"})
|
||||
assert.Equal(t, "***", r.logRows[0].Content)
|
||||
@@ -835,34 +640,28 @@ func TestReporter_SetOutputs(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "bar", got.value)
|
||||
|
||||
// first value wins: a later write to the same key is ignored
|
||||
r.SetOutputs(map[string]string{"foo": "baz"})
|
||||
assert.Equal(t, "bar", r.outputs["foo"].value)
|
||||
|
||||
// keys longer than maxOutputKeyLen are dropped
|
||||
longKey := strings.Repeat("k", maxOutputKeyLen+1)
|
||||
r.SetOutputs(map[string]string{longKey: "v"})
|
||||
_, ok = r.outputs[longKey]
|
||||
assert.False(t, ok)
|
||||
|
||||
// values longer than maxOutputValueLen are dropped
|
||||
longValue := strings.Repeat("v", maxOutputValueLen+1)
|
||||
r.SetOutputs(map[string]string{"big": longValue})
|
||||
_, ok = r.outputs["big"]
|
||||
assert.False(t, ok)
|
||||
|
||||
// a value carrying a secret is skipped, as GitHub does, rather than sent masked
|
||||
r.SetOutputs(map[string]string{"leaky": "has s3cr3t in it"})
|
||||
_, ok = r.outputs["leaky"]
|
||||
assert.False(t, ok)
|
||||
|
||||
// invalid UTF-8 is not a secret, so the value is kept as it is rather than dropped
|
||||
r.SetOutputs(map[string]string{"binary": "caf\xff"})
|
||||
got, ok = r.outputs["binary"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "caf\xff", got.value)
|
||||
|
||||
// a value at exactly the limit is still stored
|
||||
maxValue := strings.Repeat("v", maxOutputValueLen)
|
||||
r.SetOutputs(map[string]string{"atlimit": maxValue})
|
||||
got, ok = r.outputs["atlimit"]
|
||||
@@ -870,8 +669,6 @@ func TestReporter_SetOutputs(t *testing.T) {
|
||||
assert.Len(t, got.value, maxOutputValueLen)
|
||||
}
|
||||
|
||||
// Gitea delivers ACTIONS_STEP_DEBUG as a secret, so masking "true" would drop any job output
|
||||
// saying it. GitHub skips the same two keys.
|
||||
func TestReporter_DebugSettingsAreNotMasked(t *testing.T) {
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
@@ -886,10 +683,9 @@ func TestReporter_DebugSettingsAreNotMasked(t *testing.T) {
|
||||
assert.Equal(t, "***", reporter.mask("s3cr3t"))
|
||||
|
||||
reporter.SetOutputs(map[string]string{"changed": "true"})
|
||||
assert.Equal(t, "true", reporter.outputs["changed"].value) // needs.<job>.outputs.changed == 'true' still works
|
||||
assert.Equal(t, "true", reporter.outputs["changed"].value)
|
||||
}
|
||||
|
||||
// An output the server acknowledged is not reported again.
|
||||
func TestReporter_OutputsSentOnce(t *testing.T) {
|
||||
client := mocks.NewClient(t)
|
||||
var reported []map[string]string
|
||||
@@ -899,12 +695,8 @@ func TestReporter_OutputsSentOnce(t *testing.T) {
|
||||
return &runnerv1.UpdateTaskResponse{SentOutputs: slices.Collect(maps.Keys(req.Outputs))}, nil
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
r := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
r := newTestReporter(t, client, cfg)
|
||||
|
||||
r.SetOutputs(map[string]string{"foo": "bar"})
|
||||
require.NoError(t, r.ReportState(false))
|
||||
@@ -935,8 +727,6 @@ func TestReporter_FlushFinalRetriesUntilSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReporter_ParseResult(t *testing.T) {
|
||||
r := &Reporter{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
@@ -951,15 +741,13 @@ func TestReporter_ParseResult(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := r.parseResult(tt.input)
|
||||
got, ok := parseResult(tt.input)
|
||||
assert.Equal(t, tt.wantOk, ok)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A secret leaked in an encoded form — the shape it takes once an action puts it in a
|
||||
// JSON body, a URL or a base64 payload — must be masked in the reported log as well.
|
||||
func TestReporter_masksEncodedSecrets(t *testing.T) {
|
||||
secret := `p@ss w"rd/1`
|
||||
r := &Reporter{logReplacer: strings.NewReplacer()}
|
||||
@@ -978,20 +766,14 @@ func TestReporter_masksEncodedSecrets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A server that acknowledges more rows than were sent must not take the runner
|
||||
// process down with it.
|
||||
func TestReporter_AckIndexBeyondBuffer(t *testing.T) {
|
||||
client := mocks.NewClient(t)
|
||||
client.On("UpdateLog", mock.Anything, mock.Anything).Return(func(_ context.Context, _ *runnerv1.UpdateLogRequest) (*runnerv1.UpdateLogResponse, error) {
|
||||
return &runnerv1.UpdateLogResponse{AckIndex: 1000}, nil
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
|
||||
require.NoError(t, fire(reporter, logEntry{
|
||||
@@ -1003,7 +785,6 @@ func TestReporter_AckIndexBeyondBuffer(t *testing.T) {
|
||||
require.NoError(t, reporter.ReportLog(false))
|
||||
}
|
||||
|
||||
// Close has to survive giving up on a daemon still parked in an RPC.
|
||||
func TestReporter_CloseWithStuckDaemon(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
entered := make(chan struct{})
|
||||
@@ -1023,13 +804,9 @@ func TestReporter_CloseWithStuckDaemon(t *testing.T) {
|
||||
return &runnerv1.UpdateTaskResponse{}, nil
|
||||
}).Maybe()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
cfg.Runner.LogReportInterval = 10 * time.Millisecond
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
reporter.daemonWait = time.Millisecond
|
||||
reporter.RunDaemon()
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
@@ -1053,8 +830,6 @@ func TestReporter_CloseWithStuckDaemon(t *testing.T) {
|
||||
require.NoError(t, reporter.Close(""))
|
||||
}
|
||||
|
||||
// The zombie reaper marks a task failed, and cancelling the context is how the
|
||||
// runner stops, so the two must not be conflated into "cancelled".
|
||||
func TestReporter_ServerFailureResultIsReportedAsFailure(t *testing.T) {
|
||||
var lastState *runnerv1.TaskState
|
||||
client := mocks.NewClient(t)
|
||||
@@ -1076,7 +851,7 @@ func TestReporter_ServerFailureResultIsReportedAsFailure(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
cfg, _ := config.LoadDefault("")
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
close(reporter.daemon) // no daemon loop to acknowledge
|
||||
close(reporter.daemon)
|
||||
|
||||
require.NoError(t, reporter.ReportState(false))
|
||||
require.ErrorIs(t, ctx.Err(), context.Canceled)
|
||||
@@ -1091,11 +866,9 @@ func TestReporter_NoteReport(t *testing.T) {
|
||||
|
||||
reporter := &Reporter{reportFailing: map[string]bool{}}
|
||||
|
||||
// A pending output is not a transport failure and must not read as one.
|
||||
reporter.noteReport(metrics.LabelMethodUpdateTask, fmt.Errorf("wrapped: %w", errOutputsNotSent))
|
||||
assert.Empty(t, hook.AllRecords())
|
||||
|
||||
// An outage logs once at each end, not every report interval for hours.
|
||||
reporter.noteReport(metrics.LabelMethodUpdateTask, errors.New("connection refused"))
|
||||
reporter.noteReport(metrics.LabelMethodUpdateTask, errors.New("connection refused"))
|
||||
require.Len(t, hook.AllRecords(), 1)
|
||||
@@ -1106,8 +879,6 @@ func TestReporter_NoteReport(t *testing.T) {
|
||||
assert.Contains(t, hook.LastRecord().Message, "reconnected")
|
||||
}
|
||||
|
||||
// A job's final error can carry a secret, e.g. one interpolated into a failing
|
||||
// expression, so Close must mask it like every other row.
|
||||
func TestReporter_CloseMasksLastWords(t *testing.T) {
|
||||
const secret = "supersecret"
|
||||
var rows []*runnerv1.LogRow
|
||||
@@ -1145,9 +916,6 @@ func TestReporter_CloseMasksLastWords(t *testing.T) {
|
||||
assert.Equal(t, "could not get job matrix: *** ***", rows[0].Content)
|
||||
}
|
||||
|
||||
// giteaLogModel mirrors how Gitea stores a task log: UpdateLog appends rows to one
|
||||
// stream, and UpdateTask overwrites the per-step ranges the web UI slices that stream by
|
||||
// (modules/actions/task_state.go, FullSteps).
|
||||
type giteaLogModel struct {
|
||||
mu sync.Mutex
|
||||
rows int64
|
||||
@@ -1169,15 +937,12 @@ func (m *giteaLogModel) applyState(state *runnerv1.TaskState) {
|
||||
}
|
||||
}
|
||||
|
||||
// snapshot returns the accepted rows and how far the reported step ranges reach into
|
||||
// them. The remainder is what the web UI renders under "Complete job".
|
||||
func (m *giteaLogModel) snapshot() (rows, claimed int64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.rows, m.claimed
|
||||
}
|
||||
|
||||
// Regression test for https://gitea.com/gitea/runner/issues/1184.
|
||||
func TestReporter_StepRangeCoversAckedRows(t *testing.T) {
|
||||
server := &giteaLogModel{}
|
||||
|
||||
@@ -1196,20 +961,13 @@ func TestReporter_StepRangeCoversAckedRows(t *testing.T) {
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Only the batch threshold may flush: the tickers are the periodic repair this test
|
||||
// must not depend on, and Close() is the one after the damage is done.
|
||||
cfg, _ := config.LoadDefault("")
|
||||
cfg.Runner.LogReportInterval = time.Hour
|
||||
cfg.Runner.LogReportMaxLatency = time.Hour
|
||||
cfg.Runner.StateReportInterval = time.Hour
|
||||
cfg.Runner.LogReportBatchSize = 5
|
||||
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
reporter.ResetSteps(newTestSteps(1))
|
||||
reporter.RunDaemon()
|
||||
defer func() {
|
||||
@@ -1218,7 +976,6 @@ func TestReporter_StepRangeCoversAckedRows(t *testing.T) {
|
||||
|
||||
stepData := map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true}
|
||||
|
||||
// The step's first row is a transition, so it flushes state as well as logs.
|
||||
require.NoError(t, fire(reporter, logEntry{Message: "step starting", Data: stepData}))
|
||||
require.Eventually(t, func() bool {
|
||||
rows, claimed := server.snapshot()
|
||||
@@ -1239,14 +996,7 @@ func TestReporter_StepRangeCoversAckedRows(t *testing.T) {
|
||||
"every log row the server accepted must be covered by a reported step range")
|
||||
}
|
||||
|
||||
// A flush the server took no rows for describes nothing new, so it must not spend an
|
||||
// UpdateTask. UpdateTask is never registered on the client, so calling it fails the test.
|
||||
func TestReporter_FlushWithoutAckedRowsSkipsState(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
|
||||
client := mocks.NewClient(t)
|
||||
client.On("UpdateLog", mock.Anything, mock.Anything).Once().Return(
|
||||
func(_ context.Context, _ *runnerv1.UpdateLogRequest) (*runnerv1.UpdateLogResponse, error) {
|
||||
@@ -1255,16 +1005,23 @@ func TestReporter_FlushWithoutAckedRowsSkipsState(t *testing.T) {
|
||||
)
|
||||
|
||||
cfg, _ := config.LoadDefault("")
|
||||
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
reporter := newTestReporter(t, client, cfg)
|
||||
|
||||
// An idle job: no rows, so not even an UpdateLog.
|
||||
reporter.reportLogWithState()
|
||||
|
||||
// A row the server declines to take: an UpdateLog, but still no step range to report.
|
||||
reporter.Logf("row")
|
||||
reporter.reportLogWithState()
|
||||
}
|
||||
|
||||
func newTestReporter(t *testing.T, client *mocks.Client, cfg *config.Config) *Reporter {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
t.Cleanup(cancel)
|
||||
taskCtx, err := structpb.NewStruct(map[string]any{})
|
||||
require.NoError(t, err)
|
||||
return NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
|
||||
}
|
||||
|
||||
func newTestSteps(n int) []*model.Step {
|
||||
steps := make([]*model.Step, n)
|
||||
for i := range steps {
|
||||
@@ -1353,7 +1110,7 @@ func TestReporter_ReportedSteps(t *testing.T) {
|
||||
{main, 0, "Echo 1", success, 6, 2},
|
||||
{main, 1, "Run echo 1", skipped, 0, 0},
|
||||
{post, 0, "Post Echo 1", success, 8, 3},
|
||||
{cleanup, 0, "Complete job", unspecified, 11, 2}, // still logging after the job result
|
||||
{cleanup, 0, "Complete job", unspecified, 11, 2},
|
||||
}
|
||||
assert.Equal(t, want, reportedOf(t, reporter))
|
||||
|
||||
@@ -1362,7 +1119,6 @@ func TestReporter_ReportedSteps(t *testing.T) {
|
||||
want[5].result, want[5].logLen = success, 3
|
||||
assert.Equal(t, want, reportedOf(t, reporter))
|
||||
|
||||
// the workflow steps keep what Gitea versions deriving them from the workflow file expect
|
||||
require.Len(t, reporter.state.Steps, 2)
|
||||
assert.EqualValues(t, 6, reporter.state.Steps[0].LogIndex)
|
||||
assert.EqualValues(t, 2, reporter.state.Steps[0].LogLength)
|
||||
@@ -1403,7 +1159,7 @@ func TestReporter_ReportedSteps(t *testing.T) {
|
||||
"Set up job": success,
|
||||
"Pre Run echo 0": success,
|
||||
"Pre Run echo 2": success,
|
||||
"Run echo 0": failure, // not the result of its composite step
|
||||
"Run echo 0": failure,
|
||||
"Run echo 1": cancelled,
|
||||
"Run echo 2": cancelled,
|
||||
"Post Run echo 0": failure,
|
||||
@@ -1473,7 +1229,6 @@ func TestReporter_ReportedSteps(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// logEntry is the record a job logger hands the Reporter, with the fields it accumulated as Data.
|
||||
type logEntry struct {
|
||||
Message string
|
||||
Data map[string]any
|
||||
|
||||
Reference in new issue
Block a user