From aa56f46c89d654c823094b7f47211c6bed5909c3 Mon Sep 17 00:00:00 2001 From: bircni Date: Thu, 5 Nov 2026 05:28:40 +0000 Subject: [PATCH] enhance: trust a private CA with runner.ca_file and add container.volumes (#1366) Adds `container.volumes`, mounted into every Docker job, service and action container. Sources are paths on the Docker daemon's host and need no `valid_volumes` entry, and workflow volumes may use them only with the same access. Adds `runner.ca_file`, a private CA trusted for the Gitea instance besides the system's by the runner's connections and its artifact proxy. Runner-side git and `builtin:checkout` use it as their CA bundle for the instance, persisted with the checkout's credentials. Jobs get it at `/var/run/act/ca.crt` with `NODE_EXTRA_CA_CERTS` pointing there unless the job or its image sets it. Git in jobs, as run by `actions/checkout`, needs the CA in the image's bundle, for example through `container.volumes`. Fixes: https://gitea.com/gitea/runner/issues/568 Fixes: https://gitea.com/gitea/runner/issues/671 Co-authored-by: silverwind Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/1366 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: bircni --- act/artifactcache/handler.go | 19 ++--- act/artifactcache/results.go | 2 +- act/artifactcache/results_test.go | 17 +++++ act/common/git/git.go | 17 +++-- act/common/git/git_test.go | 18 ++--- act/runner/job_hooks.go | 2 +- act/runner/reusable_workflow.go | 2 +- act/runner/reusable_workflow_test.go | 17 ++--- act/runner/run_context.go | 71 +++++++++++++++++-- act/runner/run_context_test.go | 62 ++++++++++++++-- act/runner/runner.go | 2 + act/runner/step.go | 2 +- act/runner/step_action_builtin.go | 1 + docs/builtin-actions.md | 2 +- docs/kubernetes.md | 2 +- internal/app/run/runner.go | 20 +++--- internal/pkg/action/action.go | 1 + internal/pkg/action/checkout/checkout.go | 10 +-- internal/pkg/action/checkout/checkout_test.go | 20 +++--- internal/pkg/config/config.example.yaml | 15 ++++ internal/pkg/config/config.go | 34 ++++++++- internal/pkg/config/config_test.go | 48 ++++++++++++- 22 files changed, 309 insertions(+), 75 deletions(-) diff --git a/act/artifactcache/handler.go b/act/artifactcache/handler.go index 2c6bdacc..4a8c5043 100644 --- a/act/artifactcache/handler.go +++ b/act/artifactcache/handler.go @@ -104,7 +104,8 @@ type Handler struct { stopSync context.CancelFunc syncing sync.WaitGroup - outboundIP string + outboundIP string + instanceTransport http.RoundTripper // internalSecret guards /_internal/{register,revoke}. When set, a remote // runner can use these endpoints to pre-register per-job @@ -143,19 +144,21 @@ type Options struct { // in-process RegisterJob method directly. InternalSecret string - Policy Policy - Logger *logging.Logger + Policy Policy + Logger *logging.Logger + InstanceTransport http.RoundTripper // reaches the instance artifact requests are forwarded to, nil for http.DefaultTransport } // StartHandler starts an embedded cache or forwards cache requests to Upstream. func StartHandler(opts Options) (*Handler, error) { dir, logger := opts.Dir, opts.Logger h := &Handler{ - creds: make(map[string]*credEntry), - internalSecret: opts.InternalSecret, - policy: opts.Policy.withDefaults(), - freeDisk: disk.FreeBytes, - dbLock: make(chan struct{}, 1), + creds: make(map[string]*credEntry), + internalSecret: opts.InternalSecret, + policy: opts.Policy.withDefaults(), + freeDisk: disk.FreeBytes, + dbLock: make(chan struct{}, 1), + instanceTransport: opts.InstanceTransport, } if logger == nil { diff --git a/act/artifactcache/results.go b/act/artifactcache/results.go index 5cfe22f3..4068ca9e 100644 --- a/act/artifactcache/results.go +++ b/act/artifactcache/results.go @@ -67,7 +67,7 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) { h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err)) }, } - if cred.InsecureTLS { + if proxy.Transport = h.instanceTransport; proxy.Transport == nil && cred.InsecureTLS { // the instance transport carries runner.insecure and the client cert proxy.Transport = insecureTransport } proxy.ServeHTTP(w, r) diff --git a/act/artifactcache/results_test.go b/act/artifactcache/results_test.go index 2863c8cb..ef899dc9 100644 --- a/act/artifactcache/results_test.go +++ b/act/artifactcache/results_test.go @@ -133,3 +133,20 @@ func TestFrontResultsService(t *testing.T) { }) } } + +func TestFrontResultsServiceForwardsThroughTheInstanceTransport(t *testing.T) { + t.Parallel() + gitea := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _request *http.Request) { _, _ = io.WriteString(writer, "{}") })) + defer gitea.Close() + handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InstanceTransport: gitea.Client().Transport}) + require.NoError(t, err) + defer handler.Close() + defer handler.RegisterJob("token", JobCredential{Results: gitea.URL})() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+artifactServicePath+"CreateArtifact", nil) + require.NoError(t, err) + resp, err := (&http.Client{Transport: &bearerTransport{token: "token"}}).Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/act/common/git/git.go b/act/common/git/git.go index 0db6724f..7eee1099 100644 --- a/act/common/git/git.go +++ b/act/common/git/git.go @@ -185,6 +185,7 @@ type NewGitCloneExecutorInput struct { // For Gitea InsecureSkipTLS bool ClientCertFile, ClientKeyFile string // PEM, absolute as git runs in other directories + CAFile string // PEM, absolute, replaces git's CA bundle Transport http.RoundTripper // for FetchAction's archive downloads, nil for http.DefaultTransport } @@ -389,12 +390,16 @@ func RemoteConfig(token string, insecureSkipTLS bool) []string { return config } -func ClientCertConfig(prefix, certFile, keyFile, executionOS string) []string { - if certFile == "" { - return nil +// SSLConfig uses OpenSSL on Windows, as Schannel takes no PEM key and ignores sslCAInfo. +func SSLConfig(prefix, certFile, keyFile, caFile, executionOS string) []string { + var config []string + if certFile != "" { + config = append(config, prefix+"sslCert="+certFile, prefix+"sslKey="+keyFile) } - config := []string{prefix + "sslCert=" + certFile, prefix + "sslKey=" + keyFile} - if strings.EqualFold(executionOS, "windows") { + if caFile != "" { + config = append(config, prefix+"sslCAInfo="+caFile) + } + if config != nil && strings.EqualFold(executionOS, "windows") { config = append(config, prefix+"sslBackend=openssl") } return config @@ -457,7 +462,7 @@ func runGitWithStdin(ctx context.Context, dir string, input *NewGitCloneExecutor config := []string{"core.autocrlf=false", "core.eol=lf"} // keep action files LF if input != nil { config = append(config, RemoteConfig(input.Token, input.InsecureSkipTLS)...) - config = append(config, ClientCertConfig("http.", input.ClientCertFile, input.ClientKeyFile, runtime.GOOS)...) + config = append(config, SSLConfig("http.", input.ClientCertFile, input.ClientKeyFile, input.CAFile, runtime.GOOS)...) } if dir != "" { args = append([]string{"-C", dir}, args...) diff --git a/act/common/git/git_test.go b/act/common/git/git_test.go index f077ca1f..eadeaaf6 100644 --- a/act/common/git/git_test.go +++ b/act/common/git/git_test.go @@ -54,18 +54,18 @@ func TestMain(m *testing.M) { os.Exit(code) } -func TestClientCertConfig(t *testing.T) { +func TestSSLConfig(t *testing.T) { for _, testcase := range []struct { - prefix, certFile, executionOS string - expected []string + prefix, certFile, caFile, executionOS string + expected []string }{ - {"http.", "cert.pem", "windows", []string{"http.sslCert=cert.pem", "http.sslKey=key.pem", "http.sslBackend=openssl"}}, - {"http.https://example.com/.", "cert.pem", "Windows", []string{"http.https://example.com/.sslCert=cert.pem", "http.https://example.com/.sslKey=key.pem", "http.https://example.com/.sslBackend=openssl"}}, - {"http.", "cert.pem", "Linux", []string{"http.sslCert=cert.pem", "http.sslKey=key.pem"}}, - {"http.", "", "Windows", nil}, + {"http.https://example.com/.", "cert.pem", "ca.pem", "Windows", []string{"http.https://example.com/.sslCert=cert.pem", "http.https://example.com/.sslKey=key.pem", "http.https://example.com/.sslCAInfo=ca.pem", "http.https://example.com/.sslBackend=openssl"}}, + {"http.", "", "ca.pem", "windows", []string{"http.sslCAInfo=ca.pem", "http.sslBackend=openssl"}}, + {"http.", "cert.pem", "ca.pem", "Linux", []string{"http.sslCert=cert.pem", "http.sslKey=key.pem", "http.sslCAInfo=ca.pem"}}, + {"http.", "", "", "Windows", nil}, } { - t.Run(testcase.prefix+testcase.certFile+testcase.executionOS, func(t *testing.T) { - assert.Equal(t, testcase.expected, ClientCertConfig(testcase.prefix, testcase.certFile, "key.pem", testcase.executionOS)) + t.Run(testcase.prefix+testcase.certFile+testcase.caFile+testcase.executionOS, func(t *testing.T) { + assert.Equal(t, testcase.expected, SSLConfig(testcase.prefix, testcase.certFile, "key.pem", testcase.caFile, testcase.executionOS)) }) } } diff --git a/act/runner/job_hooks.go b/act/runner/job_hooks.go index 062ff975..dc7df6d0 100644 --- a/act/runner/job_hooks.go +++ b/act/runner/job_hooks.go @@ -53,7 +53,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err } rawLogger.Infof("::endgroup::") - env := mergeMaps(rc.containerSpec.Env, rc.GetEnv()) + env := rc.mergeEnv(rc.caEnv, rc.containerSpec.Env, rc.GetEnv()) rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env) rc.ApplyExtraPath(ctx, &env) diff --git a/act/runner/reusable_workflow.go b/act/runner/reusable_workflow.go index 45d16d23..64d7453b 100644 --- a/act/runner/reusable_workflow.go +++ b/act/runner/reusable_workflow.go @@ -197,7 +197,7 @@ func (c Config) withInstanceAuth(input git.NewGitCloneExecutorInput, token strin } input.ClientCertFile, input.ClientKeyFile = c.ClientCertFile, c.ClientKeyFile if onInstanceHost(u.Host, c.GitHubInstance) { - input.InsecureSkipTLS, input.Transport = c.InsecureSkipTLS, c.InstanceTransport + input.InsecureSkipTLS, input.CAFile, input.Transport = c.InsecureSkipTLS, c.CAFile, c.InstanceTransport } if u.User == nil { input.Token = token diff --git a/act/runner/reusable_workflow_test.go b/act/runner/reusable_workflow_test.go index e107f5f0..6674746d 100644 --- a/act/runner/reusable_workflow_test.go +++ b/act/runner/reusable_workflow_test.go @@ -130,7 +130,7 @@ func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T) } } -func TestCloneRemoteReusableWorkflowSendsInstanceTokenAndClientCert(t *testing.T) { +func TestCloneRemoteReusableWorkflowSendsInstanceTokenAndClientCertTrustingCA(t *testing.T) { var gotCert bool var gotToken string server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -150,12 +150,12 @@ func TestCloneRemoteReusableWorkflowSendsInstanceTokenAndClientCert(t *testing.T require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600)) rc := &RunContext{ Config: &Config{ - Env: testCheckoutEnv(), - GitHubInstance: server.URL, - Secrets: map[string]string{"GITEA_TOKEN": "token"}, - InsecureSkipTLS: true, - ClientCertFile: certFile, - ClientKeyFile: keyFile, + Env: testCheckoutEnv(), + GitHubInstance: server.URL, + Secrets: map[string]string{"GITEA_TOKEN": "token"}, + ClientCertFile: certFile, + ClientKeyFile: keyFile, + CAFile: certFile, }, Run: &model.Run{JobID: "j1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"j1": {}}}}, } @@ -188,11 +188,12 @@ func TestInstanceAuthAndTransportOnlyApplyToTheInstance(t *testing.T) { InsecureSkipTLS: true, ClientCertFile: "/cert", ClientKeyFile: "/key", + CAFile: "/ca", InstanceTransport: &http.Transport{}, } want := git.NewGitCloneExecutorInput{URL: tt.cloneURL, InsecureSkipTLS: tt.insecure} if tt.insecure { - want.Transport = conf.InstanceTransport + want.CAFile, want.Transport = conf.CAFile, conf.InstanceTransport } if tt.token { want.Token = "token" diff --git a/act/runner/run_context.go b/act/runner/run_context.go index a58641d3..cb4aa923 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -68,6 +68,7 @@ type RunContext struct { kubernetes bool containerSpec model.ContainerSpec // container:, resolved once with platformImage containerEnv yaml.Node // container.env of the kept containerSpec, decoded once the job env resolves + caEnv map[string]string // NODE_EXTRA_CA_CERTS for the runner CA, beneath any env the job and its image set JobName string ActionPath string Parent *RunContext @@ -278,11 +279,37 @@ func (rc *RunContext) validVolumes() []string { if rc.dockerProxy != nil { volumes = append(volumes, rc.dockerProxy.Socket) } + volumes = append(volumes, rc.Config.containerVolumeSources()...) // TODO: add a new configuration to control whether the docker daemon can be mounted return append(volumes, name, name+"-env", getDockerDaemonSocketMountPath(rc.containerDaemonSocket())) } +// containerVolumeSources lets workflows mount the container.volumes sources with no more access than they give. +func (c *Config) containerVolumeSources() []string { + var sources []string + for _, spec := range c.ContainerVolumes { + parsed, _ := container.ParseVolume(spec) // validated on config load + if parsed.ReadOnly { + parsed.Source += ":ro" + } + sources = append(sources, parsed.Source) + } + return sources +} + +// mountVolumes sanitizes volumes and adds the container.volumes on the targets they leave unclaimed. +func (rc *RunContext) mountVolumes(ctx context.Context, validVolumes, volumes []string) ([]string, map[string]string, map[string]bool) { + binds, mounts, claimed := splitVolumes(container.SanitizeBinds(ctx, validVolumes, volumes)) + for _, spec := range rc.Config.ContainerVolumes { + if parsed, _ := container.ParseVolume(spec); !claimed[parsed.Target] { // validated on config load + claimed[parsed.Target] = true + binds = append(binds, spec) + } + } + return binds, mounts, claimed +} + func (rc *RunContext) workdirMountRoot() string { if rc.Config.PresetGitHubContext != nil { return filepath.Dir(filepath.Dir(rc.Config.Workdir)) // only the daemon presets, its workdir is // @@ -357,7 +384,7 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri ext := container.LinuxContainerEnvironmentExtensions{} // the runner's own mounts below yield to the targets the job claims - binds, mounts, claimed := splitVolumes(container.SanitizeBinds(ctx, rc.validVolumes(), rc.containerSpec.Volumes)) + binds, mounts, claimed := rc.mountVolumes(ctx, rc.validVolumes(), rc.containerSpec.Volumes) if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] { binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock") @@ -705,7 +732,12 @@ func (rc *RunContext) jobServices(ctx context.Context, networkName string) (map[ entrypoint = []string{spec.Entrypoint} } - serviceBinds, serviceMounts, _ := splitVolumes(spec.Volumes) + serviceBinds, serviceMounts, _ := splitVolumes(spec.Volumes) // as given on Kubernetes, which rejects them + validVolumes := rc.Config.ValidVolumes // not validVolumes(), a service gets no docker socket + if !rc.kubernetes { + validVolumes = append(slices.Clone(validVolumes), rc.Config.containerVolumeSources()...) + serviceBinds, serviceMounts, _ = rc.mountVolumes(ctx, validVolumes, spec.Volumes) + } exposedPorts, portBindings, err := nat.ParsePortSpecs(spec.Ports) if err != nil { @@ -734,7 +766,7 @@ func (rc *RunContext) jobServices(ctx context.Context, networkName string) (map[ NetworkAliases: []string{serviceID}, ExposedPorts: exposedPorts, PortBindings: portBindings, - ValidVolumes: rc.Config.ValidVolumes, // not validVolumes(), a service gets no docker socket + ValidVolumes: validVolumes, AllocatePTY: rc.Config.AllocatePTY, } } @@ -1119,10 +1151,35 @@ func (svc *serviceContainer) healthOutputSuffix() string { } func (rc *RunContext) copyWorkflowFiles() common.Executor { - return rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/", - &container.FileEntry{Name: "workflow/event.json", Mode: 0o644, Body: rc.EventJSON}, - &container.FileEntry{Name: "workflow/envs.txt", Mode: 0o666}, - ) + return func(ctx context.Context) error { + files := []*container.FileEntry{ + {Name: "workflow/event.json", Mode: 0o644, Body: rc.EventJSON}, + {Name: "workflow/envs.txt", Mode: 0o666}, + } + if rc.Config.CAFile != "" { + ca, err := os.ReadFile(rc.Config.CAFile) + if err != nil { + return fmt.Errorf("read runner CA file: %w", err) + } + files = append(files, &container.FileEntry{Name: "ca.crt", Mode: 0o644, Body: string(ca)}) + imageEnv := map[string]string{} + if err = rc.JobContainer.UpdateFromImageEnv(&imageEnv)(ctx); err != nil { + return err + } + if _, set := imageEnv["NODE_EXTRA_CA_CERTS"]; !set { + rc.caEnv = map[string]string{"NODE_EXTRA_CA_CERTS": rc.jobCAFile()} + } + } + return rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/", files...)(ctx) + } +} + +// jobCAFile is where copyWorkflowFiles puts the runner CA in the job, empty without one. +func (rc *RunContext) jobCAFile() string { + if rc.Config.CAFile == "" { + return "" + } + return rc.JobContainer.GetActPath() + "/ca.crt" } // captureJobContainerInfo is a convenience: failing to describe the container must not diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index 957ae2ed..c8b9c320 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -277,25 +277,38 @@ jobs: require.Equal(t, [2]string{"", ""}, credentials["redis:latest"]) } -func TestStartJobContainerGivesServicesTheirVolumes(t *testing.T) { - redis := startJobContainerInputs(t, ` +func TestStartJobContainerMountsWorkflowVolumesAndContainerVolumesOnUnclaimedTargets(t *testing.T) { + const ca, certs = "/host/ca.crt:/etc/ssl/certs/ca-certificates.crt:ro", "/host/certs:/certs" + inputs := startJobContainerInputs(t, ` jobs: job: + container: + image: node:24 + volumes: + - /job/certs:/certs services: redis: image: redis:latest volumes: - data:/data - data:/backup -`, &Config{ValidVolumes: []string{"data"}})[0] + - /blocked/ca.crt:/etc/ssl/certs/ca-certificates.crt:ro + - blocked:/certs +`, &Config{ValidVolumes: []string{"data", "/job/certs"}, ContainerVolumes: []string{ca, certs}}) + require.Len(t, inputs, 2) + redis, job := inputs[0], inputs[1] require.Equal(t, "redis:latest", redis.Image) - require.Equal(t, []string{"data"}, redis.ValidVolumes) + require.Equal(t, []string{"data", "/job/certs", "/host/ca.crt:ro", "/host/certs"}, redis.ValidVolumes) require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts) - require.Equal(t, []string{"data:/backup"}, redis.Binds) + require.Equal(t, []string{"data:/backup", ca, certs}, redis.Binds) require.Empty(t, redis.WorkingDir) require.Nil(t, redis.Entrypoint) require.Nil(t, redis.Cmd) + assert.Subset(t, job.Binds, []string{"/job/certs:/certs", ca}) + assert.NotContains(t, job.Binds, certs) + assert.Subset(t, job.ValidVolumes, []string{"/job/certs", "/host/ca.crt:ro", "/host/certs"}) + assert.NotContains(t, job.ValidVolumes, "/host/ca.crt", "a workflow must not mount a read-only source writable") } func TestStartJobContainerBuildsEvaluatedJobAndServiceInputs(t *testing.T) { @@ -304,7 +317,7 @@ jobs: job: container: ${{ fromJSON('{"image":"docker://node:20","options":"--label ${{ github.job }}","credentials":{"username":"user","password":"${{ vars.TITLE }}"}}') }} services: ${{ fromJSON('{"redis":{"image":"docker://redis:latest","volumes":["data:/data"],"env":{"TITLE":"${{ github.job }}"},"credentials":{"username":"user","password":"${{ github.job }}"},"entrypoint":"/entry.sh","command":"redis-server --port 6380"}}') }} -`, &Config{}) +`, &Config{ValidVolumes: []string{"data"}}) redis, job := inputs[0], inputs[1] require.Equal(t, "redis:latest", redis.Image) @@ -1450,6 +1463,43 @@ func TestGetJobContextReportsContainers(t *testing.T) { }, jobContext.Services) } +type imageEnvContainer struct { + *container.HostEnvironment + imageEnv map[string]string +} + +func (c imageEnvContainer) UpdateFromImageEnv(env *map[string]string) common.Executor { + return func(_ctx context.Context) error { maps.Copy(*env, c.imageEnv); return nil } +} + +func TestCopyWorkflowFilesHandsNodeTheRunnerCAUnlessTheJobOrImageSetsIt(t *testing.T) { + caFile, actPath := filepath.Join(t.TempDir(), "ca.crt"), t.TempDir() + require.NoError(t, os.WriteFile(caFile, []byte("PEM\n"), 0o600)) + own := map[string]string{"NODE_EXTRA_CA_CERTS": "/own.pem"} + stepEnv := func(caFile string, jobEnv, containerEnv, imageEnv map[string]string) map[string]string { + rc := &RunContext{ + Config: &Config{CAFile: caFile}, + Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}}, + Env: maps.Clone(jobEnv), + JobContainer: imageEnvContainer{&container.HostEnvironment{Path: t.TempDir(), ActPath: actPath, StdOut: io.Discard}, imageEnv}, + } + rc.containerSpec.Env = containerEnv + require.NoError(t, rc.copyWorkflowFiles()(t.Context())) + env := map[string]string{} + require.NoError(t, setupEnv(t.Context(), &stepRun{RunContext: rc, Step: &model.Step{}, env: env})) + return env + } + + assert.Equal(t, actPath+"/ca.crt", stepEnv(caFile, nil, nil, nil)["NODE_EXTRA_CA_CERTS"]) + ca, err := os.ReadFile(filepath.Join(actPath, "ca.crt")) + require.NoError(t, err) + assert.Equal(t, "PEM\n", string(ca)) + assert.Equal(t, "/own.pem", stepEnv(caFile, own, nil, nil)["NODE_EXTRA_CA_CERTS"]) + assert.Equal(t, "/own.pem", stepEnv(caFile, nil, own, nil)["NODE_EXTRA_CA_CERTS"]) + assert.NotContains(t, stepEnv(caFile, nil, nil, own), "NODE_EXTRA_CA_CERTS") + assert.NotContains(t, stepEnv("", nil, nil, nil), "NODE_EXTRA_CA_CERTS") +} + func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) { workdir := t.TempDir() job := &containerMock{} diff --git a/act/runner/runner.go b/act/runner/runner.go index 1542ce13..824bd0a5 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -83,9 +83,11 @@ type Config struct { InstanceTransport http.RoundTripper JobLoggerLevel *logging.Level // the level of job logger ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers + ContainerVolumes []string // mounted into every job, service and docker action container SharedToolCache bool // one tool cache for all jobs instead of one per job InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance ClientCertFile, ClientKeyFile string // PEM client certificate for the Gitea instance + CAFile string // PEM CA certificates the Gitea instance's certificate chains to MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count) AllocatePTY bool // allocate a pseudo-TTY for each step's process ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default) diff --git a/act/runner/step.go b/act/runner/step.go index a86794c2..86cb52b4 100644 --- a/act/runner/step.go +++ b/act/runner/step.go @@ -263,7 +263,7 @@ func setupEnv(ctx context.Context, step step) error { env := step.getEnv() // container env is the image's baseline, which job env and $GITHUB_ENV override - mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv()) + mergeIntoMap(step, env, rc.caEnv, rc.containerSpec.Env, rc.GetEnv()) rc.withGithubEnv(ctx, getStepGithubContext(ctx, step), *env) var err error diff --git a/act/runner/step_action_builtin.go b/act/runner/step_action_builtin.go index 7c6d4e51..d129147a 100644 --- a/act/runner/step_action_builtin.go +++ b/act/runner/step_action_builtin.go @@ -49,6 +49,7 @@ func (sab *stepActionBuiltin) main() common.Executor { InsecureSkipTLS: rc.Config.InsecureSkipTLS, ClientCertFile: rc.Config.ClientCertFile, ClientKeyFile: rc.Config.ClientKeyFile, + CAFile: rc.jobCAFile(), Env: sab.env, }) }) diff --git a/docs/builtin-actions.md b/docs/builtin-actions.md index 1b74ed4f..b33a977f 100644 --- a/docs/builtin-actions.md +++ b/docs/builtin-actions.md @@ -34,6 +34,6 @@ Works like `actions/checkout` with these inputs. Any other input fails the step. A branch is checked out as a local branch tracking `origin`, a tag or commit as a detached `HEAD`. A triggering tag that moved since the event fails the step. The step outputs `ref` and `commit` like `actions/checkout`. -Persisted credentials end with the job. `token` is only sent to `github.server_url`, while `ssh-key` is offered to any SSH host, as with `actions/checkout`. The runner's `client_cert_file` is only offered to `github.server_url` and only during the step, so later steps do not get it. Without `ssh-key`, submodule URLs of the form `git@:` are fetched from `github.server_url` with `token`. With `ssh-key`, the repository is fetched from the SSH URL in the event's `repository.ssh_url`, or from the server's host on port 22. +Persisted credentials end with the job. `token` is only sent to `github.server_url`, while `ssh-key` is offered to any SSH host, as with `actions/checkout`. The runner's `client_cert_file` is only offered to `github.server_url` and only during the step, so later steps do not get it. Its `ca_file` replaces the job image's CA bundle for `github.server_url`, in later steps too when credentials persist. Without `ssh-key`, submodule URLs of the form `git@:` are fetched from `github.server_url` with `token`. With `ssh-key`, the repository is fetched from the SSH URL in the event's `repository.ssh_url`, or from the server's host on port 22. With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` or `ssh-key` is set. diff --git a/docs/kubernetes.md b/docs/kubernetes.md index 26329750..bf587730 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -109,4 +109,4 @@ Services share the pod's network, so a step reaches one by its id or `localhost` ## Unsupported -Docker actions, `docker://` steps, and the `options`, `volumes` and `credentials` of the job container fail the job, as do `volumes` and `credentials` of services. Run such workflows on a [Docker in Docker](../examples/kubernetes/README.md) runner. Pull private images with `imagePullSecrets` in `pod_template`. +Docker actions, `docker://` steps, and the `options`, `volumes` and `credentials` of the job container fail the job, as do `volumes` and `credentials` of services. Run such workflows on a [Docker in Docker](../examples/kubernetes/README.md) runner. Pull private images with `imagePullSecrets` in `pod_template`, and mount what `container.volumes` would through its `volumes`. diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index 4f78f542..0f267aca 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -98,18 +98,20 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) } envs := make(map[string]string, len(cfg.Runner.Envs)) maps.Copy(envs, cfg.Runner.Envs) + instanceTransport := client.NewTransport(cli.Address(), cfg.Runner.TLSConfig()) var cacheHandler *artifactcache.Handler if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled { warnIgnoredCachePolicy(cfg) warnIgnoredCacheSecret(cfg) handler, err := artifactcache.StartHandler(artifactcache.Options{ - Dir: cfg.Cache.Dir, - OutboundIP: cfg.Cache.Host, - Port: cfg.Cache.Port, - Upstream: cfg.Cache.ExternalServer, - S3: cacheS3(cfg), - Policy: CachePolicy(cfg), - Logger: logging.Default().With("module", "cache_request"), + Dir: cfg.Cache.Dir, + OutboundIP: cfg.Cache.Host, + Port: cfg.Cache.Port, + Upstream: cfg.Cache.ExternalServer, + S3: cacheS3(cfg), + Policy: CachePolicy(cfg), + Logger: logging.Default().With("module", "cache_request"), + InstanceTransport: instanceTransport, }) if err != nil { logging.Errorf("cannot init cache server, it will be disabled: %v", err) @@ -140,7 +142,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) runHealthCheck: executeHealthCheck, } runner.isolatedCacheContainer = sync.OnceValue(runner.detectIsolatedCacheContainer) - runner.instanceTransport = client.NewTransport(cli.Address(), cfg.Runner.TLSConfig()) + runner.instanceTransport = instanceTransport return runner } @@ -620,10 +622,12 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. JobCompletedHook: r.cfg.Runner.Hooks.JobCompleted, Vars: task.Vars, ValidVolumes: r.cfg.Container.ValidVolumes, + ContainerVolumes: r.cfg.Container.Volumes, SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared, InsecureSkipTLS: r.cfg.Runner.Insecure, ClientCertFile: r.cfg.Runner.ClientCertFile, ClientKeyFile: r.cfg.Runner.ClientKeyFile, + CAFile: r.cfg.Runner.CAFile, InstanceTransport: r.instanceTransport, RunnerName: r.name, KubernetesPicker: r.kubernetesOptions, diff --git a/internal/pkg/action/action.go b/internal/pkg/action/action.go index 67d82f87..5aca21d4 100644 --- a/internal/pkg/action/action.go +++ b/internal/pkg/action/action.go @@ -22,5 +22,6 @@ type Context struct { InsecureSkipTLS bool ClientCertFile string ClientKeyFile string + CAFile string // the runner CA as seen inside Container Env map[string]string } diff --git a/internal/pkg/action/checkout/checkout.go b/internal/pkg/action/checkout/checkout.go index 624ef64c..a4c74306 100644 --- a/internal/pkg/action/checkout/checkout.go +++ b/internal/pkg/action/checkout/checkout.go @@ -124,7 +124,7 @@ func Main(ctx context.Context, c *action.Context) (err error) { credentials := fmt.Sprintf("git-credentials-%x.config", digest[:8]) co.credentials = container.JoinPath(runnerTemp, credentials) persist := boolInput(c.Inputs, "persist-credentials", true) - config, remoteConfig := serverConfig(ghc.ServerURL, cmp.Or(c.Inputs["token"], ghc.Token), c.InsecureSkipTLS, co.submodules != "" && sshKey == "") + config, remoteConfig := serverConfig(ghc.ServerURL, append(git.RemoteConfig(cmp.Or(c.Inputs["token"], ghc.Token), c.InsecureSkipTLS), git.SSLConfig("http.", "", "", c.CAFile, executionOS)...), co.submodules != "" && sshKey == "") if boolInput(c.Inputs, "set-safe-directory", true) { remoteConfig = append(remoteConfig, "safe.directory=*") // a bound workdir belongs to the host user } @@ -145,7 +145,7 @@ func Main(ctx context.Context, c *action.Context) (err error) { if err := c.Container.Copy(runnerTemp, &container.FileEntry{Name: cert, Mode: 0o600, Body: string(certPEM)}, &container.FileEntry{Name: key, Mode: 0o600, Body: string(keyPEM)})(ctx); err != nil { return err } - remoteConfig = append(remoteConfig, git.ClientCertConfig("http."+strings.TrimSuffix(ghc.ServerURL, "/")+"/.", container.JoinPath(runnerTemp, cert), container.JoinPath(runnerTemp, key), executionOS)...) + remoteConfig = append(remoteConfig, git.SSLConfig("http."+strings.TrimSuffix(ghc.ServerURL, "/")+"/.", container.JoinPath(runnerTemp, cert), container.JoinPath(runnerTemp, key), "", executionOS)...) } env := gitEnv(c, remoteConfig) env["GIT_LFS_SKIP_SMUDGE"] = strconv.FormatBool(!co.lfs || len(co.sparse) == 0) // lfs pull fetches the objects of a full checkout in one go @@ -265,13 +265,13 @@ func lsRemote(ctx context.Context, c *action.Context, env map[string]string, url } // serverConfig scopes the token to the server, so no other host like a repository's LFS endpoint gets it. -func serverConfig(serverURL, token string, insecureSkipTLS, rewriteSSH bool) (file string, env []string) { +func serverConfig(serverURL string, settings []string, rewriteSSH bool) (file string, env []string) { scope := strings.TrimSuffix(serverURL, "/") + "/" file = fmt.Sprintf("[http %q]\n", scope) - for _, entry := range git.RemoteConfig(token, insecureSkipTLS) { + for _, entry := range settings { setting := strings.TrimPrefix(entry, "http.") key, value, _ := strings.Cut(setting, "=") - file += "\t" + key + " = " + value + "\n" + file += "\t" + key + " = " + strconv.Quote(value) + "\n" env = append(env, "http."+scope+"."+setting) } if server, err := url.Parse(serverURL); rewriteSSH && err == nil && server.Hostname() != "" { // submodules on the server fetch over HTTP with the token diff --git a/internal/pkg/action/checkout/checkout_test.go b/internal/pkg/action/checkout/checkout_test.go index 84587029..1da861e1 100644 --- a/internal/pkg/action/checkout/checkout_test.go +++ b/internal/pkg/action/checkout/checkout_test.go @@ -215,6 +215,7 @@ exec "$LFS" "$@" Workspace: workspace, BindWorkdir: tc.bind, NoSkipCheckout: tc.noSkip, + CAFile: "ca.crt", Env: map[string]string{ "PATH": tools + string(os.PathListSeparator) + os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": globalConfig, "GIT_CONFIG_NOSYSTEM": "1", "GIT_DIR": host, "GITHUB_OUTPUT": output, "SSH_SERVER": filepath.ToSlash(server), "SSH_ARGS": filepath.Join(runnerTemp, "ssh-args"), @@ -283,6 +284,7 @@ exec "$LFS" "$@" assert.Len(t, strings.Fields(gitOutput(repo, "config", "--local", "--get-all", credentialsInclude)), 1, repo) headers, _ := exec.Command("git", "-C", repo, "config", "--get-all", "http."+serverURL+"/.extraheader").Output() assert.Equal(t, persisted, string(headers) == "\nAuthorization: Basic dG9rZW46dG9r\n", repo) + assert.Equal(t, persisted, gitOutput(repo, "config", "http."+serverURL+"/.sslcainfo") != "", repo) } if tc.inputs["lfs"] == "true" && runtime.GOOS != "windows" { headers, _ := os.ReadFile(filepath.Join(runnerTemp, "lfs-headers")) @@ -306,7 +308,7 @@ exec "$LFS" "$@" } } -func TestCheckoutOffersClientCertOnlyDuringTheStep(t *testing.T) { +func TestCheckoutTrustsTheRunnerCAAndOffersClientCertOnlyDuringTheStep(t *testing.T) { var gotCert bool server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotCert = len(r.TLS.PeerCertificates) > 0 @@ -324,14 +326,14 @@ func TestCheckoutOffersClientCertOnlyDuringTheStep(t *testing.T) { require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600)) require.ErrorContains(t, Main(t.Context(), &action.Context{ - Container: &container.HostEnvironment{Path: workspace, TmpDir: runnerTemp, StdOut: io.Discard}, - Github: &model.GithubContext{Repository: "org/repo", Ref: "refs/heads/main", ServerURL: server.URL}, - Inputs: map[string]string{"repository": "other/repo"}, - Workspace: workspace, - InsecureSkipTLS: true, - ClientCertFile: certFile, - ClientKeyFile: keyFile, - Env: map[string]string{"PATH": os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": os.DevNull, "GIT_CONFIG_NOSYSTEM": "1"}, + Container: &container.HostEnvironment{Path: workspace, TmpDir: runnerTemp, StdOut: io.Discard}, + Github: &model.GithubContext{Repository: "org/repo", Ref: "refs/heads/main", ServerURL: server.URL}, + Inputs: map[string]string{"repository": "other/repo"}, + Workspace: workspace, + ClientCertFile: certFile, + ClientKeyFile: keyFile, + CAFile: certFile, + Env: map[string]string{"PATH": os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": os.DevNull, "GIT_CONFIG_NOSYSTEM": "1"}, }), "checkout: other/repo@") require.True(t, gotCert) keys, _ := filepath.Glob(filepath.Join(runnerTemp, "git-client-key-*")) diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index 52703a71..5fc3f149 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -44,6 +44,12 @@ runner: # PEM client certificate and key for a Gitea instance that requires mutual TLS. #client_cert_file: /etc/gitea-runner/client.crt #client_key_file: /etc/gitea-runner/client.key + # PEM CA certificates of a Gitea instance with a private CA, trusted besides the system's. + # Runner-side git and builtin:checkout use it instead of the system's bundle for the instance. + # Jobs get it at /var/run/act/ca.crt, or in their act directory on the host, with NODE_EXTRA_CA_CERTS + # pointing there unless the job, or on Docker its image, sets it. Other tools in jobs, such as the + # git that actions/checkout runs, need the CA in the image's bundle, for example via container.volumes. + #ca_file: /etc/gitea-runner/ca.crt # The timeout for fetching the job from the Gitea instance. # Values above the 60s RPC timeout are capped to it. #fetch_timeout: 5s @@ -264,6 +270,15 @@ container: # valid_volumes: # - '**' #valid_volumes: [] + # Volumes and bind mounts the runner adds to every job, service and docker action container, as in + # `docker run --volume`, with sources on the docker daemon's host. One the workflow mounts on the same + # container path takes its place. Their sources need no valid_volumes entry, and workflow volumes may + # use them only with the same access, though a job given the docker socket can mount anything. + # Docker only, use pod_template on Kubernetes. For example, to trust a private CA in Debian and + # Ubuntu based images: + # volumes: + # - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro + #volumes: [] # Overrides the docker client host with the specified one. # If it's empty, runner will find an available docker host automatically. # If it's "-", runner will find an available docker host automatically, but won't mount it into the job and action containers. diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 5686d883..4191c794 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -5,6 +5,7 @@ package config import ( "crypto/tls" + "crypto/x509" "errors" "fmt" "maps" @@ -16,7 +17,9 @@ import ( "time" "gitea.com/gitea/runner/act/common/logging" + "gitea.com/gitea/runner/act/container" + "github.com/docker/go-connections/tlsconfig" "github.com/docker/go-units" "github.com/joho/godotenv" "go.yaml.in/yaml/v4" @@ -67,6 +70,7 @@ type Runner struct { Insecure bool `yaml:"insecure"` // Insecure indicates whether the runner operates in an insecure mode. ClientCertFile string `yaml:"client_cert_file"` // ClientCertFile is the PEM certificate presented to a Gitea instance that requires mutual TLS. ClientKeyFile string `yaml:"client_key_file"` // ClientKeyFile is the PEM private key paired with ClientCertFile. + CAFile string `yaml:"ca_file"` // CAFile holds PEM CA certificates trusted for the Gitea instance besides the system's. ExtraHeaders map[string]string `yaml:"extra_headers"` // ExtraHeaders adds HTTP headers to runner API requests to Gitea. FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources. FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources. @@ -90,6 +94,8 @@ type Runner struct { 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. Hooks RunnerHooks `yaml:"hooks"` // Hooks are scripts run inside the job environment around the job's steps. + + rootCAs *x509.CertPool // the system's and CAFile's, nil for the system's alone } // RunnerHooks represents the scripts run inside the job environment around the job's steps. @@ -176,6 +182,7 @@ type Container struct { Options string `yaml:"options"` // Options specifies additional options for the container. WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the container's working directory. ValidVolumes []string `yaml:"valid_volumes"` // ValidVolumes specifies the volumes (including bind mounts) can be mounted to containers. + Volumes []string `yaml:"volumes"` // Volumes are mounted into every job, service and docker action container. DockerHost string `yaml:"docker_host"` // DockerHost specifies the Docker host. It overrides the value specified in environment variable DOCKER_HOST. ForcePull bool `yaml:"force_pull"` // Pull docker image(s) even if already present, except digest-pinned ones. A pull that fails while a local copy exists is a warning, not a job failure. ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present @@ -313,6 +320,17 @@ func LoadDefault(file string) (*Config, error) { } } + if cfg.Runner.CAFile != "" { + if err := cfg.Runner.loadCAFile(); err != nil { + return nil, err + } + } + for _, spec := range cfg.Container.Volumes { + if parsed, err := container.ParseVolume(spec); err != nil || parsed.Source == "" { + return nil, fmt.Errorf("container.volumes entry %q is not source:target[:options]", spec) + } + } + if cfg.Log.Level == "" { cfg.Log.Level = "info" } @@ -465,8 +483,22 @@ func LoadDefault(file string) (*Config, error) { return cfg, nil } +func (r *Runner) loadCAFile() error { + abs, err := filepath.Abs(r.CAFile) // git resolves a relative path against the repository it runs in + if err != nil { + return err + } + r.CAFile = abs + tlsConfig, err := tlsconfig.Client(tlsconfig.Options{CAFile: r.CAFile}) + if err != nil { + return fmt.Errorf("load runner CA file: %w", err) + } + r.rootCAs = tlsConfig.RootCAs + return nil +} + func (r *Runner) TLSConfig() *tls.Config { - tlsConfig := &tls.Config{InsecureSkipVerify: r.Insecure} + tlsConfig := &tls.Config{InsecureSkipVerify: r.Insecure, RootCAs: r.rootCAs} if r.ClientCertFile != "" { tlsConfig.GetClientCertificate = func(info *tls.CertificateRequestInfo) (*tls.Certificate, error) { pair, err := tls.LoadX509KeyPair(r.ClientCertFile, r.ClientKeyFile) // per handshake, so renewed files apply diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index 61446c6f..a0777076 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -10,7 +10,11 @@ import ( "crypto/tls" "crypto/x509" "encoding/pem" + "io" + "log" "math/big" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -66,6 +70,46 @@ func TestClientCertIsValidatedAndAbsoluteOnLoadReadOnUseAndOfferedOnlyWhenAccept require.Empty(t, pair.Certificate) } +func TestCAFileIsValidatedAndAbsoluteOnLoadAndTrustedBesidesTheSystem(t *testing.T) { + server := httptest.NewUnstartedServer(http.HandlerFunc(func(_writer http.ResponseWriter, _request *http.Request) {})) + server.Config.ErrorLog = log.New(io.Discard, "", 0) + server.StartTLS() + defer server.Close() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.crt"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "empty.crt"), nil, 0o600)) + load := func(file string) (*Config, error) { return LoadDefault(write(t, "runner:\n ca_file: '"+file+"'\n")) } + + t.Chdir(dir) + _, err := load("empty.crt") + require.ErrorContains(t, err, "failed to append certificates") + _, err = load("missing.crt") + require.ErrorContains(t, err, "could not read CA certificate") + cfg, err := load("ca.crt") + require.NoError(t, err) + require.Equal(t, filepath.Join(dir, "ca.crt"), cfg.Runner.CAFile) + + get := func(runner *Runner) error { + resp, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: runner.TLSConfig()}}).Get(server.URL) + if err == nil { + resp.Body.Close() + } + return err + } + require.Error(t, get(&Runner{})) + require.NoError(t, get(&cfg.Runner)) +} + +func TestLoadDefault_RejectsContainerVolumesWithoutSource(t *testing.T) { + cfg, err := LoadDefault(write(t, "container:\n volumes: ['/etc/ssl/certs:/etc/ssl/certs:ro', 'data:/data']\n")) + require.NoError(t, err) + require.Equal(t, []string{"/etc/ssl/certs:/etc/ssl/certs:ro", "data:/data"}, cfg.Container.Volumes) + for _, spec := range []string{"/etc/ssl/certs", "/a:/b:ro:/c"} { + _, err = LoadDefault(write(t, "container:\n volumes: ['"+spec+"']\n")) + require.ErrorContains(t, err, "is not source:target", spec) + } +} + func TestLoadDefault_ExpandsEnvironmentValues(t *testing.T) { t.Setenv("RUNNER_CAPACITY", "4") t.Setenv("RUNNER_LABEL", "linux:host") @@ -307,11 +351,11 @@ func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) { func TestLoadDefault_WarnsOnUnknownKeysButStillLoads(t *testing.T) { hook := logtest.SetDefault(t) - cfg, err := LoadDefault(write(t, "container:\n volumes:\n - /host:/ctr\n privileged: true\n")) + cfg, err := LoadDefault(write(t, "container:\n mounts:\n - /host:/ctr\n privileged: true\n")) require.NoError(t, err) assert.True(t, cfg.Container.Privileged) require.Len(t, hook.AllRecords(), 1) - assert.Contains(t, hook.LastRecord().Message, "field volumes not found") + assert.Contains(t, hook.LastRecord().Message, "field mounts not found") } func TestContainerNetworkCreateOptions(t *testing.T) {