enhance: bind-mount job paths through the docker proxy (#1226)

Containers a job starts through its Docker socket, for example `docker run -v "$PWD:/src"`, `./data:/data` in docker compose, or actions like dockerfile-roast, can now bind-mount the workspace and other paths the job sees, as on a host, without `bind_workdir`.

The per-job Docker proxy rewrites container and volume create requests. A bind source, or the device of a `local` volume with `o: bind`, that lies under one of the job container's mounts is pointed at that mount's path on the daemon, read from inspecting the job container. Paths that already name a daemon path, like `GITEA_DOCKER_WORKSPACE`, and paths outside the job's mounts pass through unchanged.

The proxy now also starts when the runner runs in a container given the host's Docker socket, by placing its socket in the runner's working directory, and in rootless dind, by granting the daemon socket's group through an ACL.

Fixes https://gitea.com/gitea/runner/issues/1219
Fixes https://gitea.com/gitea/runner/issues/1193

Reviewed-on: https://gitea.com/gitea/runner/pulls/1226
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
This commit is contained in:
silverwind
2026-09-14 20:21:58 +00:00
committed by bircni
parent 19afebc53f
commit 699a97fc8c
13 changed files with 367 additions and 28 deletions
+1 -1
View File
@@ -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`) #### 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 ```yaml
volumes: volumes:
+6
View File
@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"io" "io"
"sync" "sync"
"sync/atomic"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -82,6 +83,11 @@ type DockerProxy struct {
close func(context.Context) error close func(context.Context) error
closeOnce sync.Once closeOnce sync.Once
closeErr error 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 { func (p *DockerProxy) Close(ctx context.Context) error {
+198 -11
View File
@@ -19,9 +19,11 @@ import (
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"os" "os"
"path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"slices"
"strings" "strings"
"sync" "sync"
"time" "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) common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
return nil 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 { if err != nil {
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err) common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
return nil return nil
} }
if !seen { 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 return nil
} }
if ctx.Err() != nil { if ctx.Err() != nil {
@@ -84,13 +92,35 @@ func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
proxy, err := StartDockerProxy(daemonSocket, dir, job) proxy, err := StartDockerProxy(daemonSocket, dir, job)
if err != nil { if err != nil {
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err) 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 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. // 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-") marker, err := os.CreateTemp(dir, "gitea-runner-probe-")
if err != nil { if err != nil {
return false, err 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{ created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}}, Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}},
HostConfig: &container.HostConfig{Mounts: []mount.Mount{ 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) { if cerrdefs.IsInvalidArgument(err) {
@@ -151,7 +181,7 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
if err != nil { if err != nil {
return nil, errors.Join(err, os.RemoveAll(instance)) 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)) return nil, errors.Join(err, listener.Close(), os.RemoveAll(instance))
} }
dial := func(ctx context.Context, _, _ string) (net.Conn, error) { dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -165,6 +195,7 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
}, },
Transport: transport, Transport: transport,
} }
proxy := &DockerProxy{Socket: socket}
streams, cancelStreams := context.WithCancel(context.Background()) streams, cancelStreams := context.WithCancel(context.Background())
creates, cancelCreates := context.WithCancel(context.Background()) creates, cancelCreates := context.WithCancel(context.Background())
var admission sync.Mutex var admission sync.Mutex
@@ -200,7 +231,8 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
r = r.WithContext(ctx) r = r.WithContext(ctx)
if creating { if creating {
r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody) 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 status := http.StatusBadRequest
if _, ok := errors.AsType[*http.MaxBytesError](err); ok { if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
status = http.StatusRequestEntityTooLarge status = http.StatusRequestEntityTooLarge
@@ -219,7 +251,7 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
defer close(served) defer close(served)
_ = server.Serve(listener) _ = 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) ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() defer cancel()
admission.Lock() admission.Lock()
@@ -233,10 +265,11 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
handlers.Wait() handlers.Wait()
transport.CloseIdleConnections() transport.CloseIdleConnections()
return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance)) 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) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
return err return err
@@ -255,6 +288,9 @@ func addLabel(r *http.Request, job string) error {
if fields == nil { if fields == nil {
fields = make(map[string]json.RawMessage) 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 { maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool {
return strings.EqualFold(name, "Labels") return strings.EqualFold(name, "Labels")
}) })
@@ -274,6 +310,155 @@ func addLabel(r *http.Request, job string) error {
return nil 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") // 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 dockerProxyConnKey struct{}
type dockerProxyResponse 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}) networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
errs = append(errs, err) errs = append(errs, err)
for _, n := range networks.Items { 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)) errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
} }
} }
+34 -5
View File
@@ -27,6 +27,7 @@ import (
"github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/image" "github.com/moby/moby/api/types/image"
"github.com/moby/moby/api/types/mount" "github.com/moby/moby/api/types/mount"
"github.com/moby/moby/api/types/network"
"github.com/moby/moby/api/types/volume" "github.com/moby/moby/api/types/volume"
mobyclient "github.com/moby/moby/client" mobyclient "github.com/moby/moby/client"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -111,6 +112,7 @@ func TestDockerProxy(t *testing.T) {
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1") proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = proxy.Close(context.Background()) }) 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) { 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) 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"}}`, "/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"}}`, `{"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"}`, "/networks/create", `{"Name":"n"}`,
`{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`, `{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`,
}, },
{ {
"/volumes/create", `{"Name":"v","labels":null}`, "/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"}}`, `{"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", "", "/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("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil).Once()
cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}). cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
Return(mobyclient.ContainerRemoveResult{}, containerFailure).Once() 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}). cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}).
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once() Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once()
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).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, containerFailure)
require.ErrorIs(t, err, listFailure) require.ErrorIs(t, err, listFailure)
require.ErrorIs(t, err, volumeFailure) 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 container c1")
require.ErrorContains(t, err, "failed to remove volume app_data") require.ErrorContains(t, err, "failed to remove volume app_data")
cli.AssertExpectations(t) cli.AssertExpectations(t)
@@ -289,7 +318,7 @@ func TestDockerProxyWithDaemon(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
defer direct.Close() defer direct.Close()
dir := shortTempDir(t) dir := shortTempDir(t)
seen, err := daemonSeesDir(ctx, direct, dir) seen, err := daemonSeesDir(ctx, direct, dir, dir)
require.NoError(t, err) require.NoError(t, err)
t.Logf("daemon sees the runner's filesystem: %v", seen) t.Logf("daemon sees the runner's filesystem: %v", seen)
@@ -457,7 +486,7 @@ func TestDaemonSeesDir(t *testing.T) {
return mobyclient.ContainerRemoveResult{}, testCase.removeErr return mobyclient.ContainerRemoveResult{}, testCase.removeErr
}, },
} }
seen, err := daemonSeesDir(ctx, cli, dir) seen, err := daemonSeesDir(ctx, cli, dir, dir)
require.ErrorIs(t, err, testCase.removeErr) require.ErrorIs(t, err, testCase.removeErr)
assert.Equal(t, !testCase.private && testCase.removeErr == nil, seen) assert.Equal(t, !testCase.private && testCase.removeErr == nil, seen)
assert.Equal(t, !testCase.private, removed) assert.Equal(t, !testCase.private, removed)
+25 -4
View File
@@ -6,22 +6,43 @@
package container package container
import ( import (
"encoding/binary"
"errors" "errors"
"io/fs"
"math"
"os" "os"
"path/filepath" "path/filepath"
"syscall" "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) stat, ok := info.Sys().(*syscall.Stat_t)
if !ok { if !ok {
return errors.New("docker socket ownership is unavailable") return errors.New("docker socket ownership is unavailable")
} }
if err := os.Chown(socket, int(stat.Uid), int(stat.Gid)); err != nil { groupErr := os.Chown(socket, int(stat.Uid), int(stat.Gid))
return err 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 { if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
return err 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
} }
+1 -1
View File
@@ -10,6 +10,6 @@ import (
"os" "os"
) )
func copyDockerSocketPermissions(_ string, _ os.FileInfo) error { func copyDockerSocketPermissions(_, _ string, _ os.FileInfo) error {
return errors.New("docker socket ownership cannot be preserved on Windows") return errors.New("docker socket ownership cannot be preserved on Windows")
} }
+22
View File
@@ -14,6 +14,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"path"
"path/filepath" "path/filepath"
"reflect" "reflect"
"regexp" "regexp"
@@ -257,6 +258,27 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info {
for _, mountPoint := range inspect.Mounts { for _, mountPoint := range inspect.Mounts {
info.Mounts[mountPoint.Destination] = mountPoint.Source 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 { if state := inspect.State; state != nil {
info.State = string(state.Status) info.State = string(state.Status)
+11
View File
@@ -814,12 +814,23 @@ func TestContainerInfoFromInspect(t *testing.T) {
Mounts: []container.MountPoint{ Mounts: []container.MountPoint{
{Type: mount.TypeVolume, Name: "job", Source: "/var/lib/docker/volumes/job/_data", Destination: "/workspace/owner/repo"}, {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.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{ assert.Equal(t, map[string]string{
"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data", "/workspace/owner/repo": "/var/lib/docker/volumes/job/_data",
"/workspace/owner/repo/tmp": "",
"/var/run/docker.sock": "/var/run/docker.sock", "/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) }, info.Mounts)
}) })
} }
+9 -1
View File
@@ -49,12 +49,17 @@ func TestDockerProxyMountedJob(t *testing.T) {
ContainerNamePrefix: resourceName, ContainerNamePrefix: resourceName,
ContainerDaemonSocket: dockerClient.DaemonHost(), ContainerDaemonSocket: dockerClient.DaemonHost(),
ContainerMaxLifetime: 2 * time.Minute, 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) require.NoError(t, err)
planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true) planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true)
require.NoError(t, err) require.NoError(t, err)
plan, err := planner.PlanEvent("push") plan, err := planner.PlanEvent("push")
if mode == "direct" {
plan, err = planner.PlanJob("proxy")
}
require.NoError(t, err) require.NoError(t, err)
runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil) runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil)
require.NoError(t, err) require.NoError(t, err)
@@ -85,6 +90,9 @@ func TestDockerProxyMountedJob(t *testing.T) {
messages = append(messages, strings.TrimSpace(entry.Message)) messages = append(messages, strings.TrimSpace(entry.Message))
} }
require.Contains(t, messages, "docker proxy post verified") 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{}) _, err = dockerClient.ContainerInspect(ctx, jobName, client.ContainerInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err), "job container survived cleanup: %v", err) assert.True(t, cerrdefs.IsNotFound(err), "job container survived cleanup: %v", err)
_, err = dockerClient.NetworkInspect(ctx, resourceName, client.NetworkInspectOptions{}) _, err = dockerClient.NetworkInspect(ctx, resourceName, client.NetworkInspectOptions{})
+3
View File
@@ -992,6 +992,9 @@ func (rc *RunContext) captureJobContainerInfo() common.Executor {
return nil return nil
} }
rc.jobContainerID = info.ID rc.jobContainerID = info.ID
if rc.dockerProxy != nil {
rc.dockerProxy.SetMounts(info.Mounts)
}
workspace := rc.githubWorkspace() workspace := rc.githubWorkspace()
for dir := workspace; dir != "/" && dir != "."; dir = path.Dir(dir) { for dir := workspace; dir != "/" && dir != "."; dir = path.Dir(dir) {
if source := info.Mounts[dir]; source != "" { if source := info.Mounts[dir]; source != "" {
+48
View File
@@ -6,3 +6,51 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./action - 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"
+2 -1
View File
@@ -261,7 +261,8 @@ container:
#docker_timeout: 0s #docker_timeout: 0s
# Mount the workspace from a host directory instead of a Docker volume, so jobs # 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 # 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. # The workspace parent directory must be mounted into the runner container.
#bind_workdir: false #bind_workdir: false
# How long a job waits for a service container that declares a healthcheck to become # How long a job waits for a service container that declares a healthcheck to become
+5 -2
View File
@@ -125,14 +125,14 @@ if [ "$default_tests" = true ]; then
tar -C "$test_dir" -cf - runner.test -C "$PWD/act/runner" testdata/docker-proxy | \ 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 docker -H "$host_docker" exec -i "$name" tar -x -C /tmp/gitea-runner-proxy-test
socket="unix:///var/run/docker.sock" socket="unix:///var/run/docker.sock"
users=(0) users=(0 1000:2375)
if [ "$target" = dind-rootless ]; then if [ "$target" = dind-rootless ]; then
socket="unix:///run/user/1000/docker.sock" socket="unix:///run/user/1000/docker.sock"
users=(1000 0) users=(1000 0)
fi fi
for user in "${users[@]}"; do for user in "${users[@]}"; do
proxy_mode="proxy" proxy_mode="proxy"
if [ "$user" != 0 ]; then if [ "$user" = 1000:2375 ]; then
proxy_mode="direct" proxy_mode="direct"
fi fi
echo "==> Running mounted Docker job inside ${target} as UID ${user}, expecting ${proxy_mode} access" 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" \ -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 "$name" ./runner.test -test.v -test.run '^TestDockerProxyMountedJob$' -test.timeout 3m
done 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 fi