diff --git a/README.md b/README.md index 542c5fa0..13514cfe 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,35 @@ Whenever the resulting labels differ from the ones in the registration file, the > **Note:** A runner that only exposes `host` labels still needs access to a Docker daemon (e.g. a mounted `/var/run/docker.sock`) whenever a job uses a `docker://` action or a service container. `host` labels only change where the job's own steps run; container-based steps and actions are still executed with Docker. +#### Proxy + +Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`: + +```sh +http_proxy=http://proxy.example:3128 +https_proxy=http://proxy.example:3128 +no_proxy=gitea.internal,.example.local +``` + +The runner uses them for its own requests and gives them to every job, in lower and upper case. + +These hosts are added to `no_proxy` for jobs, so they are always reached directly: + +- the cache server +- `localhost`, `127.0.0.1` and `::1` +- the job's service containers +- the Docker daemon, when it is reached over `tcp://` + +Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly. + +To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it. + +Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not. + +Dockerfile actions are built with these variables as build arguments, so their `RUN` steps can reach the network. + +A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment. + #### Caching (`actions/cache`) Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default. diff --git a/act/container/container_types.go b/act/container/container_types.go index 363ebe85..61679807 100644 --- a/act/container/container_types.go +++ b/act/container/container_types.go @@ -82,6 +82,7 @@ type NewDockerBuildExecutorInput struct { BuildContext io.Reader ImageTag string Platform string + BuildArgs map[string]*string } // NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function diff --git a/act/container/docker_build.go b/act/container/docker_build.go index 6adedfff..2e072252 100644 --- a/act/container/docker_build.go +++ b/act/container/docker_build.go @@ -49,6 +49,7 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor { Remove: true, AuthConfigs: LoadDockerAuthConfigs(ctx), Dockerfile: input.Dockerfile, + BuildArgs: input.BuildArgs, } platform, err := parsePlatform(input.Platform) if err != nil { diff --git a/act/runner/action.go b/act/runner/action.go index 7f2712c6..a125f92e 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -372,6 +372,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b ImageTag: image, BuildContext: buildContext, Platform: rc.Config.ContainerArchitecture, + BuildArgs: rc.proxyBuildArgs(), }) if buildContext == nil { // Held across the whole build: the daemon drains contextDir lazily. diff --git a/act/runner/run_context.go b/act/runner/run_context.go index 5b0a1b21..29e53edf 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -435,7 +435,9 @@ func (rc *RunContext) startJobContainer() common.Executor { continue } // interpolate env - interpolatedEnvs := make(map[string]string, len(spec.Env)) + interpolatedEnvs := make(map[string]string, len(spec.Env)+len(rc.Config.ProxyEnv)) + // a service reaches the internet the way the job does; its own env still wins + maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv) for k, v := range spec.Env { interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v) } @@ -969,6 +971,20 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) { return true, nil } +// proxyBuildArgs returns the job's proxy variables as docker build args. The docker CLI +// pre-populates these from its own client configuration, but act builds through the API, +// so without them a Dockerfile action's RUN steps have no network behind a proxy. +func (rc *RunContext) proxyBuildArgs() map[string]*string { + if len(rc.Config.ProxyEnv) == 0 { + return nil + } + args := make(map[string]*string, len(rc.Config.ProxyEnv)) + for name, value := range rc.Config.ProxyEnv { + args[name] = &value + } + return args +} + func mergeMaps(maps ...map[string]string) map[string]string { rtnMap := make(map[string]string) for _, m := range maps { diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index 8c72cdb2..8179acfa 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -292,6 +292,83 @@ jobs: require.Equal(t, [2]string{"", ""}, credentials["redis:latest"]) } +// A service container reaches the internet the same way the job does, so it inherits the +// job's proxy; a service that sets the variable itself keeps its own value. +func TestStartJobContainerGivesServicesTheJobProxy(t *testing.T) { + workflow, err := model.ReadWorkflow(strings.NewReader(` +name: test +on: push +jobs: + job: + runs-on: ubuntu-latest + container: + image: registry.example/job:latest + services: + redis: + image: redis:latest + db: + image: postgres:latest + env: + no_proxy: db-only.example + steps: [] +`)) + require.NoError(t, err) + + var inputs []*container.NewContainerInput + origNewContainer := newContainer + newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment { + inputs = append(inputs, input) + return fakeContainer{} + } + t.Cleanup(func() { newContainer = origNewContainer }) + + rc := &RunContext{ + Name: "test", + Config: &Config{ + Workdir: "/tmp", + ContainerNetworkMode: "host", + ReuseContainers: true, + Env: map[string]string{}, + ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"}, + Secrets: map[string]string{}, + }, + Env: map[string]string{}, + Run: &model.Run{ + JobID: "job", + Workflow: workflow, + }, + } + rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) + + require.NoError(t, rc.startJobContainer()(t.Context())) + + env := map[string][]string{} + for _, in := range inputs { + env[in.Image] = in.Env + } + + require.Contains(t, env["redis:latest"], "http_proxy=http://proxy:3128") + require.Contains(t, env["redis:latest"], "no_proxy=internal.example") + // the service's own env wins over what the runner injected, without dropping the rest + require.Contains(t, env["postgres:latest"], "no_proxy=db-only.example") + require.NotContains(t, env["postgres:latest"], "no_proxy=internal.example") + require.Contains(t, env["postgres:latest"], "http_proxy=http://proxy:3128") +} + +// act builds Dockerfile actions through the API, which does not pre-populate the proxy +// build args the docker CLI would, so the RUN steps would have no network behind a proxy. +func TestProxyBuildArgs(t *testing.T) { + rc := &RunContext{Config: &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128"}}} + + args := rc.proxyBuildArgs() + + require.Len(t, args, 1) + require.Equal(t, "http://proxy:3128", *args["http_proxy"]) + + // a job without a proxy builds exactly as it does today + require.Nil(t, (&RunContext{Config: &Config{}}).proxyBuildArgs()) +} + func TestRunContext_GetBindsAndMounts(t *testing.T) { rctemplate := &RunContext{ Name: "TestRCName", diff --git a/act/runner/runner.go b/act/runner/runner.go index 45d05a7d..e38e8dea 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -73,6 +73,7 @@ type Config struct { ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network) ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options ActionCache ActionCache // Use a custom ActionCache Implementation + ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc. EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath diff --git a/go.mod b/go.mod index 5abc80ac..3939b248 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928 go.etcd.io/bbolt v1.5.0 go.yaml.in/yaml/v4 v4.0.0-rc.3 + golang.org/x/net v0.56.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 golang.org/x/text v0.40.0 @@ -105,7 +106,6 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.53.0 // indirect - golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.22.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go index 2ed6f70e..a93a3384 100644 --- a/internal/app/cmd/daemon.go +++ b/internal/app/cmd/daemon.go @@ -64,6 +64,14 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu log.Warn("no labels configured, runner may not be able to pick up jobs") } + // Before the first Docker API call: the standard library resolves the proxy + // environment once. Ungated because host labels still reach the daemon. + if dockerSocketPath, err := getDockerSocketPath(cfg.Container.DockerHost); err == nil { + run.BypassProxyForDockerHost(dockerSocketPath) + } else { + log.Debugf("cannot resolve the docker socket path, so Docker API calls are not exempted from the proxy: %v", err) + } + if ls.RequireDocker() || cfg.Container.RequireDocker { // Wait for dockerd be ready if timeout := cfg.Container.DockerTimeout; timeout > 0 { @@ -101,6 +109,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu } // if dockerSocketPath passes the check, override DOCKER_HOST with dockerSocketPath os.Setenv("DOCKER_HOST", dockerSocketPath) + run.WarnIfDaemonHasNoProxy(ctx) // empty cfg.Container.DockerHost means runner need to find an available docker host automatically // and assign the path to cfg.Container.DockerHost if cfg.Container.DockerHost == "" { diff --git a/internal/app/cmd/exec.go b/internal/app/cmd/exec.go index ccbfae71..4edb05ac 100644 --- a/internal/app/cmd/exec.go +++ b/internal/app/cmd/exec.go @@ -22,6 +22,7 @@ import ( "gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/runner" + "gitea.com/gitea/runner/internal/app/run" "github.com/joho/godotenv" "github.com/moby/moby/api/types/container" @@ -415,6 +416,11 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command } handler.RegisterJob(actionsRuntimeToken, "__local/__exec") + // no service aliases: exec builds one config for the whole plan + run.BypassProxyForDockerHost(os.Getenv("DOCKER_HOST")) + proxyEnv := run.JobProxyEnv(env, env["ACTIONS_CACHE_URL"], nil) + maps.Copy(env, proxyEnv) + // run the plan config := &runner.Config{ Workdir: execArgs.Workdir(), @@ -425,6 +431,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command LogOutput: true, JSONLogger: execArgs.jsonLogger, Env: env, + ProxyEnv: proxyEnv, Vars: execArgs.LoadVars(), Secrets: execArgs.LoadSecrets(), InsecureSecrets: execArgs.insecureSecrets, diff --git a/internal/app/run/proxy.go b/internal/app/run/proxy.go new file mode 100644 index 00000000..125458bb --- /dev/null +++ b/internal/app/run/proxy.go @@ -0,0 +1,163 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package run + +import ( + "context" + "net/url" + "os" + "slices" + "strings" + + "gitea.com/gitea/runner/act/container" + + log "github.com/sirupsen/logrus" + "golang.org/x/net/http/httpproxy" +) + +// proxyFromEnv returns the runner's own proxy configuration, or nil when it has none. +func proxyFromEnv() *httpproxy.Config { + cfg := httpproxy.FromEnvironment() + if cfg.HTTPProxy == "" && cfg.HTTPSProxy == "" { + return nil + } + return cfg +} + +// JobProxyEnv returns the proxy variables a job runs with, given what runner.envs already +// put in jobEnvs. Gitea is deliberately not made direct, so it stays reachable however the +// runner reaches it. +func JobProxyEnv(jobEnvs map[string]string, cacheURL string, serviceNames []string) map[string]string { + cfg := proxyFromEnv() + if cfg == nil { + return nil + } + + // Go bypasses loopback on its own, curl and most other tools in a job do not. + direct := append([]string{"localhost", "127.0.0.1", "::1"}, serviceNames...) + direct = append(direct, hostOf(cacheURL)) + + proxyEnv := map[string]string{} + setPair := func(lower, upper, value string) { + if value == "" { + return + } + // Either spelling in runner.envs takes over both, so the pair cannot disagree. + if existing, ok := jobEnvs[lower]; ok { + value = existing + } else if existing, ok := jobEnvs[upper]; ok { + value = existing + } + proxyEnv[lower], proxyEnv[upper] = value, value + } + setPair("http_proxy", "HTTP_PROXY", cfg.HTTPProxy) + setPair("https_proxy", "HTTPS_PROXY", cfg.HTTPSProxy) + + // no_proxy is merged rather than replaced: the hosts above are structural, and an + // operator cannot list the cache server's startup-assigned address in advance. + noProxy := appendNoProxy(cfg.NoProxy, direct...) + for _, name := range []string{"no_proxy", "NO_PROXY"} { + if existing, ok := jobEnvs[name]; ok { + noProxy = appendNoProxy(existing, strings.Split(noProxy, ",")...) + break + } + } + proxyEnv["no_proxy"], proxyEnv["NO_PROXY"] = noProxy, noProxy + return proxyEnv +} + +// BypassProxyForDockerHost keeps the runner's Docker API traffic off the proxy: the docker +// client proxies every transport that is not a unix socket or a named pipe, so a tcp:// +// daemon would be reached through a proxy that cannot route to it. +// +// It must run before the first Docker API call, because the standard library resolves the +// proxy environment once. +func BypassProxyForDockerHost(dockerHost string) { + cfg := proxyFromEnv() + if cfg == nil { + return + } + host := hostOf(dockerHost) + if host == "" { + // A unix socket or named pipe is never proxied. + return + } + + noProxy := appendNoProxy(cfg.NoProxy, host) + for _, name := range []string{"no_proxy", "NO_PROXY"} { + if err := os.Setenv(name, noProxy); err != nil { + log.Warnf("cannot set %s for the runner process: %v", name, err) + } + } + log.Debugf("docker host %s is reached directly, no_proxy is now %q", host, noProxy) +} + +// WarnIfDaemonHasNoProxy points at the one part the runner cannot set: the docker daemon +// pulls the images, and a daemon in its own container needs its own proxy. +func WarnIfDaemonHasNoProxy(ctx context.Context) { + if proxyFromEnv() == nil { + return + } + info, err := container.GetHostInfo(ctx) + if err != nil { + log.Debugf("cannot read the docker daemon's proxy configuration: %v", err) + return + } + if info.HTTPProxy == "" && info.HTTPSProxy == "" { + log.Warn("the runner has a proxy but the docker daemon reports none, so image pulls will not use it: https://docs.docker.com/engine/daemon/proxy/") + } +} + +// proxyPasswords returns the passwords embedded in the runner's proxy URLs, to mask before +// a job echoes its environment. +func proxyPasswords() []string { + cfg := proxyFromEnv() + if cfg == nil { + return nil + } + + var passwords []string + for _, raw := range []string{cfg.HTTPProxy, cfg.HTTPSProxy} { + parsed, err := url.Parse(raw) + if err != nil || parsed.User == nil { + continue + } + if password, ok := parsed.User.Password(); ok && password != "" && !slices.Contains(passwords, password) { + passwords = append(passwords, password) + } + } + return passwords +} + +// appendNoProxy adds hosts to a no_proxy list, keeping the operator's entries and adding +// none twice. +func appendNoProxy(noProxy string, hosts ...string) string { + entries := []string{} + for entry := range strings.SplitSeq(noProxy, ",") { + if entry = strings.TrimSpace(entry); entry != "" { + entries = append(entries, entry) + } + } + + for _, host := range hosts { + if host == "" || slices.Contains(entries, host) { + continue + } + entries = append(entries, host) + } + return strings.Join(entries, ",") +} + +// hostOf returns the host of a URL without its port, the form a no_proxy entry takes. It is +// empty for anything without a network host, such as a unix socket. +func hostOf(raw string) string { + if raw == "" { + return "" + } + parsed, err := url.Parse(strings.TrimSuffix(raw, "/")) + if err != nil || parsed.Hostname() == "" { + return "" + } + return parsed.Hostname() +} diff --git a/internal/app/run/proxy_test.go b/internal/app/run/proxy_test.go new file mode 100644 index 00000000..33e1e750 --- /dev/null +++ b/internal/app/run/proxy_test.go @@ -0,0 +1,192 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package run + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +// clearProxyEnv starts a test from a runner that has no proxy, whatever the developer's own +// environment looks like. +func clearProxyEnv(t *testing.T) { + t.Helper() + for _, name := range []string{"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "no_proxy", "NO_PROXY"} { + t.Setenv(name, "") + } +} + +func TestJobProxyEnv(t *testing.T) { + const loopback = "localhost,127.0.0.1,::1" + const proxy = "http://proxy:3128" + + for _, tc := range []struct { + name string + runner map[string]string // the runner process environment + envs map[string]string // what runner.envs already put in the job + cacheURL string + services []string + want map[string]string + }{ + { + // The guarantee that makes this safe to ship: a runner without a proxy gives its + // jobs nothing at all. + name: "runner has no proxy", + cacheURL: "http://cache.local:8088/", + }, + { + name: "a lone no_proxy is not a proxy", + runner: map[string]string{"no_proxy": "example.com"}, + }, + { + name: "both spellings of every variable", + runner: map[string]string{"http_proxy": proxy, "https_proxy": proxy}, + want: map[string]string{ + "http_proxy": proxy, "HTTP_PROXY": proxy, + "https_proxy": proxy, "HTTPS_PROXY": proxy, + "no_proxy": loopback, "NO_PROXY": loopback, + }, + }, + { + name: "a variable the runner does not have is omitted", + runner: map[string]string{"https_proxy": proxy}, + want: map[string]string{ + "https_proxy": proxy, "HTTPS_PROXY": proxy, + "no_proxy": loopback, "NO_PROXY": loopback, + }, + }, + { + // The cache server and the service containers are on the local network; Gitea is + // not added and keeps being reached through the proxy. + name: "hosts the job must reach directly", + runner: map[string]string{"http_proxy": proxy, "no_proxy": "internal.example"}, + cacheURL: "http://192.168.1.10:34567/", + services: []string{"postgres", "redis"}, + want: map[string]string{ + "http_proxy": proxy, "HTTP_PROXY": proxy, + "no_proxy": "internal.example," + loopback + ",postgres,redis,192.168.1.10", + "NO_PROXY": "internal.example," + loopback + ",postgres,redis,192.168.1.10", + }, + }, + { + name: "runner.envs wins, for both spellings", + runner: map[string]string{"http_proxy": "http://from-env:3128"}, + envs: map[string]string{"http_proxy": "http://from-config:3128"}, + want: map[string]string{ + "http_proxy": "http://from-config:3128", "HTTP_PROXY": "http://from-config:3128", + "no_proxy": loopback, "NO_PROXY": loopback, + }, + }, + { + name: "runner.envs wins through the upper case spelling too", + runner: map[string]string{"http_proxy": "http://from-env:3128"}, + envs: map[string]string{"HTTP_PROXY": "http://from-config:3128"}, + want: map[string]string{ + "http_proxy": "http://from-config:3128", "HTTP_PROXY": "http://from-config:3128", + "no_proxy": loopback, "NO_PROXY": loopback, + }, + }, + { + // A runner.envs no_proxy adds to the hosts that must stay direct rather than + // replacing them, which would send cache traffic through the proxy. + name: "runner.envs no_proxy is merged, not substituted", + runner: map[string]string{"http_proxy": proxy}, + envs: map[string]string{"no_proxy": "operator.example"}, + cacheURL: "http://192.168.1.10:34567/", + want: map[string]string{ + "http_proxy": proxy, "HTTP_PROXY": proxy, + "no_proxy": "operator.example," + loopback + ",192.168.1.10", + "NO_PROXY": "operator.example," + loopback + ",192.168.1.10", + }, + }, + { + name: "runner.envs NO_PROXY is merged through the upper case spelling too", + runner: map[string]string{"http_proxy": proxy}, + envs: map[string]string{"NO_PROXY": "operator.example"}, + cacheURL: "http://192.168.1.10:34567/", + want: map[string]string{ + "http_proxy": proxy, "HTTP_PROXY": proxy, + "no_proxy": "operator.example," + loopback + ",192.168.1.10", + "NO_PROXY": "operator.example," + loopback + ",192.168.1.10", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + clearProxyEnv(t) + for name, value := range tc.runner { + t.Setenv(name, value) + } + assert.Equal(t, tc.want, JobProxyEnv(tc.envs, tc.cacheURL, tc.services)) + }) + } +} + +// docker-in-docker over tcp: the docker client would otherwise send API calls to a proxy +// that cannot route to the daemon. +func TestBypassProxyForDockerHost(t *testing.T) { + for _, tc := range []struct { + name string + httpProxy string + dockerHost string + want string + }{ + { + name: "tcp daemon is added", + httpProxy: "http://proxy:3128", + dockerHost: "tcp://docker:2375", + want: "internal.example,docker", + }, + { + name: "unix socket is left alone", + httpProxy: "http://proxy:3128", + dockerHost: "unix:///var/run/docker.sock", + want: "internal.example", + }, + { + name: "nothing happens without a proxy", + dockerHost: "tcp://docker:2375", + want: "internal.example", + }, + } { + t.Run(tc.name, func(t *testing.T) { + clearProxyEnv(t) + t.Setenv("no_proxy", "internal.example") + if tc.httpProxy != "" { + t.Setenv("http_proxy", tc.httpProxy) + } + + BypassProxyForDockerHost(tc.dockerHost) + + assert.Equal(t, tc.want, os.Getenv("no_proxy")) + }) + } +} + +func TestProxyPasswords(t *testing.T) { + clearProxyEnv(t) + t.Setenv("http_proxy", "http://user:hunter2@proxy:3128") + t.Setenv("https_proxy", "http://user:s3cret@proxy:3128") + assert.Equal(t, []string{"hunter2", "s3cret"}, proxyPasswords()) + + t.Setenv("https_proxy", "http://proxy:3128") + assert.Equal(t, []string{"hunter2"}, proxyPasswords()) +} + +func TestAppendNoProxy(t *testing.T) { + assert.Equal(t, "a.example,b.example", appendNoProxy(" a.example , b.example ")) + // a host already listed is not repeated + assert.Equal(t, "cache.local", appendNoProxy("cache.local", "cache.local")) + assert.Empty(t, appendNoProxy("", "", "")) +} + +func TestHostOf(t *testing.T) { + assert.Equal(t, "cache.local", hostOf("http://cache.local:8088/")) + assert.Equal(t, "192.168.1.10", hostOf("http://192.168.1.10:34567")) + // nothing to bypass for a socket path or an unparseable URL + assert.Empty(t, hostOf("unix:///var/run/docker.sock")) + assert.Empty(t, hostOf("cache.local:8088")) + assert.Empty(t, hostOf("://nope")) +} diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index b7fad1f3..24eee853 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strconv" "strings" "sync" @@ -268,7 +269,8 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error { ctx, cancel := context.WithTimeout(ctx, r.cfg.Runner.Timeout) defer cancel() - reporter := report.NewReporter(ctx, cancel, r.client, task, r.cfg) + // A proxy URL may carry credentials, and every job is given it; keep them out of the log. + reporter := report.NewReporter(ctx, cancel, r.client, task, r.cfg, proxyPasswords()...) var runErr error defer func() { r.runningCount.Add(-1) @@ -341,6 +343,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. taskContext := task.Context.Fields envs := r.cloneEnvs() + // Added per task because this job's service containers must be reached directly, and + // act reaches them by their workflow key. + proxyEnv := JobProxyEnv(envs, envs["ACTIONS_CACHE_URL"], slices.Sorted(maps.Keys(job.Services))) + maps.Copy(envs, proxyEnv) + if r.capabilities != "" { envs["GITEA_ACTIONS_CAPABILITIES"] = r.capabilities } @@ -450,6 +457,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. LogOutput: true, JSONLogger: false, Env: envs, + ProxyEnv: proxyEnv, Secrets: task.Secrets, GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"), AutoRemove: true, diff --git a/internal/app/run/runner_test.go b/internal/app/run/runner_test.go index 6e0ff6a7..ba9b1162 100644 --- a/internal/app/run/runner_test.go +++ b/internal/app/run/runner_test.go @@ -93,6 +93,24 @@ func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) { require.Nil(t, r.cacheHandler) } +// 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) { + clearProxyEnv(t) + t.Setenv("http_proxy", "http://proxy:3128") + + cfg := &config.Config{} + cfg.Cache.ExternalServer = "http://cache.local:8088/" + reg := &config.Registration{Name: "runner"} + cli := clientmocks.NewClient(t) + cli.On("Address").Return("https://gitea.example/").Maybe() + + r := NewRunner(cfg, reg, cli) + + require.NotContains(t, r.envs, "http_proxy") + require.NotContains(t, r.envs, "no_proxy") +} + func taskWithDefaultActionsURL(url string) *runnerv1.Task { return &runnerv1.Task{ Context: &structpb.Struct{ diff --git a/internal/pkg/report/reporter.go b/internal/pkg/report/reporter.go index bb82c89d..82f2bfab 100644 --- a/internal/pkg/report/reporter.go +++ b/internal/pkg/report/reporter.go @@ -86,8 +86,13 @@ type Reporter struct { stopCommandEndToken string } -func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.Client, task *runnerv1.Task, cfg *config.Config) *Reporter { +// extraMasks are values known before the job starts that are not among its secrets, such as +// the password in the runner's proxy URL. +func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.Client, task *runnerv1.Task, cfg *config.Config, extraMasks ...string) *Reporter { var oldnew []string + for _, v := range extraMasks { + oldnew = runner.AppendSecretMasker(oldnew, v) + } if v := task.Context.Fields["token"].GetStringValue(); v != "" { oldnew = runner.AppendSecretMasker(oldnew, v) }