mirror of
https://gitea.com/gitea/act_runner
synced 2026-09-21 19:37:07 +02:00
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:
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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", "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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{})
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user