diff --git a/README.md b/README.md index 1dfb1f32..44097088 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubunt Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas. -If a job's `runs-on` matches none of the runner's labels, the job still runs, in the default `docker.gitea.com/runner-images:ubuntu-latest` image. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images). +If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs: in `runner.default_image` where docker is available, on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images). Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings. diff --git a/internal/app/cmd/exec.go b/internal/app/cmd/exec.go index ae2e71b9..62824b37 100644 --- a/internal/app/cmd/exec.go +++ b/internal/app/cmd/exec.go @@ -558,7 +558,7 @@ func loadExecCmd(ctx context.Context) *cobra.Command { execCmd.PersistentFlags().BoolVarP(&execArg.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout") execCmd.PersistentFlags().BoolVarP(&execArg.debug, "debug", "d", false, "enable debug log") execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun mode") - execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", "docker.gitea.com/runner-images:ubuntu-latest", "Docker image to use. Use \"-self-hosted\" to run directly on the host.") + execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", config.DefaultImage, "Docker image to use. Use \"-self-hosted\" to run directly on the host.") execCmd.PersistentFlags().StringVarP(&execArg.toolCacheMode, "tool-cache-mode", "", config.ToolCacheModeNone, "What to mount at RUNNER_TOOL_CACHE: none, or shared to reuse one tool cache across runs") execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "Specify the network to which the container will connect") execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "Gitea instance to use.") diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index 0bfa2adf..77ce7fdb 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -28,6 +28,7 @@ import ( "gitea.com/gitea/runner/internal/pkg/client" "gitea.com/gitea/runner/internal/pkg/config" "gitea.com/gitea/runner/internal/pkg/disk" + "gitea.com/gitea/runner/internal/pkg/envcheck" "gitea.com/gitea/runner/internal/pkg/labels" "gitea.com/gitea/runner/internal/pkg/metrics" "gitea.com/gitea/runner/internal/pkg/report" @@ -172,7 +173,7 @@ func (r *Runner) OnIdle(ctx context.Context) { // directories above, a task beginning during the pass is safe because the cutoff keeps a // network it has created but not yet attached a container to out of scope. func (r *Runner) cleanupOrphanNetworks(ctx context.Context) { - if r.uuid == "" || !r.labels.RequireDocker() && !r.cfg.Container.RequireDocker { + if r.uuid == "" || !r.requiresDocker() { return } cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge) @@ -347,6 +348,27 @@ func (r *Runner) isSelfHostedActionsURL(task *runnerv1.Task) bool { return giteaDefaultActionsURL != "" && giteaDefaultActionsURL != "https://github.com" } +// dockerReachable is a variable so tests can substitute one that needs no Docker daemon. It +// probes the environment act connects through, not container.docker_host, which act ignores. +var dockerReachable = func(ctx context.Context) bool { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + return envcheck.CheckIfDockerRunning(ctx, "") == nil +} + +func (r *Runner) requiresDocker() bool { + return r.labels.RequireDocker() || r.cfg.Container.RequireDocker +} + +// fallbackPlatform is where a job runs whose runs-on matches no label, as any job without a +// runs-on does, since Gitea sends those to every runner. +func (r *Runner) fallbackPlatform(ctx context.Context) string { + if r.requiresDocker() || dockerReachable(ctx) { + return r.cfg.Runner.DefaultImage + } + return labels.SelfHostedPlatform +} + func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.Reporter) (err error) { defer func() { if r := recover(); r != nil { @@ -475,6 +497,15 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. // Without bind_workdir, the workspace path omits the task id; concurrent host-mode jobs // for the same repository would share this directory and can race with per-job cleanup. + // act asks for the platform once per step, so resolve the fallback at most once per task. + fallbackPlatform := sync.OnceValue(func() string { return r.fallbackPlatform(ctx) }) + platformPicker := func(runsOn []string) string { + if platform := r.labels.PickPlatform(runsOn); platform != "" { + return platform + } + return fallbackPlatform() + } + runnerConfig := &runner.Config{ // On Linux, Workdir will be like "///" // On Windows, Workdir will be like "\\\" @@ -517,7 +548,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. Privileged: r.cfg.Container.Privileged, DefaultActionInstance: r.getDefaultActionsURL(task), DefaultActionInstanceIsSelfHosted: r.isSelfHostedActionsURL(task), - PlatformPicker: r.labels.PickPlatform, + PlatformPicker: platformPicker, JobStartedHook: r.cfg.Runner.Hooks.JobStarted, JobCompletedHook: r.cfg.Runner.Hooks.JobCompleted, Vars: task.Vars, diff --git a/internal/app/run/runner_test.go b/internal/app/run/runner_test.go index 79f7d34f..4283f607 100644 --- a/internal/app/run/runner_test.go +++ b/internal/app/run/runner_test.go @@ -12,6 +12,7 @@ import ( "gitea.com/gitea/runner/act/runner" clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks" "gitea.com/gitea/runner/internal/pkg/config" + "gitea.com/gitea/runner/internal/pkg/labels" "gitea.com/gitea/runner/internal/pkg/ver" "connectrpc.com/connect" @@ -98,6 +99,34 @@ func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) { require.Empty(t, r.envs[runner.CacheServiceV2Env], "no cache server, nothing to serve v2 from") } +func TestRunnerFallbackPlatform(t *testing.T) { + tests := []struct { + name string + label string + dockerRunning bool + want string + }{ + {"a docker label needs no daemon probe", "ubuntu:docker://node:18", false, "mirror.example/ci:noble"}, + {"host labels keep the image where docker runs", "ubuntu:host", true, "mirror.example/ci:noble"}, + {"host labels without docker run on the host", "ubuntu:host", false, labels.SelfHostedPlatform}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reachable := dockerReachable + dockerReachable = func(context.Context) bool { return tt.dockerRunning } + t.Cleanup(func() { dockerReachable = reachable }) + + label, err := labels.Parse(tt.label) + require.NoError(t, err) + cfg := &config.Config{} + cfg.Runner.DefaultImage = "mirror.example/ci:noble" + r := &Runner{cfg: cfg, labels: labels.Labels{label}} + + require.Equal(t, tt.want, r.fallbackPlatform(t.Context())) + }) + } +} + // Proxy variables are assembled per task, because a job's service containers have to be // reached directly and they are only known once the workflow is parsed. func TestNewRunnerLeavesProxyToTheTask(t *testing.T) { diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index e5aea4e3..c8922a2f 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -94,6 +94,9 @@ runner: # terminal; tools like `docker build` emit redrawing progress frames into the captured log # when a TTY is present. #allocate_pty: false + # Image for a job whose runs-on matches none of the labels above. A runner without a + # docker daemon runs such a job on the host instead. + #default_image: "docker.gitea.com/runner-images:ubuntu-latest" # What to mount at RUNNER_TOOL_CACHE (/opt/hostedtoolcache), where setup actions install tools: # none: nothing. A docker job sees what its image ships there, a host job an empty dir, and # either way what it installs is gone when the job ends. diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 6a71c898..f432dc73 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -23,6 +23,9 @@ import ( // RequestTimeout bounds every RPC to Gitea, and with it runner.fetch_timeout. const RequestTimeout = 60 * time.Second +// DefaultImage is the image jobs run in unless a label or runner.default_image names another. +const DefaultImage = "docker.gitea.com/runner-images:ubuntu-latest" + // DefaultPostTaskScriptTimeout is the fallback cap on how long the post-task // script may run when post_task_script is set without an explicit timeout. It is // applied both at config load (for a configured script) and at the point of use @@ -64,6 +67,7 @@ type Runner struct { ActionShallowClone *bool `yaml:"action_shallow_clone"` // ActionShallowClone fetches only the requested ref of an action repository at depth 1 instead of cloning every branch's full history. It is a pointer to distinguish between false and not set; if not set, it defaults to true. SetActEnv *bool `yaml:"set_act_env"` // SetActEnv controls whether the ACT=true environment variable is injected into jobs. It is a pointer to distinguish between false and not set; if not set, it defaults to true. Set it to false so workflows gated on `if: ${{ !env.ACT }}` behave like on GitHub. AllocatePTY bool `yaml:"allocate_pty"` // AllocatePTY allocates a pseudo-TTY for each step's process. Default is false, matching GitHub's actions/runner. Enable only for jobs that need an interactive terminal; tools like docker build emit redrawing progress frames into the captured log when a TTY is present. Applies to both host and docker backends. + DefaultImage string `yaml:"default_image"` // DefaultImage is the image a job runs in when its runs-on matches none of the runner's labels. A runner without docker runs such a job on the host instead. ToolCacheMode string `yaml:"tool_cache_mode"` // ToolCacheMode is what the runner mounts at RUNNER_TOOL_CACHE on both backends: ToolCacheModeNone or ToolCacheModeShared. PostTaskScript string `yaml:"post_task_script"` // PostTaskScript is the path to an executable script run on the host after each task's cleanup completes. Empty disables the hook. On Windows use .exe/.bat/.cmd; PowerShell (.ps1) is not supported yet as the configured path. PostTaskScriptTimeout time.Duration `yaml:"post_task_script_timeout"` // PostTaskScriptTimeout caps how long the post-task script may run. Default is 5m when post_task_script is set. @@ -270,6 +274,9 @@ func LoadDefault(file string) (*Config, error) { if cfg.Container.WorkdirParent == "" { cfg.Container.WorkdirParent = "workspace" } + if cfg.Runner.DefaultImage == "" { + cfg.Runner.DefaultImage = DefaultImage + } if cfg.Runner.ToolCacheMode == "" { cfg.Runner.ToolCacheMode = ToolCacheModeNone } diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index bd860357..471ec29b 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -64,6 +64,7 @@ func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) { 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) { diff --git a/internal/pkg/labels/labels.go b/internal/pkg/labels/labels.go index 342d5b4d..66e3f8e2 100644 --- a/internal/pkg/labels/labels.go +++ b/internal/pkg/labels/labels.go @@ -11,6 +11,9 @@ import ( const ( SchemeHost = "host" SchemeDocker = "docker" + + // SelfHostedPlatform is the platform marker act treats as "run on the host". + SelfHostedPlatform = "-self-hosted" ) type Label struct { @@ -61,6 +64,7 @@ func (l Labels) RequireDocker() bool { return false } +// PickPlatform returns the platform of the first runs-on entry this runner has a label for, or "". func (l Labels) PickPlatform(runsOn []string) string { platforms := make(map[string]string, len(l)) for _, label := range l { @@ -69,7 +73,7 @@ func (l Labels) PickPlatform(runsOn []string) string { // "//" will be ignored platforms[label.Name] = strings.TrimPrefix(label.Arg, "//") case SchemeHost: - platforms[label.Name] = "-self-hosted" + platforms[label.Name] = SelfHostedPlatform default: // unreachable: Parse only produces host or docker schemas continue @@ -80,18 +84,7 @@ func (l Labels) PickPlatform(runsOn []string) string { return v } } - - // TODO: support multiple labels - // like: - // ["ubuntu-22.04"] => "ubuntu:22.04" - // ["with-gpu"] => "linux:with-gpu" - // ["ubuntu-22.04", "with-gpu"] => "ubuntu:22.04_with-gpu" - - // return default. - // So the runner receives a task with a label that the runner doesn't have, - // it happens when the user have edited the label of the runner in the web UI. - // TODO: it may be not correct, what if the runner is used as host mode only? - return "docker.gitea.com/runner-images:ubuntu-latest" + return "" } func (l Labels) Names() []string { diff --git a/internal/pkg/labels/labels_test.go b/internal/pkg/labels/labels_test.go index 8d8eb598..d70e1c32 100644 --- a/internal/pkg/labels/labels_test.go +++ b/internal/pkg/labels/labels_test.go @@ -123,10 +123,10 @@ func TestPickPlatform(t *testing.T) { want string }{ {"docker strips leading slashes", []string{"ubuntu"}, "node:18"}, - {"host maps to self-hosted marker", []string{"self-hosted"}, "-self-hosted"}, - {"first match wins", []string{"self-hosted", "ubuntu"}, "-self-hosted"}, - {"unknown falls back to default", []string{"windows"}, "docker.gitea.com/runner-images:ubuntu-latest"}, - {"no runsOn falls back to default", nil, "docker.gitea.com/runner-images:ubuntu-latest"}, + {"host maps to self-hosted marker", []string{"self-hosted"}, SelfHostedPlatform}, + {"first match wins", []string{"self-hosted", "ubuntu"}, SelfHostedPlatform}, + {"unknown label picks nothing", []string{"windows"}, ""}, + {"no runsOn picks nothing", nil, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -167,5 +167,5 @@ func TestOpaqueLabelRoundTrip(t *testing.T) { require.Equal(t, ls, again) require.Equal(t, []string{raw}, again.Names()) require.False(t, again.RequireDocker()) - require.Equal(t, "-self-hosted", again.PickPlatform([]string{raw})) + require.Equal(t, SelfHostedPlatform, again.PickPlatform([]string{raw})) }