diff --git a/README.md b/README.md index d5cebe70..5c9c4524 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ Unlike GitHub, a job whose steps run on the host (a `host` label without `contai #### Docker from a job (`GITEA_DOCKER_WORKSPACE`) -A container a job starts through the Docker socket cannot bind-mount the workspace by the job's own path, the daemon does not have it. `GITEA_DOCKER_WORKSPACE` holds the path the daemon sees. Use it as the prefix of workspace binds, with `.` as the fallback for local use, here in a `docker-compose.yaml`: +Containers a job starts through the Docker socket can bind-mount the workspace by the job's own path, as on a host, for example `docker run -v "$PWD:/src"`. Without the Docker proxy, for example with a remote `DOCKER_HOST`, use `GITEA_DOCKER_WORKSPACE`, the path the daemon sees, as the prefix of workspace binds, with `.` as the fallback for local use: ```yaml volumes: diff --git a/act/container/container_types.go b/act/container/container_types.go index c8d07a8f..5e66e059 100644 --- a/act/container/container_types.go +++ b/act/container/container_types.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "sync" + "sync/atomic" "gitea.com/gitea/runner/act/common" @@ -82,6 +83,11 @@ type DockerProxy struct { close func(context.Context) error closeOnce sync.Once closeErr error + mounts atomic.Value +} + +func (p *DockerProxy) SetMounts(mounts map[string]string) { + p.mounts.Store(mounts) } func (p *DockerProxy) Close(ctx context.Context) error { diff --git a/act/container/docker_proxy.go b/act/container/docker_proxy.go index b32dd341..02ca60de 100644 --- a/act/container/docker_proxy.go +++ b/act/container/docker_proxy.go @@ -19,9 +19,11 @@ import ( "net/http" "net/http/httputil" "os" + "path" "path/filepath" "regexp" "runtime" + "slices" "strings" "sync" "time" @@ -69,13 +71,19 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy { common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err) return nil } - seen, err := daemonSeesDir(probeCtx, cli, dir) + daemonDir := dir + seen, err := daemonSeesDir(probeCtx, cli, dir, daemonDir) + if err == nil && !seen { + if dir, daemonDir = runnerContainerWorkdir(probeCtx, cli); daemonDir != "" { + seen, err = daemonSeesDir(probeCtx, cli, dir, daemonDir) + } + } if err != nil { common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err) return nil } if !seen { - common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary filesystem, jobs get the daemon socket directly") + common.Logger(ctx).Infof("the docker daemon cannot reach the runner's temporary or working directory, jobs get the daemon socket directly") return nil } if ctx.Err() != nil { @@ -84,13 +92,35 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy { proxy, err := StartDockerProxy(daemonSocket, dir, job) if err != nil { common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err) + return nil } + proxy.Socket = daemonDir + strings.TrimPrefix(proxy.Socket, dir) return proxy } -// daemonSeesDir reports whether the daemon opens the files the runner writes in dir, +// runnerContainerWorkdir looks the runner's container up by hostname to find the daemon's path to its working directory. +func runnerContainerWorkdir(ctx context.Context, cli client.APIClient) (workdir, daemonDir string) { + workdir, err := os.Getwd() + hostname, hostnameErr := os.Hostname() + if err != nil || hostnameErr != nil { + return "", "" + } + self, err := cli.ContainerInspect(ctx, hostname, client.ContainerInspectOptions{}) + if err != nil { + return "", "" + } + destination := "" + for _, point := range self.Container.Mounts { + if rel, err := filepath.Rel(point.Destination, workdir); err == nil && filepath.IsLocal(rel) && len(point.Destination) > len(destination) { + destination, daemonDir = point.Destination, filepath.Join(point.Source, rel) + } + } + return workdir, daemonDir +} + +// daemonSeesDir reports whether the daemon opens the files the runner writes in dir by their path in daemonDir, // which is what a job's proxy socket mounted from there needs. -func daemonSeesDir(ctx context.Context, cli client.APIClient, dir string) (bool, error) { +func daemonSeesDir(ctx context.Context, cli client.APIClient, dir, daemonDir string) (bool, error) { marker, err := os.CreateTemp(dir, "gitea-runner-probe-") if err != nil { return false, err @@ -113,7 +143,7 @@ func daemonSeesDir(ctx context.Context, cli client.APIClient, dir string) (bool, created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}}, HostConfig: &container.HostConfig{Mounts: []mount.Mount{ - {Type: mount.TypeBind, Source: marker.Name(), Target: "/gitea-runner-probe", ReadOnly: true}, + {Type: mount.TypeBind, Source: filepath.Join(daemonDir, filepath.Base(marker.Name())), Target: "/gitea-runner-probe", ReadOnly: true}, }}, }) if cerrdefs.IsInvalidArgument(err) { @@ -151,7 +181,7 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) { if err != nil { return nil, errors.Join(err, os.RemoveAll(instance)) } - if err := copyDockerSocketPermissions(socket, info); err != nil { + if err := copyDockerSocketPermissions(daemonSocket, socket, info); err != nil { return nil, errors.Join(err, listener.Close(), os.RemoveAll(instance)) } dial := func(ctx context.Context, _, _ string) (net.Conn, error) { @@ -165,6 +195,7 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) { }, Transport: transport, } + proxy := &DockerProxy{Socket: socket} streams, cancelStreams := context.WithCancel(context.Background()) creates, cancelCreates := context.WithCancel(context.Background()) var admission sync.Mutex @@ -200,7 +231,8 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) { r = r.WithContext(ctx) if creating { r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody) - if err := addLabel(r, job); err != nil { + mounts, _ := proxy.mounts.Load().(map[string]string) + if err := rewriteCreate(r, job, mounts); err != nil { status := http.StatusBadRequest if _, ok := errors.AsType[*http.MaxBytesError](err); ok { status = http.StatusRequestEntityTooLarge @@ -219,7 +251,7 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) { defer close(served) _ = server.Serve(listener) }() - return &DockerProxy{Socket: socket, close: func(ctx context.Context) error { + proxy.close = func(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() admission.Lock() @@ -233,10 +265,11 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) { handlers.Wait() transport.CloseIdleConnections() return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance)) - }}, nil + } + return proxy, nil } -func addLabel(r *http.Request, job string) error { +func rewriteCreate(r *http.Request, job string, mounts map[string]string) error { body, err := io.ReadAll(r.Body) if err != nil { return err @@ -255,6 +288,9 @@ func addLabel(r *http.Request, job string) error { if fields == nil { fields = make(map[string]json.RawMessage) } + if len(mounts) > 0 && !hasAmbiguousFields(body) { + translateBinds(fields, createPath.FindStringSubmatch(r.URL.Path)[2], mounts) + } maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool { return strings.EqualFold(name, "Labels") }) @@ -274,6 +310,155 @@ func addLabel(r *http.Request, job string) error { return nil } +func translateBinds(fields map[string]json.RawMessage, kind string, mounts map[string]string) { + cleanedSource := func(source string) string { + cleaned := path.Clean(source) + if target := jobMount(cleaned, mounts); mounts[target] != "" { + return mounts[target] + cleaned[len(target):] + } + return source + } + spelledSource := func(source string) string { // dockerd checks these as spelled + target := jobMount(path.Clean(source), mounts) + if rest, spelled := strings.CutPrefix(source, target); mounts[target] != "" && spelled && (rest == "" || rest[0] == '/') && filepath.IsLocal("."+rest) { + return mounts[target] + rest + } + return cleanedSource(source) + } + switch kind { + case "volumes": + var driver string + var options map[string]any + decodeField(fields, "Driver", &driver) + if key := decodeField(fields, "DriverOpts", &options); options != nil { + translateDevice(driver, options, spelledSource) + if encoded, err := json.Marshal(options); err == nil { + fields[key] = encoded + } + } + case "containers": + var hostConfig map[string]any + key := decodeField(fields, "HostConfig", &hostConfig) + binds, _ := field(hostConfig, "Binds").([]any) + for i, bind := range binds { + bind, _ := bind.(string) + source, target, _ := strings.Cut(bind, ":") + if translated := cleanedSource(source); strings.HasPrefix(target, "/") && !strings.Contains(translated, ":") { + binds[i] = translated + ":" + target + } + } + specs, _ := field(hostConfig, "Mounts").([]any) + for _, spec := range specs { + spec, _ := spec.(map[string]any) + switch field(spec, "Type") { + case "bind": + if source, ok := field(spec, "Source").(string); ok { + spec["Source"] = spelledSource(source) + } + case "volume": + volumeOptions, _ := field(spec, "VolumeOptions").(map[string]any) + driverConfig, _ := field(volumeOptions, "DriverConfig").(map[string]any) + translateDevice(field(driverConfig, "Name"), field(driverConfig, "Options"), spelledSource) + } + } + if encoded, err := json.Marshal(hostConfig); err == nil && hostConfig != nil { + fields[key] = encoded + } + } +} + +func decodeField(fields map[string]json.RawMessage, name string, value any) string { + for key, raw := range fields { + if strings.EqualFold(key, name) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + _ = decoder.Decode(value) // wrong types stay unset for dockerd to reject + return key + } + } + return "" +} + +func translateDevice(driver, options any, translate func(string) string) { + optionMap, _ := options.(map[string]any) + device, ok := optionMap["device"].(string) + flags, _ := optionMap["o"].(string) + tokens := strings.Split(flags, ",") + local := driver == nil || driver == "" || driver == "local" + if ok && local && (slices.Contains(tokens, "bind") || slices.Contains(tokens, "rbind")) && !slices.Contains(tokens, "remount") { + optionMap["device"] = translate(device) + } +} + +// field also renames the matched key to name. +func field(object map[string]any, name string) any { + for key, value := range object { + if strings.EqualFold(key, name) { + delete(object, key) + object[name] = value + return value + } + } + return nil +} + +var ( + requestFields = []string{"hostconfig", "driver", "driveropts"} + asciiFolds = strings.NewReplacer("ſ", "s", "K", "k") // the non-ASCII runes strings.EqualFold matches to ASCII letters +) + +// dockerd settles repeated names by order, which re-encoding loses. +func hasAmbiguousFields(body []byte) bool { + type frame struct { + names map[string]bool + key string + expectKey bool + nested bool + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + stack := []*frame{{}} + for { + token, err := decoder.Token() + if err != nil { + return !errors.Is(err, io.EOF) + } + top := stack[len(stack)-1] + if name, ok := token.(string); ok && top.expectKey { + top.key, top.expectKey = strings.ToLower(asciiFolds.Replace(name)), false + if top.names[top.key] && (top.nested || slices.Contains(requestFields, top.key)) { + return true + } + top.names[top.key] = true + continue + } + top.expectKey = top.names != nil + nested := top.nested || len(stack) == 2 && slices.Contains(requestFields, top.key) + switch token { + case json.Delim('{'): + stack = append(stack, &frame{names: map[string]bool{}, expectKey: true, nested: nested}) + case json.Delim('['): + stack = append(stack, &frame{nested: nested}) + case json.Delim('}'), json.Delim(']'): + stack = stack[:len(stack)-1] + } + } +} + +// jobMount returns "" also for a path already naming a daemon source. +func jobMount(source string, mounts map[string]string) string { + target := "" + for destination, daemonSource := range mounts { + if daemonSource != "" && (source == daemonSource || strings.HasPrefix(source, daemonSource+"/")) { + return "" + } + if len(destination) > len(target) && (source == destination || strings.HasPrefix(source, destination+"/")) { + target = destination + } + } + return target +} + type dockerProxyConnKey struct{} type dockerProxyResponse struct { @@ -380,7 +565,9 @@ func removeLabelled(ctx context.Context, cli client.APIClient, job string) error networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters}) errs = append(errs, err) for _, n := range networks.Items { - if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) { + if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); n.Scope == "swarm" && cerrdefs.IsInvalidArgument(err) { // swarm refuses while a service or its tasks use it + logger.Infof("keeping network %s, a swarm service still uses it", n.Name) + } else if err != nil && !cerrdefs.IsNotFound(err) { errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err)) } } diff --git a/act/container/docker_proxy_test.go b/act/container/docker_proxy_test.go index 641d1e6f..81c1ff0c 100644 --- a/act/container/docker_proxy_test.go +++ b/act/container/docker_proxy_test.go @@ -27,6 +27,7 @@ import ( "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/image" "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/api/types/network" "github.com/moby/moby/api/types/volume" mobyclient "github.com/moby/moby/client" "github.com/stretchr/testify/assert" @@ -111,6 +112,7 @@ func TestDockerProxy(t *testing.T) { proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1") require.NoError(t, err) t.Cleanup(func() { _ = proxy.Close(context.Background()) }) + proxy.SetMounts(map[string]string{"/workspace/o/r": "/volumes/job/_data", "/workspace/o/r/tmp": "", "/var/run/docker.sock": "/tmp/p/docker.sock", "/volumes": "/daemon/volumes"}) client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", proxy.Socket) }}} @@ -147,13 +149,37 @@ func TestDockerProxy(t *testing.T) { "/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`, `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"job-1"}}`, }, + { + "/containers/create", `{"HostConfig":{"Binds":["/workspace/o/r:/src:ro","/workspace/o/r/../r/sub/:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2"}}`, + `{"HostConfig":{"Binds":["/volumes/job/_data:/src:ro","/volumes/job/_data/sub:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2","com.gitea.runner.job":"job-1"}}`, + }, + { + "/containers/create", `{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/var/run/docker.sock","Target":"/var/run/docker.sock"},{"type":"bind","source":"/workspace/o/r/sub/","target":"/m"},{"Type":"bind","Source":"/workspace/o/r/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/workspace/o/r/../r","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]}}`, + `{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/tmp/p/docker.sock","Target":"/var/run/docker.sock"},{"Type":"bind","Source":"/volumes/job/_data/sub/","target":"/m"},{"Type":"bind","Source":"/volumes/job/_data/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/volumes/job/_data","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]},"Labels":{"com.gitea.runner.job":"job-1"}}`, + }, + { + "/containers/create", `{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/workspace/o/r/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]}}`, + `{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/volumes/job/_data/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]},"Labels":{"com.gitea.runner.job":"job-1"}}`, + }, + { + "/containers/create", `{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null}`, + `{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null,"Labels":{"com.gitea.runner.job":"job-1"}}`, + }, { "/networks/create", `{"Name":"n"}`, `{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`, }, { - "/volumes/create", `{"Name":"v","labels":null}`, - `{"Name":"v","Labels":{"com.gitea.runner.job":"job-1"}}`, + "/volumes/create", `{"Name":"v","labels":null,"DriverOpts":{"type":"none","o":"rbind,ro","device":"/workspace/o/r/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`, + `{"Name":"v","Labels":{"com.gitea.runner.job":"job-1"},"DriverOpts":{"type":"none","o":"rbind,ro","device":"/volumes/job/_data/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`, + }, + { + "/volumes/create", `{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"}}`, + `{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`, + }, + { + "/volumes/create", `{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"}}`, + `{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`, }, { "/volumes/create", "", @@ -267,7 +293,9 @@ func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) { cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil).Once() cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}). Return(mobyclient.ContainerRemoveResult{}, containerFailure).Once() - cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).Return(mobyclient.NetworkListResult{}, listFailure).Once() + cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}). + Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "stack_default", Scope: "swarm"}}}, listFailure).Once() + cli.On("NetworkRemove", ctx, "n1", mobyclient.NetworkRemoveOptions{}).Return(mobyclient.NetworkRemoveResult{}, cerrdefs.ErrInvalidArgument).Once() cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}). Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once() cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).Once() @@ -276,6 +304,7 @@ func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) { require.ErrorIs(t, err, containerFailure) require.ErrorIs(t, err, listFailure) require.ErrorIs(t, err, volumeFailure) + require.NotErrorIs(t, err, cerrdefs.ErrInvalidArgument) require.ErrorContains(t, err, "failed to remove container c1") require.ErrorContains(t, err, "failed to remove volume app_data") cli.AssertExpectations(t) @@ -289,7 +318,7 @@ func TestDockerProxyWithDaemon(t *testing.T) { require.NoError(t, err) defer direct.Close() dir := shortTempDir(t) - seen, err := daemonSeesDir(ctx, direct, dir) + seen, err := daemonSeesDir(ctx, direct, dir, dir) require.NoError(t, err) t.Logf("daemon sees the runner's filesystem: %v", seen) @@ -457,7 +486,7 @@ func TestDaemonSeesDir(t *testing.T) { return mobyclient.ContainerRemoveResult{}, testCase.removeErr }, } - seen, err := daemonSeesDir(ctx, cli, dir) + seen, err := daemonSeesDir(ctx, cli, dir, dir) require.ErrorIs(t, err, testCase.removeErr) assert.Equal(t, !testCase.private && testCase.removeErr == nil, seen) assert.Equal(t, !testCase.private, removed) diff --git a/act/container/docker_proxy_unix.go b/act/container/docker_proxy_unix.go index 402a6d93..61b11e8d 100644 --- a/act/container/docker_proxy_unix.go +++ b/act/container/docker_proxy_unix.go @@ -6,22 +6,43 @@ package container import ( + "encoding/binary" "errors" + "io/fs" + "math" "os" "path/filepath" "syscall" + + "golang.org/x/sys/unix" ) -func copyDockerSocketPermissions(socket string, info os.FileInfo) error { +func copyDockerSocketPermissions(daemonSocket, socket string, info os.FileInfo) error { stat, ok := info.Sys().(*syscall.Stat_t) if !ok { return errors.New("docker socket ownership is unavailable") } - if err := os.Chown(socket, int(stat.Uid), int(stat.Gid)); err != nil { - return err + groupErr := os.Chown(socket, int(stat.Uid), int(stat.Gid)) + if groupErr != nil && (!errors.Is(groupErr, fs.ErrPermission) || int(stat.Uid) != os.Geteuid()) { + return groupErr } if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil { return err } - return os.Chmod(socket, info.Mode().Perm()) + mode := uint16(info.Mode().Perm()) + if err := os.Chmod(socket, fs.FileMode(mode)); err != nil || groupErr == nil { + return err + } + if size, err := unix.Getxattr(daemonSocket, "system.posix_acl_access", nil); err == nil && size > 0 { + return groupErr + } + owner, group, other := mode>>6, mode>>3&7, mode&7 + acl := binary.LittleEndian.AppendUint32(nil, 2) + for _, entry := range []struct { + tag, perm uint16 + id uint32 + }{{1, owner, math.MaxUint32}, {4, other, math.MaxUint32}, {8, group, stat.Gid}, {16, group | other, math.MaxUint32}, {32, other, math.MaxUint32}} { + acl = binary.LittleEndian.AppendUint32(binary.LittleEndian.AppendUint16(binary.LittleEndian.AppendUint16(acl, entry.tag), entry.perm), entry.id) + } + return unix.Setxattr(socket, "system.posix_acl_access", acl, 0) // names the group a rootless runner cannot chown to, its own group keeps what others had } diff --git a/act/container/docker_proxy_windows.go b/act/container/docker_proxy_windows.go index 8ab70f80..7bafc3c2 100644 --- a/act/container/docker_proxy_windows.go +++ b/act/container/docker_proxy_windows.go @@ -10,6 +10,6 @@ import ( "os" ) -func copyDockerSocketPermissions(_ string, _ os.FileInfo) error { +func copyDockerSocketPermissions(_, _ string, _ os.FileInfo) error { return errors.New("docker socket ownership cannot be preserved on Windows") } diff --git a/act/container/docker_run.go b/act/container/docker_run.go index 94d44f2d..c8b4c111 100644 --- a/act/container/docker_run.go +++ b/act/container/docker_run.go @@ -14,6 +14,7 @@ import ( "fmt" "io" "os" + "path" "path/filepath" "reflect" "regexp" @@ -257,6 +258,27 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info { for _, mountPoint := range inspect.Mounts { info.Mounts[mountPoint.Destination] = mountPoint.Source } + if hostConfig := inspect.HostConfig; hostConfig != nil { // Mounts omits --tmpfs targets and subpaths + for target := range hostConfig.Tmpfs { + info.Mounts[path.Clean(target)] = "" + } + for _, spec := range hostConfig.Mounts { + subpath := "" + if spec.VolumeOptions != nil { + subpath = spec.VolumeOptions.Subpath + } else if spec.ImageOptions != nil { + subpath = spec.ImageOptions.Subpath + } + if target := path.Clean(spec.Target); subpath != "" && info.Mounts[target] != "" { + info.Mounts[target] = path.Join(info.Mounts[target], subpath) + } + } + } + for _, target := range []string{"/etc/hosts", "/etc/hostname", "/etc/resolv.conf"} { // specific to the job's network namespace + if _, mounted := info.Mounts[target]; !mounted { + info.Mounts[target] = "" + } + } if state := inspect.State; state != nil { info.State = string(state.Status) diff --git a/act/container/docker_run_test.go b/act/container/docker_run_test.go index d520664d..967676a5 100644 --- a/act/container/docker_run_test.go +++ b/act/container/docker_run_test.go @@ -814,12 +814,23 @@ func TestContainerInfoFromInspect(t *testing.T) { Mounts: []container.MountPoint{ {Type: mount.TypeVolume, Name: "job", Source: "/var/lib/docker/volumes/job/_data", Destination: "/workspace/owner/repo"}, {Type: mount.TypeBind, Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock"}, + {Type: mount.TypeVolume, Name: "cache", Source: "/var/lib/docker/volumes/cache/_data", Destination: "/cache"}, + {Type: mount.TypeBind, Source: "/custom/resolv.conf", Destination: "/etc/resolv.conf"}, + }, + HostConfig: &container.HostConfig{ + Tmpfs: map[string]string{"/workspace/owner/repo//tmp/": "size=1m"}, + Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "cache", Target: "/cache/", VolumeOptions: &mount.VolumeOptions{Subpath: "project"}}}, }, }) assert.Equal(t, map[string]string{ - "/workspace/owner/repo": "/var/lib/docker/volumes/job/_data", - "/var/run/docker.sock": "/var/run/docker.sock", + "/workspace/owner/repo": "/var/lib/docker/volumes/job/_data", + "/workspace/owner/repo/tmp": "", + "/var/run/docker.sock": "/var/run/docker.sock", + "/cache": "/var/lib/docker/volumes/cache/_data/project", + "/etc/resolv.conf": "/custom/resolv.conf", + "/etc/hosts": "", + "/etc/hostname": "", }, info.Mounts) }) } diff --git a/act/runner/docker_proxy_integration_test.go b/act/runner/docker_proxy_integration_test.go index c5b49d73..87970ccf 100644 --- a/act/runner/docker_proxy_integration_test.go +++ b/act/runner/docker_proxy_integration_test.go @@ -49,12 +49,17 @@ func TestDockerProxyMountedJob(t *testing.T) { ContainerNamePrefix: resourceName, ContainerDaemonSocket: dockerClient.DaemonHost(), ContainerMaxLifetime: 2 * time.Minute, - Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode}, + ContainerOptions: "-v /usr/local/bin/docker:/usr/local/bin/docker:ro -v /usr/local/libexec/docker/cli-plugins:/usr/local/libexec/docker/cli-plugins:ro", + ValidVolumes: []string{"/usr/local/bin/docker", "/usr/local/libexec/docker/cli-plugins"}, + Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode, "PROXY_TEST_IMAGE": baseImage, "COMPOSE_PROJECT_NAME": resourceName}, }) require.NoError(t, err) planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true) require.NoError(t, err) plan, err := planner.PlanEvent("push") + if mode == "direct" { + plan, err = planner.PlanJob("proxy") + } require.NoError(t, err) runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil) require.NoError(t, err) @@ -85,6 +90,9 @@ func TestDockerProxyMountedJob(t *testing.T) { messages = append(messages, strings.TrimSpace(entry.Message)) } require.Contains(t, messages, "docker proxy post verified") + if mode == "proxy" { + require.Contains(t, messages, "docker binds verified") + } _, err = dockerClient.ContainerInspect(ctx, jobName, client.ContainerInspectOptions{}) assert.True(t, cerrdefs.IsNotFound(err), "job container survived cleanup: %v", err) _, err = dockerClient.NetworkInspect(ctx, resourceName, client.NetworkInspectOptions{}) diff --git a/act/runner/run_context.go b/act/runner/run_context.go index 792dd32a..b5a4f70e 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -992,6 +992,9 @@ func (rc *RunContext) captureJobContainerInfo() common.Executor { return nil } rc.jobContainerID = info.ID + if rc.dockerProxy != nil { + rc.dockerProxy.SetMounts(info.Mounts) + } workspace := rc.githubWorkspace() for dir := workspace; dir != "/" && dir != "."; dir = path.Dir(dir) { if source := info.Mounts[dir]; source != "" { diff --git a/act/runner/testdata/docker-proxy/push.yml b/act/runner/testdata/docker-proxy/push.yml index edc2a3be..049dc4d8 100644 --- a/act/runner/testdata/docker-proxy/push.yml +++ b/act/runner/testdata/docker-proxy/push.yml @@ -6,3 +6,51 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./action + - if: env.PROXY_TEST_MODE == 'proxy' + run: | + docker run -d --restart always --network "$PROXY_TEST_RESOURCE" --name "$PROXY_TEST_RESOURCE-detached" "$PROXY_TEST_IMAGE" sleep infinity + echo "{services: {app: {image: $PROXY_TEST_IMAGE, command: sleep infinity, volumes: [named:/named]}}, volumes: {named: {}}}" | docker compose -f - up -d + binds: + needs: proxy + runs-on: ubuntu-latest + steps: + - run: | + test -z "$(docker ps -aq -f name="$PROXY_TEST_RESOURCE-detached" -f name="$PROXY_TEST_RESOURCE-app"; docker volume ls -q -f name="${PROXY_TEST_RESOURCE}_named"; docker network ls -q -f name="${PROXY_TEST_RESOURCE}_default")" + mkdir data && echo from-job > data/marker + - id: run + run: | + docker run --rm -v "${{ github.workspace }}:/src:ro" -v "$PWD:$PWD" -v ./data/created:/created -v "$GITHUB_OUTPUT:/output" \ + --mount "type=bind,src=$PWD/data/../data,dst=/spelled,readonly" \ + --mount "type=volume,dst=/device,volume-opt=type=none,volume-opt=o=bind,volume-opt=device=$PWD/data" \ + "$PROXY_TEST_IMAGE" sh -c 'cat /src/data/marker "$0/data/marker" /spelled/marker /device/marker | grep -c from-job | grep -qx 4 && echo from-nested > /created/file && echo value=from-nested >> /output' "$PWD" + grep -qx from-nested data/created/file + - run: | + test "${{ steps.run.outputs.value }}" = from-nested + cat > compose.yaml <<'EOF' + services: + app: + image: ${PROXY_TEST_IMAGE} + command: cat /short/marker /long/marker /named/marker + volumes: + - ./data:/short + - type: bind + source: ./data + target: /long + bind: + create_host_path: false + - named:/named + volumes: + named: + driver: local + driver_opts: + type: none + o: bind + device: ./data + EOF + test "$(docker compose run --rm app | grep -c from-job)" = 3 + docker compose down --volumes + - run: | + docker run --rm --user 1001 --group-add 2375 -v /var/run/docker.sock:/var/run/docker.sock -v /usr/local/bin/docker:/usr/local/bin/docker:ro -v "${GITEA_DOCKER_WORKSPACE:?}/data:/data:ro" \ + "$PROXY_TEST_IMAGE" sh -c 'grep -qx from-job /data/marker && docker volume create "$0"' "$PROXY_TEST_RESOURCE-nested" + test "$(docker volume inspect -f '{{ index .Labels "com.gitea.runner.job" }}' "$PROXY_TEST_RESOURCE-nested")" = "$JOB_CONTAINER_NAME" + echo "docker binds verified" diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index 5b98964c..5cdd2370 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -261,7 +261,8 @@ container: #docker_timeout: 0s # Mount the workspace from a host directory instead of a Docker volume, so jobs # can bind-mount it by its own path into sibling containers (".:/app" in docker - # compose). Not needed when workflows use the GITEA_DOCKER_WORKSPACE variable. + # compose). Not needed when jobs get the Docker proxy or workflows use the + # GITEA_DOCKER_WORKSPACE variable. # The workspace parent directory must be mounted into the runner container. #bind_workdir: false # How long a job waits for a service container that declares a healthcheck to become diff --git a/scripts/test-dind.sh b/scripts/test-dind.sh index 844dc559..23bb6085 100755 --- a/scripts/test-dind.sh +++ b/scripts/test-dind.sh @@ -125,14 +125,14 @@ if [ "$default_tests" = true ]; then tar -C "$test_dir" -cf - runner.test -C "$PWD/act/runner" testdata/docker-proxy | \ docker -H "$host_docker" exec -i "$name" tar -x -C /tmp/gitea-runner-proxy-test socket="unix:///var/run/docker.sock" - users=(0) + users=(0 1000:2375) if [ "$target" = dind-rootless ]; then socket="unix:///run/user/1000/docker.sock" users=(1000 0) fi for user in "${users[@]}"; do proxy_mode="proxy" - if [ "$user" != 0 ]; then + if [ "$user" = 1000:2375 ]; then proxy_mode="direct" fi echo "==> Running mounted Docker job inside ${target} as UID ${user}, expecting ${proxy_mode} access" @@ -140,4 +140,7 @@ if [ "$default_tests" = true ]; then -e DOCKER_HOST="$socket" -e ACT_TEST_DOCKER_PROXY="$proxy_mode" -e ACT_TEST_IMAGE="$job_image" \ "$name" ./runner.test -test.v -test.run '^TestDockerProxyMountedJob$' -test.timeout 3m done + echo "==> Running mounted Docker job in a container given the ${target} socket, expecting proxy access" + docker -H "$host_docker" exec -e DOCKER_HOST="$socket" "$name" docker run --rm -v "${socket#unix://}:/var/run/docker.sock" -v /tmp/gitea-runner-proxy-test:/data -w /data \ + -e ACT_TEST_DOCKER_PROXY=proxy -e ACT_TEST_IMAGE="$job_image" "$job_image" ./runner.test -test.v -test.run '^TestDockerProxyMountedJob$' -test.timeout 3m fi