fix: stop the job's docker socket from becoming a directory (#1215)

Fixes https://gitea.com/gitea/runner/issues/1213

Fix the DooD regression that mounts `/var/run/docker.sock` as a directory. Keep the Docker proxy available through job and post steps. Clean stale resources before opening it, then remove containers before their networks and volumes during teardown.

Use a unique filesystem probe and preserve socket ownership. Fall back to direct access when proxying is unsupported. Preserve exec output and clean up active streams and failed starts.

Add a real Docker job test for mounted socket access, post steps and resource cleanup.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1215
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
Zettat123
2026-09-08 04:16:02 +00:00
committed by bircni
co-authored by silverwind
parent ba4d3c5b4f
commit ff9965e940
18 changed files with 935 additions and 295 deletions
+1 -1
View File
@@ -275,7 +275,7 @@ volumes:
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data - ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
``` ```
Containers, networks and volumes a job creates through the socket are removed when the job ends. Both apply to jobs in containers, on the host `.` already works. Linux container jobs use a Docker proxy when socket sharing and permissions allow it. Other setups use the daemon socket directly. The proxy removes containers, networks and volumes after post steps and the completed hook. Named volumes created through it are job-scoped. To retain resources, mount the daemon socket explicitly in `container.options`. Host jobs use their existing Docker access, where `.` works.
#### Proxy #### Proxy
+7 -4
View File
@@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"sync"
"gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common"
@@ -77,13 +78,15 @@ var ErrContainerNotFound = errors.New("does not exist")
// DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime. // DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime.
type DockerProxy struct { type DockerProxy struct {
Socket string Socket string
close func(context.Context) error close func(context.Context) error
closeOnce sync.Once
closeErr error
} }
// Close removes what the job created through the socket, then stops serving it.
func (p *DockerProxy) Close(ctx context.Context) error { func (p *DockerProxy) Close(ctx context.Context) error {
return p.close(ctx) p.closeOnce.Do(func() { p.closeErr = p.close(ctx) })
return p.closeErr
} }
// Info is a snapshot of a container, as of one inspect. // Info is a snapshot of a container, as of one inspect.
+229 -110
View File
@@ -6,20 +6,22 @@
package container package container
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"mime"
"net" "net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -35,64 +37,84 @@ import (
const ( const (
jobLabel = "com.gitea.runner.job" jobLabel = "com.gitea.runner.job"
maxCreateBody = 8 << 20 maxCreateBody = 8 << 20
dockerProxyProbeTimeout = 5 * time.Second
) )
var ( var (
createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`) createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`)
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`) rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
proxyProbe struct {
sync.Mutex
decided bool
dir string
}
) )
// DockerProxyDir returns where job proxy sockets live, "" while undecided or when the daemon cannot open the runner's files. func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
func DockerProxyDir(ctx context.Context) string { if host := os.Getenv("DOCKER_HOST"); runtime.GOOS != "linux" || host != "" && !strings.HasPrefix(host, "unix://") {
proxyProbe.Lock() return nil
defer proxyProbe.Unlock()
if proxyProbe.decided {
return proxyProbe.dir
} }
dir := filepath.Join(os.TempDir(), "gitea-runner-docker") probeCtx, cancel := context.WithTimeout(ctx, dockerProxyProbeTimeout)
ok, err := daemonSeesDir(ctx, dir) defer cancel()
cli, err := GetDockerClient(probeCtx)
if err != nil { if err != nil {
common.Logger(ctx).Debugf("docker proxy probe postponed: %v", err) return nil
return ""
}
proxyProbe.decided = true
if ok {
proxyProbe.dir = dir
} else {
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's filesystem, jobs get the daemon socket directly")
}
return proxyProbe.dir
}
func daemonSeesDir(ctx context.Context, dir string) (bool, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return false, err
}
probe := filepath.Join(dir, "probe")
if err := os.WriteFile(probe, nil, 0o600); err != nil {
return false, err
}
cli, err := GetDockerClient(ctx)
if err != nil {
return false, err
} }
defer cli.Close() defer cli.Close()
daemonSocket, ok := strings.CutPrefix(cli.DaemonHost(), "unix://")
if !ok {
return nil
}
if info, err := os.Stat(daemonSocket); err != nil || info.Mode()&os.ModeSocket == 0 {
return nil
}
dir, err := filepath.Abs(os.TempDir())
if err != nil {
common.Logger(ctx).Infof("docker proxy probe failed, jobs get the daemon socket directly: %v", err)
return nil
}
seen, err := daemonSeesDir(probeCtx, cli, dir)
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")
return nil
}
if ctx.Err() != nil {
return nil
}
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 proxy
}
// daemonSeesDir reports whether the daemon opens the files the runner writes in dir,
// which is what a job's proxy socket mounted from there needs.
func daemonSeesDir(ctx context.Context, cli client.APIClient, dir string) (bool, error) {
marker, err := os.CreateTemp(dir, "gitea-runner-probe-")
if err != nil {
return false, err
}
defer func() {
if err := os.Remove(marker.Name()); err != nil {
common.Logger(ctx).Warnf("removing the docker proxy probe marker failed: %v", err)
}
}()
if err := marker.Close(); err != nil {
return false, err
}
images, err := cli.ImageList(ctx, client.ImageListOptions{}) images, err := cli.ImageList(ctx, client.ImageListOptions{})
if err != nil { if err != nil {
return false, err return false, err
} }
if len(images.Items) == 0 { if len(images.Items) == 0 {
return false, errors.New("no image to probe with yet") return false, errors.New("no image available for the docker proxy probe")
} }
// creating validates that a bind source exists on the daemon's side, nothing is started
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{{Type: mount.TypeBind, Source: probe, Target: "/gitea-runner-probe"}}}, HostConfig: &container.HostConfig{Mounts: []mount.Mount{
{Type: mount.TypeBind, Source: marker.Name(), Target: "/gitea-runner-probe", ReadOnly: true},
}},
}) })
if cerrdefs.IsInvalidArgument(err) { if cerrdefs.IsInvalidArgument(err) {
return false, nil return false, nil
@@ -100,24 +122,37 @@ func daemonSeesDir(ctx context.Context, dir string) (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
_, err = cli.ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true}) cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
return true, err defer cancel()
if _, err := cli.ContainerRemove(cleanupCtx, created.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); err != nil {
return false, fmt.Errorf("removing the docker proxy probe container failed: %w", err)
}
return true, nil
} }
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it. // StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) { func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
if err := os.MkdirAll(dir, 0o700); err != nil { info, err := os.Stat(daemonSocket)
return nil, err
}
digest := sha256.Sum256([]byte(job))
socket := filepath.Join(dir, hex.EncodeToString(digest[:8])+".sock")
_ = os.Remove(socket)
listener, err := net.Listen("unix", socket)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if info, err := os.Stat(daemonSocket); err == nil { if info.Mode()&os.ModeSocket == 0 {
_ = os.Chmod(socket, info.Mode().Perm()) return nil, errors.New("docker daemon path is not a Unix socket")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
instance, err := os.MkdirTemp(dir, "p-")
if err != nil {
return nil, err
}
socket := filepath.Join(instance, "docker.sock")
listener, err := net.Listen("unix", socket)
if err != nil {
return nil, errors.Join(err, os.RemoveAll(instance))
}
if err := copyDockerSocketPermissions(socket, info); err != nil {
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) {
return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket) return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket)
@@ -130,61 +165,104 @@ func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
}, },
Transport: transport, Transport: transport,
} }
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { streams, cancelStreams := context.WithCancel(context.Background())
switch { creates, cancelCreates := context.WithCancel(context.Background())
case r.Method != http.MethodPost: var admission sync.Mutex
case createPath.MatchString(r.URL.Path): var handlers sync.WaitGroup
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, ConnContext: func(ctx context.Context, conn net.Conn) context.Context {
return context.WithValue(ctx, dockerProxyConnKey{}, conn)
}, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
admission.Lock()
if streams.Err() != nil {
admission.Unlock()
http.Error(w, "docker proxy is closing", http.StatusServiceUnavailable)
return
}
handlers.Add(1)
admission.Unlock()
defer handlers.Done()
creating := r.Method == http.MethodPost && createPath.MatchString(r.URL.Path)
parent, lifetime := r.Context(), streams
if creating {
parent, lifetime = context.WithoutCancel(parent), creates
}
ctx, cancel := context.WithCancel(parent)
defer cancel()
stop := context.AfterFunc(lifetime, func() {
cancel()
if !creating {
if conn, ok := parent.Value(dockerProxyConnKey{}).(net.Conn); ok {
_ = conn.Close()
}
}
})
defer stop()
r = r.WithContext(ctx)
if creating {
r.Body = http.MaxBytesReader(w, r.Body, maxCreateBody)
if err := addLabel(r, job); err != nil { if err := addLabel(r, job); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) status := http.StatusBadRequest
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
status = http.StatusRequestEntityTooLarge
}
http.Error(w, err.Error(), status)
return return
} }
case rawStreamPath.MatchString(r.URL.Path): } else if r.Method == http.MethodPost && rawStreamPath.MatchString(r.URL.Path) {
tunnel(w, r, dial) tunnel(w, r, dial, forward)
return return
} }
forward.ServeHTTP(w, r) forward.ServeHTTP(w, r)
})} })}
go func() { _ = server.Serve(listener) }() served := make(chan struct{})
go func() {
defer close(served)
_ = server.Serve(listener)
}()
return &DockerProxy{Socket: socket, close: func(ctx context.Context) error { return &DockerProxy{Socket: socket, close: func(ctx context.Context) error {
err := removeJobResources(ctx, job) ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
_ = server.Close() defer cancel()
admission.Lock()
listenerErr := listener.Close()
cancelStreams()
admission.Unlock()
<-served
shutdownErr := server.Shutdown(ctx)
cancelCreates()
serverErr := server.Close()
handlers.Wait()
transport.CloseIdleConnections() transport.CloseIdleConnections()
_ = os.Remove(socket) return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance))
return err
}}, nil }}, nil
} }
func addLabel(r *http.Request, job string) error { func addLabel(r *http.Request, job string) error {
body, err := io.ReadAll(io.LimitReader(r.Body, maxCreateBody+1)) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
return err return err
} }
if len(body) > maxCreateBody {
return errors.New("create request too large")
}
if len(bytes.TrimSpace(body)) == 0 { if len(bytes.TrimSpace(body)) == 0 {
r.Body = io.NopCloser(bytes.NewReader(body)) body = []byte("{}")
return nil
} }
var fields map[string]json.RawMessage var fields map[string]json.RawMessage
var config struct{ Labels map[string]string }
if err := json.Unmarshal(body, &fields); err != nil { if err := json.Unmarshal(body, &fields); err != nil {
return fmt.Errorf("invalid create request: %w", err) return fmt.Errorf("invalid create request: %w", err)
} }
key := "Labels" if err := json.Unmarshal(body, &config); err != nil {
for name := range fields { return fmt.Errorf("invalid create labels: %w", err)
if strings.EqualFold(name, key) {
key = name
break
}
} }
labels := map[string]string{} if fields == nil {
if raw := fields[key]; len(raw) > 0 && string(raw) != "null" { fields = make(map[string]json.RawMessage)
if err := json.Unmarshal(raw, &labels); err != nil {
return fmt.Errorf("invalid create request: %w", err)
}
} }
labels[jobLabel] = job maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool {
if fields[key], err = json.Marshal(labels); err != nil { return strings.EqualFold(name, "Labels")
})
if config.Labels == nil {
config.Labels = make(map[string]string)
}
config.Labels[jobLabel] = job
if fields["Labels"], err = json.Marshal(config.Labels); err != nil {
return err return err
} }
if body, err = json.Marshal(fields); err != nil { if body, err = json.Marshal(fields); err != nil {
@@ -196,44 +274,92 @@ func addLabel(r *http.Request, job string) error {
return nil return nil
} }
type dockerProxyConnKey struct{}
type dockerProxyResponse struct {
response *http.Response
}
func (r dockerProxyResponse) RoundTrip(_ *http.Request) (*http.Response, error) {
return r.response, nil
}
// tunnel splices attach and exec streams, which the daemon hijacks with or without an HTTP upgrade // tunnel splices attach and exec streams, which the daemon hijacks with or without an HTTP upgrade
func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error)) { func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error), forward *httputil.ReverseProxy) {
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "connection cannot be hijacked", http.StatusInternalServerError)
return
}
upstream, err := dial(r.Context(), "", "") upstream, err := dial(r.Context(), "", "")
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway) http.Error(w, err.Error(), http.StatusBadGateway)
return return
} }
defer upstream.Close() defer upstream.Close()
stop := context.AfterFunc(r.Context(), func() { _ = upstream.Close() })
defer stop()
if err := r.Write(upstream); err != nil { if err := r.Write(upstream); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway) http.Error(w, err.Error(), http.StatusBadGateway)
return return
} }
downstream, buffered, err := hijacker.Hijack() reader := bufio.NewReader(upstream)
var response *http.Response
for {
response, err = http.ReadResponse(reader, r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if response.StatusCode >= 200 || response.StatusCode == http.StatusSwitchingProtocols {
break
}
maps.Copy(w.Header(), response.Header)
w.WriteHeader(response.StatusCode)
clear(w.Header())
_ = response.Body.Close()
}
defer func() {
_ = upstream.Close()
_ = response.Body.Close()
}()
mediaType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type"))
if response.StatusCode != http.StatusSwitchingProtocols && (response.StatusCode != http.StatusOK || mediaType != "application/vnd.docker.raw-stream") {
ordinary := *forward
ordinary.Transport = dockerProxyResponse{response: response}
ordinary.ServeHTTP(w, r)
return
}
downstream, buffered, err := http.NewResponseController(w).Hijack()
if err != nil { if err != nil {
return return
} }
defer downstream.Close() defer downstream.Close()
if _, err := io.CopyN(upstream, buffered, int64(buffered.Reader.Buffered())); err != nil { if _, err := fmt.Fprintf(buffered, "%s %s\r\n", response.Proto, response.Status); err != nil {
return return
} }
done := make(chan struct{}, 2) if err := response.Header.Write(buffered); err != nil {
return
}
if _, err := buffered.WriteString("\r\n"); err != nil {
return
}
if err := buffered.Flush(); err != nil {
return
}
done := make(chan struct{})
go func() { go func() {
_, _ = io.Copy(upstream, downstream) defer close(done)
done <- struct{}{} if _, err := io.Copy(upstream, io.MultiReader(io.LimitReader(buffered, int64(buffered.Reader.Buffered())), downstream)); err != nil { // Bypass net/http after the prefix so stdin EOF preserves output.
}() _ = upstream.Close()
go func() { } else if writer, ok := upstream.(interface{ CloseWrite() error }); ok {
_, _ = io.Copy(downstream, upstream) _ = writer.CloseWrite()
done <- struct{}{} } else {
_ = upstream.Close()
}
}() }()
_, _ = io.Copy(downstream, reader)
_ = downstream.Close()
_ = upstream.Close()
<-done <-done
} }
func removeJobResources(ctx context.Context, job string) error { func RemoveDockerJobResources(ctx context.Context, job string) error {
cli, err := GetDockerClient(ctx) cli, err := GetDockerClient(ctx)
if err != nil { if err != nil {
return err return err
@@ -246,27 +372,20 @@ func removeLabelled(ctx context.Context, cli client.APIClient, job string) error
logger := common.Logger(ctx) logger := common.Logger(ctx)
filters := make(client.Filters).Add("label", jobLabel+"="+job) filters := make(client.Filters).Add("label", jobLabel+"="+job)
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters}) containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
if err != nil { errs := []error{err}
return err
}
var errs []error
for _, c := range containers.Items { for _, c := range containers.Items {
logger.Infof("removing container %s the job left behind", strings.TrimPrefix(strings.Join(c.Names, ","), "/")) logger.Infof("removing container %s the job left behind", strings.TrimPrefix(strings.Join(c.Names, ","), "/"))
errs = append(errs, (&containerReference{cli: cli, id: c.ID}).remove()(ctx)) errs = append(errs, (&containerReference{cli: cli, id: c.ID}).remove()(ctx))
} }
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters}) networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
if err != nil { errs = append(errs, err)
return errors.Join(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{}); 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))
} }
} }
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters}) volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters})
if err != nil { errs = append(errs, err)
return errors.Join(append(errs, err)...)
}
for _, v := range volumes.Items { for _, v := range volumes.Items {
if _, err := cli.VolumeRemove(ctx, v.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) { if _, err := cli.VolumeRemove(ctx, v.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", v.Name, err)) errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", v.Name, err))
+247 -48
View File
@@ -10,18 +10,23 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"encoding/json" "errors"
"fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"runtime"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
cerrdefs "github.com/containerd/errdefs" cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network" "github.com/moby/moby/api/types/image"
"github.com/moby/moby/api/types/mount"
"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"
@@ -56,7 +61,12 @@ func daemonSocketPath(t testing.TB, cli mobyclient.APIClient) string {
} }
func TestDockerProxy(t *testing.T) { func TestDockerProxy(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix socket ownership is unavailable on Windows")
}
bodies := make(chan []byte, 8) bodies := make(chan []byte, 8)
createStarted, releaseCreate := make(chan struct{}), make(chan struct{})
streamsDone := make(chan struct{}, 2)
daemonSocket := filepath.Join(shortTempDir(t), "d.sock") daemonSocket := filepath.Join(shortTempDir(t), "d.sock")
listener, err := net.Listen("unix", daemonSocket) listener, err := net.Listen("unix", daemonSocket)
require.NoError(t, err) require.NoError(t, err)
@@ -64,16 +74,33 @@ func TestDockerProxy(t *testing.T) {
switch { switch {
case strings.HasSuffix(r.URL.Path, "/create"): case strings.HasSuffix(r.URL.Path, "/create"):
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
if r.URL.RawQuery == "wait" {
close(createStarted)
<-releaseCreate
}
bodies <- body bodies <- body
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
case strings.HasSuffix(r.URL.Path, "/start"): case r.URL.Path == "/exec/detached/start" || r.URL.Path == "/exec/error/start":
if r.URL.Path == "/exec/error/start" {
w.Header().Set("Content-Type", "application/vnd.docker.raw-stream")
w.WriteHeader(http.StatusBadRequest)
}
_, _ = w.Write([]byte("ordinary"))
case rawStreamPath.MatchString(r.URL.Path) || r.URL.Path == "/session":
_, _ = io.Copy(io.Discard, r.Body)
conn, buffered, err := w.(http.Hijacker).Hijack() conn, buffered, err := w.(http.Hijacker).Hijack()
if err != nil { if err != nil {
return return
} }
defer conn.Close() defer conn.Close()
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n")) if r.Header.Get("Upgrade") != "" {
_, _ = io.Copy(conn, buffered) _, _ = conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\nready\n"))
} else {
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\nready\n"))
}
input, _ := io.ReadAll(buffered)
_, _ = conn.Write(append([]byte("final:"), input...))
streamsDone <- struct{}{}
default: default:
w.Header().Set("Api-Version", "1.47") w.Header().Set("Api-Version", "1.47")
_, _ = w.Write([]byte("OK " + r.Method + " " + r.URL.Path)) _, _ = w.Write([]byte("OK " + r.Method + " " + r.URL.Path))
@@ -83,34 +110,69 @@ func TestDockerProxy(t *testing.T) {
t.Cleanup(func() { _ = daemon.Close() }) t.Cleanup(func() { _ = daemon.Close() })
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1") proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
require.NoError(t, err) require.NoError(t, err)
client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { t.Cleanup(func() { _ = proxy.Close(context.Background()) })
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)
}}} }}}
t.Cleanup(client.CloseIdleConnections)
dialProxy := func(t *testing.T) *net.UnixConn {
t.Helper()
conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: proxy.Socket, Net: "unix"})
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
return conn
}
startStream := func(t *testing.T, path, upgrade string) (*net.UnixConn, *bufio.Reader) {
t.Helper()
conn := dialProxy(t)
_, err := fmt.Fprintf(conn, "POST %s HTTP/1.1\r\nHost: docker\r\n%sContent-Length: 2\r\n\r\n{}input", path, upgrade)
require.NoError(t, err)
reader := bufio.NewReader(conn)
resp, err := http.ReadResponse(reader, nil)
require.NoError(t, err)
t.Cleanup(func() {
_ = conn.Close()
_ = resp.Body.Close()
})
prefix, err := reader.ReadString('\n')
require.NoError(t, err)
require.Equal(t, "ready\n", prefix)
return conn, reader
}
t.Run("labels creates", func(t *testing.T) { t.Run("labels creates", func(t *testing.T) {
for path, body := range map[string]string{ for _, testCase := range []struct{ path, body, want string }{
"/v1.47/containers/create": `{"Image":"alpine","Labels":{"own":"1"}}`, {
"/networks/create": `{"Name":"n"}`, "/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`,
"/volumes/create": `{"Name":"v","labels":null}`, `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","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", "",
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/volumes/create", "null",
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
},
{
"/volumes/create", `{"Labels":{"discard":"1"},"labels":null,"LABELS":{"own":"1"},"LABELS":{"extra":"2"}}`,
`{"Labels":{"own":"1","extra":"2","com.gitea.runner.job":"job-1"}}`,
},
} { } {
resp, err := client.Post("http://docker"+path, "application/json", strings.NewReader(body)) resp, err := client.Post("http://docker"+testCase.path, "application/json", strings.NewReader(testCase.body))
require.NoError(t, err) require.NoError(t, err)
resp.Body.Close() resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode) require.Equal(t, http.StatusCreated, resp.StatusCode)
assert.JSONEq(t, testCase.want, string(<-bodies), testCase.body)
var got map[string]json.RawMessage
require.NoError(t, json.Unmarshal(<-bodies, &got))
key := "Labels"
if _, ok := got["labels"]; ok {
key = "labels"
}
var labels map[string]string
require.NoError(t, json.Unmarshal(got[key], &labels))
assert.Equal(t, "job-1", labels[jobLabel], path)
if strings.Contains(body, "own") {
assert.Equal(t, "1", labels["own"])
assert.JSONEq(t, `"alpine"`, string(got["Image"]))
}
} }
}) })
@@ -123,40 +185,99 @@ func TestDockerProxy(t *testing.T) {
assert.Equal(t, "OK GET /v1.47/_ping", string(body)) assert.Equal(t, "OK GET /v1.47/_ping", string(body))
}) })
t.Run("tunnels raw streams", func(t *testing.T) { t.Run("tunnels raw streams through stdin EOF", func(t *testing.T) {
conn, err := net.Dial("unix", proxy.Socket) for _, stream := range []struct{ path, upgrade string }{
require.NoError(t, err) {path: "/v1.47/exec/abc/start"},
defer conn.Close() {path: "/containers/abc/attach", upgrade: "Connection: Upgrade\r\nUpgrade: tcp\r\n"},
_, err = conn.Write([]byte("POST /v1.47/exec/abc/start HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}ping\n")) } {
require.NoError(t, err) conn, reader := startStream(t, stream.path, stream.upgrade)
require.NoError(t, conn.CloseWrite())
output, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, "final:input", string(output))
<-streamsDone
}
})
t.Run("detached and error responses retain keepalive label injection", func(t *testing.T) {
conn := dialProxy(t)
reader := bufio.NewReader(conn) reader := bufio.NewReader(conn)
resp, err := http.ReadResponse(reader, nil) for endpoint, status := range map[string]int{"detached": http.StatusOK, "error": http.StatusBadRequest} {
_, err := fmt.Fprintf(conn, "POST /exec/%s/start HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}", endpoint)
require.NoError(t, err)
resp, err := http.ReadResponse(reader, nil)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
assert.Equal(t, status, resp.StatusCode)
assert.Equal(t, "ordinary", string(body))
_, err = io.WriteString(conn, "POST /volumes/create HTTP/1.1\r\nHost: docker\r\nContent-Length: 0\r\n\r\n")
require.NoError(t, err)
resp, err = http.ReadResponse(reader, nil)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode)
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
}
})
t.Run("close joins streams and admitted create", func(t *testing.T) {
t.Cleanup(func() { close(releaseCreate) })
_, raw := startStream(t, "/exec/live/start", "")
_, upgraded := startStream(t, "/session", "Connection: Upgrade\r\nUpgrade: tcp\r\n")
creator := dialProxy(t)
_, err := io.WriteString(creator, "POST /volumes/create?wait HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}")
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close() <-createStarted
assert.Equal(t, "application/vnd.docker.raw-stream", resp.Header.Get("Content-Type")) require.NoError(t, creator.CloseWrite())
echoed, err := reader.ReadString('\n') closed := make(chan error, 1)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
go func() { closed <- proxy.Close(ctx) }()
for _, reader := range []*bufio.Reader{raw, upgraded} {
_, err := reader.ReadByte()
require.ErrorIs(t, err, io.EOF)
<-streamsDone
}
select {
case err := <-closed:
t.Fatalf("Close returned before admitted create settled: %v", err)
default:
}
releaseCreate <- struct{}{}
resp, err := http.ReadResponse(bufio.NewReader(creator), nil)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "{}ping\n", echoed) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode)
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
require.NoError(t, <-closed)
}) })
} }
func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) { func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
containerFailure := errors.New("container removal failed")
listFailure := errors.New("listing failed")
volumeFailure := errors.New("volume removal failed")
ctx := context.Background() ctx := context.Background()
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1") filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
cli := &mockDockerClient{} cli := &mockDockerClient{}
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}). cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil) Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil).Once()
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil) 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{}, nil) Return(mobyclient.ContainerRemoveResult{}, containerFailure).Once()
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}). cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).Return(mobyclient.NetworkListResult{}, listFailure).Once()
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "app_default"}}}, nil)
cli.On("NetworkRemove", ctx, "n1", mock.Anything).Return(mobyclient.NetworkRemoveResult{}, nil)
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) Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once()
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, nil) cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).Once()
require.NoError(t, removeLabelled(ctx, cli, "job-1")) err := removeLabelled(ctx, cli, "job-1")
require.ErrorIs(t, err, containerFailure)
require.ErrorIs(t, err, listFailure)
require.ErrorIs(t, err, volumeFailure)
require.ErrorContains(t, err, "failed to remove container c1")
require.ErrorContains(t, err, "failed to remove volume app_data")
cli.AssertExpectations(t) cli.AssertExpectations(t)
} }
@@ -168,13 +289,14 @@ 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, dir) seen, err := daemonSeesDir(ctx, direct, 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)
job := "proxy-test-" + t.Name() job := "proxy-test-" + strconv.FormatInt(time.Now().UnixNano(), 36)
proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job) proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = proxy.Close(context.Background()) })
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket)) viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
require.NoError(t, err) require.NoError(t, err)
defer viaProxy.Close() defer viaProxy.Close()
@@ -212,6 +334,7 @@ func TestDockerProxyWithDaemon(t *testing.T) {
attached.Close() attached.Close()
require.NoError(t, proxy.Close(ctx)) require.NoError(t, proxy.Close(ctx))
require.NoError(t, RemoveDockerJobResources(ctx, job))
_, err = direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{}) _, err = direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err)) assert.True(t, cerrdefs.IsNotFound(err))
_, err = direct.NetworkInspect(ctx, net.ID, mobyclient.NetworkInspectOptions{}) _, err = direct.NetworkInspect(ctx, net.ID, mobyclient.NetworkInspectOptions{})
@@ -268,3 +391,79 @@ func BenchmarkDockerProxy(b *testing.B) {
}) })
} }
} }
type probeClient struct {
mobyclient.APIClient
create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
}
func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (mobyclient.ImageListResult, error) {
return mobyclient.ImageListResult{Items: []image.Summary{{ID: "probe-image"}}}, nil
}
func (c *probeClient) ContainerCreate(_ context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
return c.create(opts)
}
func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
return c.remove(ctx, id, opts)
}
func TestDaemonSeesDir(t *testing.T) {
dir := t.TempDir()
markers := make(map[string]bool)
for _, testCase := range []struct {
name string
private bool
removeErr error
}{
{name: "cleanup after cancellation"},
{name: "private filesystem with stale marker", private: true},
{name: "cleanup error after cancellation", removeErr: errors.New("cleanup failed")},
} {
t.Run(testCase.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
daemonDir := dir
if testCase.private {
daemonDir = t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(daemonDir, "probe"), []byte("stale"), 0o600))
}
removed := false
cli := &probeClient{
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
require.Len(t, opts.HostConfig.Mounts, 1)
marker := opts.HostConfig.Mounts[0].Source
assert.Equal(t, mount.Mount{Type: mount.TypeBind, Source: marker, Target: "/gitea-runner-probe", ReadOnly: true}, opts.HostConfig.Mounts[0])
assert.False(t, markers[marker])
markers[marker] = true
info, err := os.Stat(marker)
require.NoError(t, err)
assert.True(t, info.Mode().IsRegular())
cancel()
if _, err := os.Stat(filepath.Join(daemonDir, filepath.Base(marker))); errors.Is(err, os.ErrNotExist) {
return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument
}
return mobyclient.ContainerCreateResult{ID: "probe"}, nil
},
remove: func(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
removed = true
assert.Equal(t, "probe", id)
assert.Equal(t, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}, opts)
require.NoError(t, ctx.Err())
_, bounded := ctx.Deadline()
assert.True(t, bounded)
return mobyclient.ContainerRemoveResult{}, testCase.removeErr
},
}
seen, err := daemonSeesDir(ctx, cli, dir)
require.ErrorIs(t, err, testCase.removeErr)
assert.Equal(t, !testCase.private && testCase.removeErr == nil, seen)
assert.Equal(t, !testCase.private, removed)
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Empty(t, entries)
})
}
}
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
package container
import (
"errors"
"os"
"path/filepath"
"syscall"
)
func copyDockerSocketPermissions(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
}
if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
return err
}
return os.Chmod(socket, info.Mode().Perm())
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !WITHOUT_DOCKER
package container
import (
"errors"
"os"
)
func copyDockerSocketPermissions(_ string, _ os.FileInfo) error {
return errors.New("docker socket ownership cannot be preserved on Windows")
}
+13 -21
View File
@@ -488,14 +488,11 @@ func (cr *containerReference) remove() common.Executor {
RemoveVolumes: true, RemoveVolumes: true,
Force: true, Force: true,
}) })
switch { if cerrdefs.IsConflict(err) {
case cerrdefs.IsConflict(err): err = cr.waitForRemoval(ctx, idOrName)
// the daemon's own AutoRemove teardown is running, and it releases the volume }
// references and the network endpoint only once it finishes if err != nil && !cerrdefs.IsNotFound(err) {
cr.waitForRemoval(ctx, idOrName) return fmt.Errorf("failed to remove container %s: %w", idOrName, err)
case err != nil && !cerrdefs.IsNotFound(err):
logger.Error(fmt.Errorf("failed to remove container %s: %w", idOrName, err))
return nil // keep the id, the container is still there for a later Remove()
} }
logger.Debugf("Removed container: %v", idOrName) logger.Debugf("Removed container: %v", idOrName)
@@ -504,7 +501,7 @@ func (cr *containerReference) remove() common.Executor {
} }
} }
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) { func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) error {
// per container, against the one minute the post-job executor allows for the whole // per container, against the one minute the post-job executor allows for the whole
// cleanup, so a job with several services can spend most of that budget here // cleanup, so a job with several services can spend most of that budget here
ctx, cancel := context.WithTimeout(ctx, 15*time.Second) ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
@@ -514,18 +511,13 @@ func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName strin
Condition: container.WaitConditionRemoved, Condition: container.WaitConditionRemoved,
}) })
select { select {
case <-waitResult.Result: case result := <-waitResult.Result:
case <-waitResult.Error: if result.Error != nil {
case <-ctx.Done(): return errors.New(result.Error.Message)
// the client delivers the result over an unbuffered channel, so leave a receiver }
// behind or its goroutine parks on the send for the lifetime of the process return nil
go func() { case err := <-waitResult.Error:
select { return err
case <-waitResult.Result:
case <-waitResult.Error:
}
}()
common.Logger(ctx).Warnf("Timed out waiting for the daemon to remove container %s, its volumes and network may be left behind", idOrName)
} }
} }
+19 -12
View File
@@ -345,18 +345,22 @@ func TestDockerWaitFailure(t *testing.T) {
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not // A remove that raced the daemon's AutoRemove teardown is not a failure and must not
// be logged as one. // be logged as one.
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) { func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
removeFailure := errors.New("driver failed to remove root filesystem")
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true} removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"} killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
for _, tc := range []struct { for _, tc := range []struct {
name string name string
err error err error
wantWait bool wantWait bool
wantFailure bool waitErr error
wantErr error
}{ }{
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true}, {name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
{name: "wait canceled", err: cerrdefs.ErrConflict, wantWait: true, waitErr: context.Canceled, wantErr: context.Canceled},
{name: "removed during wait", err: cerrdefs.ErrConflict, wantWait: true, waitErr: cerrdefs.ErrNotFound},
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")}, {name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
{name: "removed cleanly", err: nil}, {name: "removed cleanly", err: nil},
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true}, {name: "real failure", err: removeFailure, wantErr: removeFailure},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
logger, hook := test.NewNullLogger() logger, hook := test.NewNullLogger()
@@ -366,21 +370,24 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err) client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
if tc.wantWait { if tc.wantWait {
removed := make(chan container.WaitResponse, 1) removed := make(chan container.WaitResponse, 1)
removed <- container.WaitResponse{} waitErrors := make(chan error, 1)
if tc.waitErr == nil {
removed <- container.WaitResponse{}
} else {
waitErrors <- tc.waitErr
}
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}). client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
Return(mobyclient.ContainerWaitResult{Result: removed}) Return(mobyclient.ContainerWaitResult{Result: removed, Error: waitErrors})
} }
cr := &containerReference{id: "abc", cli: client} cr := &containerReference{id: "abc", cli: client}
require.NoError(t, cr.remove()(ctx)) require.ErrorIs(t, cr.remove()(ctx), tc.wantErr)
// a failure keeps the id, so a later Remove() can retry it if tc.wantErr != nil {
if tc.wantFailure {
assert.Equal(t, "abc", cr.id) assert.Equal(t, "abc", cr.id)
assert.Len(t, hook.AllEntries(), 1)
} else { } else {
assert.Empty(t, cr.id) assert.Empty(t, cr.id)
assert.Empty(t, hook.AllEntries())
} }
assert.Empty(t, hook.AllEntries())
client.AssertExpectations(t) client.AssertExpectations(t)
}) })
} }
+6 -2
View File
@@ -29,8 +29,12 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
return false, errors.New("Unsupported Operation") return false, errors.New("Unsupported Operation")
} }
func DockerProxyDir(ctx context.Context) string { func RemoveDockerJobResources(_ context.Context, _ string) error {
return "" return nil
}
func NewDockerProxy(_ context.Context, _ string) *DockerProxy {
return nil
} }
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) { func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
package runner
import (
"context"
"crypto/rand"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.dev/actionslib/pkg/model"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/client"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDockerProxyMountedJob(t *testing.T) {
mode := os.Getenv("ACT_TEST_DOCKER_PROXY")
if mode == "" {
t.Skip("set ACT_TEST_DOCKER_PROXY=proxy or direct to verify mounted Docker access")
}
require.Contains(t, []string{"proxy", "direct"}, mode)
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
defer cancel()
dockerClient, err := container.GetDockerClient(ctx)
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, dockerClient.Close()) })
require.True(t, strings.HasPrefix(dockerClient.DaemonHost(), "unix://"), "mounted Docker coverage requires a Unix daemon socket")
fixtureDir, err := filepath.Abs("testdata/docker-proxy")
require.NoError(t, err)
resourceName := "gitea-proxy-test-" + strings.ToLower(rand.Text())
runner, err := New(&Config{
Workdir: fixtureDir,
ActionCacheDir: t.TempDir(),
EventName: "push",
PlatformPicker: mapPlatformPicker(platforms),
ContainerNamePrefix: resourceName,
ContainerDaemonSocket: dockerClient.DaemonHost(),
ContainerMaxLifetime: 2 * time.Minute,
Env: map[string]string{"PROXY_TEST_RESOURCE": resourceName, "PROXY_TEST_MODE": mode},
})
require.NoError(t, err)
planner, err := model.NewWorkflowPlanner(filepath.Join(fixtureDir, "push.yml"), true)
require.NoError(t, err)
plan, err := planner.PlanEvent("push")
require.NoError(t, err)
runContext, err := runner.newRunContext(ctx, plan.Stages[0].Runs[0], nil)
require.NoError(t, err)
jobName := runContext.jobContainerName()
jobNetwork, _ := runContext.networkNameForGitea()
t.Cleanup(func() {
cleanCtx, cleanCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cleanCancel()
if _, err := dockerClient.ContainerRemove(cleanCtx, jobName, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}); !cerrdefs.IsNotFound(err) {
assert.NoError(t, err)
}
for _, name := range []string{resourceName, jobNetwork} {
if _, err := dockerClient.NetworkRemove(cleanCtx, name, client.NetworkRemoveOptions{}); !cerrdefs.IsNotFound(err) {
assert.NoError(t, err)
}
}
for _, name := range []string{resourceName, jobName, jobName + "-env"} {
if _, err := dockerClient.VolumeRemove(cleanCtx, name, client.VolumeRemoveOptions{}); !cerrdefs.IsNotFound(err) {
assert.NoError(t, err)
}
}
})
hook := &test.Hook{}
require.NoError(t, runner.NewPlanExecutor(plan)(common.WithLoggerHook(ctx, hook)))
var messages []string
for _, entry := range hook.AllEntries() {
messages = append(messages, strings.TrimSpace(entry.Message))
}
require.Contains(t, messages, "docker proxy post 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{})
if mode == "proxy" {
assert.True(t, cerrdefs.IsNotFound(err), "labelled network survived cleanup: %v", err)
} else {
require.NoError(t, err)
}
_, err = dockerClient.VolumeInspect(ctx, resourceName, client.VolumeInspectOptions{})
if mode == "proxy" {
assert.True(t, cerrdefs.IsNotFound(err), "labelled volume survived cleanup: %v", err)
} else {
require.NoError(t, err)
}
}
+7 -6
View File
@@ -263,18 +263,19 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
return err return err
}) })
stepsExecutor := newStepsExecutor(rc, preSteps, steps) return common.Executor(func(ctx context.Context) error {
if err := info.startContainer()(ctx); err != nil {
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor. return err
Finally(func(ctx context.Context) error { }
return newStepsExecutor(rc, preSteps, steps).Finally(func(ctx context.Context) error {
// Record an interrupt (backstop for interrupts that land outside the main // Record an interrupt (backstop for interrupts that land outside the main
// step loop) so the post steps observe the cancelled/failed job status. // step loop) so the post steps observe the cancelled/failed job status.
rc.markInterrupted(ctx.Err()) rc.markInterrupted(ctx.Err())
postCtx, cancel := postStepsContext(ctx) postCtx, cancel := postStepsContext(ctx)
defer cancel() defer cancel()
return postExecutor(postCtx) return postExecutor(postCtx)
}). })(ctx)
Finally(info.closeContainer())) }).Finally(info.closeContainer())
} }
// postStepsContext derives the context used to run the job's post/cleanup steps from the // postStepsContext derives the context used to run the job's post/cleanup steps from the
+38 -5
View File
@@ -300,6 +300,8 @@ func TestNewJobExecutor(t *testing.T) {
result string result string
hasError bool hasError bool
output string output string
startError error
cancelOnStart bool
}{ }{
{ {
name: "zeroSteps", name: "zeroSteps",
@@ -435,6 +437,21 @@ func TestNewJobExecutor(t *testing.T) {
result: "failure", result: "failure",
output: "${{ 'test' != test }}", output: "${{ 'test' != test }}",
}, },
{
name: "start failure",
steps: []*model.Step{{ID: "1"}},
executedSteps: []string{"startContainer", "closeContainer"},
startError: errors.New("start failed"),
},
{
name: "cancelled at startup boundary",
steps: []*model.Step{{ID: "1"}},
preSteps: []bool{false},
postSteps: []bool{true},
executedSteps: []string{"startContainer", "step1", "post1", "interpolateOutputs", "stopContainer", "closeContainer"},
result: "cancelled",
cancelOnStart: true,
},
} }
contains := func(needle string, haystack []string) bool { contains := func(needle string, haystack []string) bool {
@@ -445,7 +462,8 @@ func TestNewJobExecutor(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act
ctx := common.WithJobErrorContainer(context.Background()) ctx, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
defer cancel()
jim := &jobInfoMock{} jim := &jobInfoMock{}
sfm := &stepFactoryMock{} sfm := &stepFactoryMock{}
rc := &RunContext{ rc := &RunContext{
@@ -470,9 +488,12 @@ func TestNewJobExecutor(t *testing.T) {
jim.On("steps").Return(tt.steps) jim.On("steps").Return(tt.steps)
if len(tt.steps) > 0 { if len(tt.steps) > 0 {
jim.On("startContainer").Return(func(ctx context.Context) error { jim.On("startContainer").Return(func(_ context.Context) error {
executorOrder = append(executorOrder, "startContainer") executorOrder = append(executorOrder, "startContainer")
return nil if tt.cancelOnStart {
cancel()
}
return tt.startError
}) })
} }
@@ -506,7 +527,7 @@ func TestNewJobExecutor(t *testing.T) {
defer sm.AssertExpectations(t) defer sm.AssertExpectations(t)
} }
if len(tt.steps) > 0 { if len(tt.steps) > 0 && tt.startError == nil {
jim.On("matrix").Return(map[string]any{}) jim.On("matrix").Return(map[string]any{})
jim.On("interpolateOutputs").Return(func(ctx context.Context) error { jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
@@ -520,12 +541,17 @@ func TestNewJobExecutor(t *testing.T) {
if contains("stopContainer", tt.executedSteps) { if contains("stopContainer", tt.executedSteps) {
jim.On("stopContainer").Return(func(ctx context.Context) error { jim.On("stopContainer").Return(func(ctx context.Context) error {
executorOrder = append(executorOrder, "stopContainer") executorOrder = append(executorOrder, "stopContainer")
require.NoError(t, ctx.Err())
_, bounded := ctx.Deadline()
require.True(t, bounded)
return nil return nil
}) })
} }
jim.On("result", tt.result) jim.On("result", tt.result)
}
if len(tt.steps) > 0 {
jim.On("closeContainer").Return(func(ctx context.Context) error { jim.On("closeContainer").Return(func(ctx context.Context) error {
executorOrder = append(executorOrder, "closeContainer") executorOrder = append(executorOrder, "closeContainer")
return nil return nil
@@ -534,7 +560,14 @@ func TestNewJobExecutor(t *testing.T) {
executor := newJobExecutor(jim, sfm, rc) executor := newJobExecutor(jim, sfm, rc)
err := executor(ctx) err := executor(ctx)
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act switch {
case tt.startError != nil:
require.ErrorIs(t, err, tt.startError)
case tt.cancelOnStart:
require.ErrorIs(t, err, context.Canceled)
default:
require.NoError(t, err)
}
assert.Empty(t, rc.Run.Job().Outputs["bad"]) assert.Empty(t, rc.Run.Job().Outputs["bad"])
assert.Equal(t, tt.executedSteps, executorOrder) assert.Equal(t, tt.executedSteps, executorOrder)
+55 -64
View File
@@ -270,20 +270,10 @@ func (rc *RunContext) jobDockerSocket() string {
} }
func (rc *RunContext) startDockerProxy(ctx context.Context) { func (rc *RunContext) startDockerProxy(ctx context.Context) {
daemonSocket := rc.containerDaemonSocket() if !filepath.IsAbs(strings.TrimPrefix(rc.containerDaemonSocket(), "unix://")) || common.Dryrun(ctx) {
if daemonSocket == "-" || strings.HasPrefix(strings.ToLower(daemonSocket), "npipe://") {
return return
} }
dir := container.DockerProxyDir(ctx) rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName())
if dir == "" {
return
}
proxy, err := container.StartDockerProxy(getDockerDaemonSocketMountPath(daemonSocket), dir, rc.jobContainerName())
if err != nil {
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
return
}
rc.dockerProxy = proxy
} }
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE. // toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
@@ -483,23 +473,17 @@ func (rc *RunContext) startJobContainer() common.Executor {
// For gitea, to support --volumes-from <container_name_or_id> in options. // For gitea, to support --volumes-from <container_name_or_id> in options.
// We need to set the container name to the environment variable. // We need to set the container name to the environment variable.
rc.Env["JOB_CONTAINER_NAME"] = name rc.Env["JOB_CONTAINER_NAME"] = name
rc.startDockerProxy(ctx)
envList := make([]string, 0) envList := make([]string, 0)
envList = append(envList, rc.runnerEnv(ctx)...) envList = append(envList, rc.runnerEnv(ctx)...)
envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions envList = append(envList, fmt.Sprintf("%s=%s", "LANG", "C.UTF-8")) // Use same locale as GitHub Actions
ext := container.LinuxContainerEnvironmentExtensions{}
binds, mounts, err := rc.GetBindsAndMounts()
if err != nil {
return err
}
// specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>) // specify the network to which the container will connect when `docker create` stage. (like execute command line: docker create --network <networkName> <image>)
// if using service containers, will create a new network for the containers. // if using service containers, will create a new network for the containers.
// and it will be removed after at last. // and it will be removed after at last.
networkName, createAndDeleteNetwork := rc.networkNameForGitea() networkName, createAndDeleteNetwork := rc.networkNameForGitea()
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
// add service containers // add service containers
for serviceID, spec := range rc.Run.Job().Services { for serviceID, spec := range rc.Run.Job().Services {
@@ -590,8 +574,6 @@ func (rc *RunContext) startJobContainer() common.Executor {
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: serviceImage, container: c}) rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: serviceImage, container: c})
} }
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
// For Gitea, `jobContainerNetwork` should be the same as `networkName` // For Gitea, `jobContainerNetwork` should be the same as `networkName`
jobContainerNetwork := networkName jobContainerNetwork := networkName
@@ -599,7 +581,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
if err != nil { if err != nil {
return err return err
} }
rc.JobContainer = newContainer(&container.NewContainerInput{ ext := container.LinuxContainerEnvironmentExtensions{}
containerInput := &container.NewContainerInput{
Cmd: nil, Cmd: nil,
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())}, Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
WorkingDir: ext.ToContainerPath(rc.Config.Workdir), WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
@@ -608,10 +591,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
Password: password, Password: password,
Name: name, Name: name,
Env: envList, Env: envList,
Mounts: mounts,
NetworkMode: jobContainerNetwork, NetworkMode: jobContainerNetwork,
NetworkAliases: []string{rc.Name}, NetworkAliases: []string{rc.Name},
Binds: binds,
Stdout: logWriter, Stdout: logWriter,
Stderr: logWriter, Stderr: logWriter,
Privileged: rc.Config.Privileged, Privileged: rc.Config.Privileged,
@@ -620,20 +601,29 @@ func (rc *RunContext) startJobContainer() common.Executor {
RunnerOptions: rc.Config.ContainerOptions, RunnerOptions: rc.Config.ContainerOptions,
WorkflowOptions: workflowOptions, WorkflowOptions: workflowOptions,
AutoRemove: true, AutoRemove: true,
ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY, AllocatePTY: rc.Config.AllocatePTY,
}) }
rc.JobContainer = newContainer(containerInput)
if rc.JobContainer == nil { if rc.JobContainer == nil {
return errors.New("failed to create job container") return errors.New("failed to create job container")
} }
defer printStartJobContainerGroup(ctx, image, name, networkName)()
if err := common.NewPipelineExecutor(
rc.stopJobContainer(),
rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull),
).Finally(rc.closeContainer())(ctx); err != nil {
return err
}
rc.startDockerProxy(ctx)
if containerInput.Binds, containerInput.Mounts, err = rc.GetBindsAndMounts(); err != nil {
return err
}
containerInput.ValidVolumes = rc.validVolumes()
rc.jobNetworkName = networkName rc.jobNetworkName = networkName
defer printStartJobContainerGroup(ctx, image, name, networkName)()
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull),
rc.stopJobContainer(),
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions). container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
IfBool(createAndDeleteNetwork), IfBool(createAndDeleteNetwork),
rc.startServiceContainers(), rc.startServiceContainers(),
@@ -663,47 +653,46 @@ func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
}) })
} }
// cleanupJobResources removes everything the job created, continuing past failures.
// Only job container and volume errors are returned, the rest are logged.
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor { func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork bool) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
logger := common.Logger(ctx) logger := common.Logger(ctx)
removeJobContainer := rc.JobContainer != nil errs := []error{rc.closeDockerProxy(ctx)}
if rc.JobContainer != nil {
var errs []error
if removeJobContainer {
errs = append(errs, rc.JobContainer.Remove()(ctx)) errs = append(errs, rc.JobContainer.Remove()(ctx))
} }
if len(rc.serviceContainers) > 0 { if len(rc.serviceContainers) > 0 {
logger.Infof("Cleaning up services for job %s", rc.JobName) logger.Infof("Cleaning up services for job %s", rc.JobName)
if err := rc.stopServiceContainers()(ctx); err != nil { errs = append(errs, rc.stopServiceContainers()(ctx))
logger.Errorf("Error while cleaning services: %v", err)
}
} }
if rc.dockerProxy != nil { if !common.Dryrun(ctx) {
if err := rc.dockerProxy.Close(ctx); err != nil { errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName()))
logger.Errorf("Error while removing what the job created: %v", err)
}
rc.dockerProxy = nil
} }
if removeJobContainer { if rc.JobContainer != nil {
// after the containers using them, services can hold these via `--volumes-from`
name := rc.jobContainerName() name := rc.jobContainerName()
errs = append(errs, errs = append(errs,
container.NewDockerVolumeRemoveExecutor(name, false)(ctx), container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx)) container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
} }
if createAndDeleteNetwork { if createAndDeleteNetwork {
// last, once every container has detached
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName) logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil { errs = append(errs, container.NewDockerNetworkRemoveExecutor(networkName)(ctx))
logger.Errorf("Error while cleaning network: %v", err)
}
} }
return errors.Join(errs...) return errors.Join(errs...)
} }
} }
func (rc *RunContext) closeDockerProxy(ctx context.Context) error {
if rc.dockerProxy == nil {
return nil
}
err := rc.dockerProxy.Close(ctx)
rc.dockerProxy = nil
if err != nil {
return fmt.Errorf("close docker proxy: %w", err)
}
return nil
}
func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string) { func (rc *RunContext) ApplyExtraPath(ctx context.Context, env *map[string]string) {
if len(rc.ExtraPath) > 0 { if len(rc.ExtraPath) > 0 {
path := rc.JobContainer.GetPathVariableName() path := rc.JobContainer.GetPathVariableName()
@@ -763,7 +752,6 @@ func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string)
return nil return nil
} }
// stopJobContainer removes the job container (if it exists) and its volume (if it exists)
func (rc *RunContext) stopJobContainer() common.Executor { func (rc *RunContext) stopJobContainer() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if rc.cleanUpJobContainer != nil { if rc.cleanUpJobContainer != nil {
@@ -800,10 +788,17 @@ func (rc *RunContext) startServiceContainers() common.Executor {
func (rc *RunContext) stopServiceContainers() common.Executor { func (rc *RunContext) stopServiceContainers() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
execs := []common.Executor{} execs := []common.Executor{}
for _, svc := range rc.serviceContainers { errs := make([]error, len(rc.serviceContainers))
execs = append(execs, svc.container.Remove().Finally(svc.container.Close())) for index, svc := range rc.serviceContainers {
execs = append(execs, func(ctx context.Context) error {
if err := errors.Join(svc.container.Remove()(ctx), svc.container.Close()(ctx)); err != nil {
errs[index] = fmt.Errorf("clean service %s: %w", svc.name, err)
}
return nil
})
} }
return common.NewParallelExecutor(len(execs), execs...)(ctx) errs = append(errs, common.NewParallelExecutor(len(execs), execs...)(ctx))
return errors.Join(errs...)
} }
} }
@@ -1033,17 +1028,13 @@ func (rc *RunContext) startContainer() common.Executor {
} }
func (rc *RunContext) cleanupFailedStart(ctx context.Context) { func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
if rc.cleanUpJobContainer == nil { cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
return defer cancel()
cleanup := rc.cleanUpJobContainer
if cleanup == nil {
cleanup = rc.closeDockerProxy
} }
cleanCtx := ctx if err := cleanup(cleanCtx); err != nil {
if ctx.Err() != nil {
// the start likely failed because ctx was cancelled, detach so teardown still runs
var cancel context.CancelFunc
cleanCtx, cancel = context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
defer cancel()
}
if err := rc.cleanUpJobContainer(cleanCtx); err != nil {
common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err) common.Logger(ctx).Errorf("Error while cleaning up after failed container start for job %s: %v", rc.JobName, err)
} }
} }
+46 -13
View File
@@ -10,6 +10,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http"
"net/http/httptest"
"os" "os"
"runtime" "runtime"
"strings" "strings"
@@ -221,8 +223,9 @@ func (fakeContainer) Start(bool) common.Executor { return func(context.Context)
func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } } func (fakeContainer) Remove() common.Executor { return func(context.Context) error { return nil } }
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } } func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
func (fakeContainer) GetActPath() string { return "/var/run/act" } func (fakeContainer) GetActPath() string { return "/var/run/act" }
func (fakeContainer) ToContainerPath(path string) string { return path }
func (fakeContainer) Create([]string, []string) common.Executor { func (fakeContainer) Create([]string, []string) common.Executor {
return func(context.Context) error { return nil } return func(context.Context) error { return nil }
} }
@@ -269,9 +272,26 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
require.NoError(t, rc.resolvePlatformImage(t.Context())) require.NoError(t, rc.resolvePlatformImage(t.Context()))
// the inputs are built before the missing daemon fails the first call daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock") w.Header().Set("Content-Type", "application/json")
require.Error(t, rc.startJobContainer()(t.Context())) switch {
case strings.HasSuffix(r.URL.Path, "/_ping"):
w.Header().Set("API-Version", "1.47")
_, _ = io.WriteString(w, "OK")
case strings.HasSuffix(r.URL.Path, "/containers/json"), strings.HasSuffix(r.URL.Path, "/networks"):
_, _ = io.WriteString(w, "[]")
case strings.HasSuffix(r.URL.Path, "/volumes"):
_, _ = io.WriteString(w, `{"Volumes":[]}`)
case strings.HasSuffix(r.URL.Path, "/info"):
_, _ = io.WriteString(w, `{"Architecture":"amd64","OSType":"linux"}`)
default:
t.Errorf("unexpected Docker request: %s %s", r.Method, r.URL)
http.NotFound(w, r)
}
}))
t.Cleanup(daemon.Close)
t.Setenv("DOCKER_HOST", daemon.URL)
require.NoError(t, rc.startJobContainer()(t.Context()))
return inputs return inputs
} }
@@ -627,7 +647,7 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
serviceContainers: []*serviceContainer{{name: "svc", container: service}}, serviceContainers: []*serviceContainer{{name: "svc", container: service}},
} }
err := rc.cleanupJobResources("external-network", false)(context.Background()) err := rc.cleanupJobResources("external-network", false)(common.WithDryrun(t.Context(), true))
require.NoError(t, err) require.NoError(t, err)
service.AssertExpectations(t) service.AssertExpectations(t)
} }
@@ -636,11 +656,12 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) { func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock") t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
removeError, closeError := errors.New("remove service"), errors.New("close service")
jobContainer := &containerMock{} jobContainer := &containerMock{}
jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once() jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once()
service := &containerMock{} service := &containerMock{}
service.On("Remove").Return(func(context.Context) error { return nil }).Once() service.On("Remove").Return(func(context.Context) error { return removeError }).Once()
service.On("Close").Return(func(context.Context) error { return nil }).Once() service.On("Close").Return(func(context.Context) error { return closeError }).Once()
rc := &RunContext{ rc := &RunContext{
Name: "job", Name: "job",
@@ -652,7 +673,11 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
require.Error(t, rc.cleanupJobResources("job-network", true)(ctx)) err := rc.cleanupJobResources("job-network", true)(ctx)
require.ErrorContains(t, err, "removal failed")
require.ErrorIs(t, err, removeError)
require.ErrorIs(t, err, closeError)
require.ErrorIs(t, err, context.Canceled)
jobContainer.AssertExpectations(t) jobContainer.AssertExpectations(t)
service.AssertExpectations(t) service.AssertExpectations(t)
} }
@@ -1069,12 +1094,19 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
calls int calls int
err error err error
sentinel any sentinel any
cancel context.CancelFunc
} }
newRC := func(c *capture) *RunContext { newRC := func(c *capture) *RunContext {
return &RunContext{ return &RunContext{
JobName: "job", JobName: "job",
cleanUpJobContainer: func(ctx context.Context) error { cleanUpJobContainer: func(ctx context.Context) error {
c.calls++ c.calls++
if c.cancel != nil {
c.cancel()
}
deadline, ok := ctx.Deadline()
require.True(t, ok)
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, time.Second)
c.err = ctx.Err() c.err = ctx.Err()
c.sentinel = ctx.Value(sentinel) c.sentinel = ctx.Value(sentinel)
return nil return nil
@@ -1082,9 +1114,10 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
} }
} }
t.Run("runs teardown on the live context", func(t *testing.T) { t.Run("detaches teardown from cancellation during cleanup", func(t *testing.T) {
var c capture ctx, cancel := context.WithCancel(context.WithValue(context.Background(), sentinel, "v"))
ctx := context.WithValue(context.Background(), sentinel, "v") defer cancel()
c := capture{cancel: cancel}
newRC(&c).cleanupFailedStart(ctx) newRC(&c).cleanupFailedStart(ctx)
@@ -1102,7 +1135,7 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
assert.Equal(t, 1, c.calls) assert.Equal(t, 1, c.calls)
require.NoError(t, c.err) require.NoError(t, c.err)
assert.Nil(t, c.sentinel) assert.Equal(t, "v", c.sentinel)
}) })
t.Run("no-op when there is nothing to clean up", func(t *testing.T) { t.Run("no-op when there is nothing to clean up", func(t *testing.T) {
+6
View File
@@ -0,0 +1,6 @@
name: docker-proxy
description: Verify mounted Docker access through post steps
runs:
using: node24
main: index.js
post: index.js
+53
View File
@@ -0,0 +1,53 @@
const assert = require('node:assert/strict');
const {once} = require('node:events');
const fs = require('node:fs');
const http = require('node:http');
async function request(method, path, body) {
const req = http.request({
socketPath: '/var/run/docker.sock',
method,
path,
headers: {'Content-Type': 'application/json'},
signal: AbortSignal.timeout(10000),
});
req.end(JSON.stringify(body));
const [res] = await once(req, 'response');
req.on('error', (error) => res.destroy(error));
let data = '';
for await (const chunk of res.setEncoding('utf8')) {
data += chunk;
}
assert(res.statusCode >= 200 && res.statusCode < 300, `${method} ${path}: ${res.statusCode} ${data}`);
return data;
}
async function main() {
assert(fs.statSync('/var/run/docker.sock').isSocket(), 'Docker mount must be a socket');
assert.equal(await request('GET', '/_ping'), 'OK');
const api = `/v${JSON.parse(await request('GET', '/version')).ApiVersion}`;
const name = process.env.PROXY_TEST_RESOURCE;
const job = process.env.JOB_CONTAINER_NAME;
const post = process.env.STATE_post === 'true';
assert(name);
assert(job);
if (!post) {
await request('POST', `${api}/volumes/create`, {Name: name});
await request('POST', `${api}/networks/create`, {Name: name});
await request('POST', `${api}/networks/${name}/connect`, {Container: job});
fs.appendFileSync(process.env.GITHUB_STATE, 'post=true\n');
}
const label = process.env.PROXY_TEST_MODE === 'proxy' ? job : undefined;
assert.equal(JSON.parse(await request('GET', `${api}/volumes/${name}`)).Labels?.['com.gitea.runner.job'], label);
const network = JSON.parse(await request('GET', `${api}/networks/${name}`));
assert.equal(network.Labels?.['com.gitea.runner.job'], label);
assert(Object.values(network.Containers).some((container) => container.Name === job));
if (post) {
console.log('docker proxy post verified');
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+8
View File
@@ -0,0 +1,8 @@
name: docker-proxy
on: push
jobs:
proxy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./action
+56 -9
View File
@@ -9,10 +9,8 @@
# #
# Usage: scripts/test-dind.sh [target] [-- go-test-args...] # Usage: scripts/test-dind.sh [target] [-- go-test-args...]
# target: dind (default) or dind-rootless # target: dind (default) or dind-rootless
# go-test-args: passed verbatim to `go test`. The default exercises the daemon-facing tests # go-test-args: passed verbatim to `go test`. Defaults cover image env extraction,
# that need no registry access (a fresh daemon, e.g. on fork-PR CI, can't # symlink copying and a mounted Docker job using cached images.
# authenticate pulls): the env-extraction build (FROM scratch) and the #981
# /var/run symlink copy regression (which reuses a preloaded alpine).
# #
# Env: # Env:
# DIND_TEST_PORT host port for the daemon (default 32375) # DIND_TEST_PORT host port for the daemon (default 32375)
@@ -25,15 +23,25 @@ case "${1:-}" in
dind|dind-rootless) target="$1"; shift ;; dind|dind-rootless) target="$1"; shift ;;
esac esac
[ "${1:-}" = "--" ] && shift [ "${1:-}" = "--" ] && shift
[ $# -eq 0 ] && set -- -race -run '^TestDocker$|^TestDockerCopyToSymlinkPath$' ./act/container/ default_tests=false
if [ $# -eq 0 ]; then
default_tests=true
set -- -count=1 -race -run '^TestDocker$|^TestDockerCopyToSymlinkPath$' ./act/container/
fi
port="${DIND_TEST_PORT:-32375}" port="${DIND_TEST_PORT:-32375}"
name="gitea-runner-dind-test-$$" name="gitea-runner-dind-test-$$"
image="${DIND_TEST_IMAGE:-gitea-runner-${target}:dind-test}" image="${DIND_TEST_IMAGE:-gitea-runner-${target}:dind-test}"
# The host daemon endpoint, captured before DOCKER_HOST is pointed at the fresh dind daemon. # The host daemon endpoint, captured before DOCKER_HOST is pointed at the fresh dind daemon.
host_docker="${DOCKER_HOST:-unix:///var/run/docker.sock}" host_docker="${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}"
test_dir=""
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; } cleanup() {
docker -H "$host_docker" rm -fv "$name" >/dev/null 2>&1 || true
if [ -n "$test_dir" ]; then
rm -rf "$test_dir"
fi
}
trap cleanup EXIT trap cleanup EXIT
if [ -z "${DIND_TEST_IMAGE:-}" ]; then if [ -z "${DIND_TEST_IMAGE:-}" ]; then
@@ -71,7 +79,7 @@ else
fi fi
# Create the dind container on the host daemon first, then repoint DOCKER_HOST at it: exporting # Create the dind container on the host daemon first, then repoint DOCKER_HOST at it: exporting
# DOCKER_HOST before `docker run` would make this `docker run` target the not-yet-existent dind. # DOCKER_HOST before `docker run` would make this `docker run` target the not-yet-existent dind.
docker run -d --privileged --name "$name" "${run_args[@]}" \ docker -H "$host_docker" run -d --privileged --name "$name" "${run_args[@]}" \
-e DOCKER_TLS_CERTDIR= \ -e DOCKER_TLS_CERTDIR= \
--entrypoint dockerd-entrypoint.sh \ --entrypoint dockerd-entrypoint.sh \
"$image" --host=tcp://0.0.0.0:2375 >/dev/null "$image" --host=tcp://0.0.0.0:2375 >/dev/null
@@ -82,11 +90,20 @@ for _ in $(seq 1 60); do
docker version --format 'server docker {{.Server.Version}}' 2>/dev/null && break docker version --format 'server docker {{.Server.Version}}' 2>/dev/null && break
sleep 1 sleep 1
done done
if ! docker version --format 'server docker {{.Server.Version}}'; then
docker -H "$host_docker" logs "$name" >&2
exit 1
fi
# Seed the fresh daemon with images the host already has (the CI job pulls them in the # Seed the fresh daemon with images the host already has (the CI job pulls them in the
# preceding `make test`), so the daemon-facing tests run without registry access. # preceding `make test`), so the daemon-facing tests run without registry access.
echo "==> Seeding daemon with cached host images" echo "==> Seeding daemon with cached host images"
for img in ${DIND_TEST_PRELOAD:-alpine:latest}; do preload="${DIND_TEST_PRELOAD:-alpine:latest}"
job_image="${ACT_TEST_IMAGE:-node:24-bookworm-slim}"
if [ "$default_tests" = true ]; then
preload="$preload $job_image"
fi
for img in $preload; do
if docker -H "$host_docker" image inspect "$img" >/dev/null 2>&1; then if docker -H "$host_docker" image inspect "$img" >/dev/null 2>&1; then
docker -H "$host_docker" save "$img" | docker load >/dev/null 2>&1 && echo " loaded $img" || true docker -H "$host_docker" save "$img" | docker load >/dev/null 2>&1 && echo " loaded $img" || true
fi fi
@@ -94,3 +111,33 @@ done
echo "==> Running tests against dind daemon" echo "==> Running tests against dind daemon"
go test "$@" go test "$@"
if [ "$default_tests" = true ]; then
if ! docker image inspect "$job_image" >/dev/null 2>&1; then
echo "mounted Docker test requires ${job_image}, pull it on the host before running this harness" >&2
exit 1
fi
test_dir="$(mktemp -d)"
echo "==> Building mounted Docker test for the dind container"
CGO_ENABLED=0 GOOS=linux GOARCH="$(docker -H "$host_docker" image inspect "$image" --format '{{.Architecture}}')" \
go test -c -o "$test_dir/runner.test" ./act/runner/
docker -H "$host_docker" exec "$name" mkdir -p /tmp/gitea-runner-proxy-test/testdata
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)
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
proxy_mode="direct"
fi
echo "==> Running mounted Docker job inside ${target} as UID ${user}, expecting ${proxy_mode} access"
docker -H "$host_docker" exec --user "$user" -w /tmp/gitea-runner-proxy-test \
-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
fi