fix: allow relocating the tool cache and mounting over runner paths (#1122)

1. Docker rejects two mounts on one target, so a `container.volumes:` or `--volume` aimed at `/opt/hostedtoolcache` failed the job with `Duplicate mount point`. Job and service volumes now displace the mount on the same path, and `name:/target:ro` no longer mounts read-write at the literal path `/target:ro`.
1. Setting `RUNNER_TOOL_CACHE` only changed what the variable said, the cache stayed where it was, so tools writing to it landed outside the mount and `${{ runner.tool_cache }}` disagreed with the variable. It now relocates the cache. Leaving it unset behaves as before.
1. Unknown `config.yaml` keys now warn instead of being dropped without a trace.

Fixes https://gitea.com/gitea/runner/issues/813

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1122
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-07-29 21:32:28 +00:00
committed by silverwind
parent e6c7ba3a15
commit 0192861155
10 changed files with 210 additions and 72 deletions

View File

@@ -472,10 +472,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
}
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
envList = append(envList, rc.runnerEnv(ctx)...)
binds, mounts := rc.GetBindsAndMounts()
networkMode := "container:" + rc.jobContainerName()

View File

@@ -30,7 +30,9 @@ import (
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model"
"github.com/docker/cli/cli/compose/loader"
"github.com/docker/go-connections/nat"
"github.com/moby/moby/api/types/mount"
"github.com/opencontainers/selinux/go-selinux"
)
@@ -204,52 +206,93 @@ func (rc *RunContext) validVolumes() []string {
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
}
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
func (rc *RunContext) toolCache(fallback string) string {
if path := rc.GetEnv()["RUNNER_TOOL_CACHE"]; path != "" {
return path
}
return fallback
}
// runnerEnv returns a container's RUNNER_* variables, derived from the values runner.tool_cache
// and friends report so the two cannot drift apart.
func (rc *RunContext) runnerEnv(ctx context.Context) []string {
ext := container.LinuxContainerEnvironmentExtensions{}
runnerContext := ext.GetRunnerContext(ctx)
runnerContext["tool_cache"] = rc.toolCache(container.DefaultToolCache)
env := make([]string, 0, len(runnerContext))
for key, value := range runnerContext {
env = append(env, fmt.Sprintf("RUNNER_%s=%s", strings.ToUpper(key), value))
}
slices.Sort(env)
return env
}
// splitVolumes routes volume specs into binds and a source:target mount map, and returns the
// container paths they mount onto. Only a plain source:target volume fits the map, everything
// else (anonymous volumes, host binds, mount options) stays a bind.
func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool) {
binds := []string{}
mounts := map[string]string{}
targets := map[string]bool{}
for _, spec := range specs {
parsed, err := loader.ParseVolume(spec)
if err != nil {
binds = append(binds, spec) // let Docker report the malformed spec
continue
}
targets[parsed.Target] = true
if parsed.Type == string(mount.TypeVolume) && parsed.Source != "" && !parsed.ReadOnly {
mounts[parsed.Source] = parsed.Target
} else {
binds = append(binds, spec)
}
}
return binds, mounts, targets
}
// Returns the binds and mounts for the container, resolving paths as appopriate
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
name := rc.jobContainerName()
binds := []string{}
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
}
ext := container.LinuxContainerEnvironmentExtensions{}
mounts := map[string]string{
"act-toolcache": "/opt/hostedtoolcache",
name + "-env": ext.GetActPath(),
}
var volumes []string
if job := rc.Run.Job(); job != nil {
if container := job.Container(); container != nil {
for _, v := range container.Volumes {
if rc.ExprEval != nil {
v = rc.ExprEval.Interpolate(context.Background(), v)
}
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
// Bind anonymous volume or host file.
binds = append(binds, v)
} else {
// Mount existing volume.
paths := strings.SplitN(v, ":", 2)
mounts[paths[0]] = paths[1]
}
volumes = append(volumes, v)
}
}
}
// the runner's own mounts below yield to the targets the job claims
binds, mounts, claimed := splitVolumes(volumes)
if rc.Config.BindWorkdir {
bindModifiers := ""
if runtime.GOOS == "darwin" {
bindModifiers = ":delegated"
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
}
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
mounts["act-toolcache"] = toolCache
}
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
if rc.Config.BindWorkdir {
bindModifiers := ""
if runtime.GOOS == "darwin" {
bindModifiers = ":delegated"
}
if selinux.GetEnabled() {
bindModifiers = ":z"
}
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, workdir, bindModifiers))
} else {
mounts[name] = workdir
}
if selinux.GetEnabled() {
bindModifiers = ":z"
}
binds = append(binds, fmt.Sprintf("%s:%s%s", rc.Config.Workdir, ext.ToContainerPath(rc.Config.Workdir), bindModifiers))
} else {
mounts[name] = ext.ToContainerPath(rc.Config.Workdir)
}
return binds, mounts
@@ -283,7 +326,10 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
if err := os.MkdirAll(runnerTmp, 0o777); err != nil {
return err
}
toolCache := filepath.Join(cacheDir, "tool_cache")
toolCache := rc.toolCache(filepath.Join(cacheDir, "tool_cache"))
if err := os.MkdirAll(toolCache, 0o777); err != nil {
return err
}
rc.JobContainer = &container.HostEnvironment{
Path: path,
TmpDir: runnerTmp,
@@ -368,10 +414,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
envList := make([]string, 0)
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
envList = append(envList, rc.runnerEnv(ctx)...)
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
ext := container.LinuxContainerEnvironmentExtensions{}
@@ -992,6 +1035,8 @@ func (rc *RunContext) getRunnerContext(ctx context.Context) map[string]any {
runnerContext := map[string]any{}
if rc.JobContainer != nil {
maps0.Copy(runnerContext, rc.JobContainer.GetRunnerContext(ctx))
defaultToolCache, _ := runnerContext["tool_cache"].(string)
runnerContext["tool_cache"] = rc.toolCache(defaultToolCache)
}
runnerContext["name"] = rc.Config.RunnerName
runnerContext["environment"] = "self-hosted"
@@ -1363,24 +1408,9 @@ func (rc *RunContext) handleServiceCredentials(ctx context.Context, creds map[st
// GetServiceBindsAndMounts returns the binds and mounts for the service container, resolving paths as appopriate
func (rc *RunContext) GetServiceBindsAndMounts(svcVolumes []string) ([]string, map[string]string) {
binds := []string{}
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" {
daemonPath := getDockerDaemonSocketMountPath(daemonSocket)
binds = append(binds, fmt.Sprintf("%s:%s", daemonPath, "/var/run/docker.sock"))
binds, mounts, claimed := splitVolumes(svcVolumes)
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
}
mounts := map[string]string{}
for _, v := range svcVolumes {
if !strings.Contains(v, ":") || filepath.IsAbs(v) {
// Bind anonymous volume or host file.
binds = append(binds, v)
} else {
// Mount existing volume.
paths := strings.SplitN(v, ":", 2)
mounts[paths[0]] = paths[1]
}
}
return binds, mounts
}

View File

@@ -19,6 +19,7 @@ import (
"gitea.com/gitea/runner/act/exprparser"
"gitea.com/gitea/runner/act/model"
"github.com/docker/cli/cli/compose/loader"
log "github.com/sirupsen/logrus"
assert "github.com/stretchr/testify/assert"
require "github.com/stretchr/testify/require"
@@ -363,6 +364,10 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
{"BindAnonymousVolume", []string{"/volume"}, "/volume", map[string]string{}},
{"BindHostFile", []string{"/path/to/file/on/host:/volume"}, "/path/to/file/on/host:/volume", map[string]string{}},
{"MountExistingVolume", []string{"volume-id:/volume"}, "", map[string]string{"volume-id": "/volume"}},
{"MountExistingVolumeReadOnly", []string{"volume-id:/volume:ro"}, "volume-id:/volume:ro", map[string]string{}},
{"BindRelativeHostPath", []string{"./relative:/volume"}, "./relative:/volume", map[string]string{}},
{"OverridesToolCache", []string{"/host/tools:/opt/hostedtoolcache"}, "/host/tools:/opt/hostedtoolcache", map[string]string{}},
{"OverridesDockerSocket", []string{"/host/docker.sock:/var/run/docker.sock"}, "/host/docker.sock:/var/run/docker.sock", map[string]string{}},
}
t.Run("InterpolatedContainerVolumes", func(t *testing.T) {
@@ -418,15 +423,37 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
rc.Run.JobID = "job1"
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
gotbind, gotmount := rc.GetBindsAndMounts()
jobBinds, jobMounts := rc.GetBindsAndMounts()
svcBinds, svcMounts := rc.GetServiceBindsAndMounts(testcase.volumes)
// job and service containers classify volumes alike, only their own mounts differ
for _, got := range []struct {
binds []string
mounts map[string]string
}{{jobBinds, jobMounts}, {svcBinds, svcMounts}} {
gotbind, gotmount := got.binds, got.mounts
if len(testcase.wantbind) > 0 {
assert.Contains(t, gotbind, testcase.wantbind)
}
if len(testcase.wantbind) > 0 {
assert.Contains(t, gotbind, testcase.wantbind)
}
for k, v := range testcase.wantmount {
assert.Contains(t, gotmount, k)
assert.Equal(t, gotmount[k], v)
for k, v := range testcase.wantmount {
assert.Contains(t, gotmount, k)
assert.Equal(t, gotmount[k], v)
}
// Docker rejects a container with two mounts on one target, so the job's own
// volumes must displace the runner's rather than pile up next to them.
targets := map[string]bool{}
for _, bind := range gotbind {
parsed, err := loader.ParseVolume(bind)
require.NoError(t, err)
assert.NotContains(t, targets, parsed.Target, "%s mounts an already mounted target", bind)
targets[parsed.Target] = true
}
for source, target := range gotmount {
assert.NotContains(t, targets, target, "%s mounts an already mounted target", source)
targets[target] = true
}
}
})
}

View File

@@ -110,10 +110,7 @@ func (sd *stepDocker) newStepContainer(ctx context.Context, image string, cmd, e
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
}
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache"))
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux"))
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx)))
envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp"))
envList = append(envList, rc.runnerEnv(ctx)...)
binds, mounts := rc.GetBindsAndMounts()
networkMode := "container:" + rc.jobContainerName()