mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 14:55:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff9965e940 | ||
|
|
ba4d3c5b4f |
No files matched your search
@@ -74,7 +74,7 @@ jobs:
|
|||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
fetch-depth: 0 # all history for all branches and tags
|
fetch-depth: 0 # all history for all branches and tags
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
- name: Set up Docker BuildX
|
||||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: pre-pull test images
|
- name: pre-pull test images
|
||||||
env:
|
env:
|
||||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e # renovate: datasource=docker
|
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e # renovate: datasource=docker
|
||||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
TEST_SERVICE_IMAGE: nginx:alpine@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3 # renovate: datasource=docker
|
||||||
run: |
|
run: |
|
||||||
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ DOCKER_TAG ?= nightly
|
|||||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||||
|
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].1 # renovate: datasource=go
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].2 # renovate: datasource=go
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/[email protected] # renovate: datasource=go
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/[email protected] # renovate: datasource=go
|
||||||
|
|
||||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||||
@@ -96,13 +96,8 @@ go-check:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: fmt-check
|
.PHONY: fmt-check
|
||||||
fmt-check: fmt
|
fmt-check: ## check the Go code formatting
|
||||||
@diff=$$(git diff --color=always -- '*.go'); \
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
|
||||||
if [ -n "$$diff" ]; then \
|
|
||||||
echo "Please run 'make fmt' and commit the result:"; \
|
|
||||||
printf "%s" "$${diff}"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: deps-tools
|
.PHONY: deps-tools
|
||||||
deps-tools: ## install tool dependencies
|
deps-tools: ## install tool dependencies
|
||||||
@@ -149,13 +144,8 @@ tidy: ## run go mod tidy
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: tidy-check
|
.PHONY: tidy-check
|
||||||
tidy-check: tidy
|
tidy-check: ## check that go.mod and go.sum are tidy
|
||||||
@diff=$$(git diff --color=always -- go.mod go.sum); \
|
$(GO) mod tidy -diff
|
||||||
if [ -n "$$diff" ]; then \
|
|
||||||
echo "Please run 'make tidy' and commit the result:"; \
|
|
||||||
printf "%s" "$${diff}"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: ## test everything (integration tests self-skip without docker/network)
|
test: ## test everything (integration tests self-skip without docker/network)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
+17
-25
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1277,15 +1269,15 @@ func overlayVolumes(dst, src *container.HostConfig) {
|
|||||||
|
|
||||||
type validVolumeMatcher struct {
|
type validVolumeMatcher struct {
|
||||||
allowAll bool
|
allowAll bool
|
||||||
named []glob.Glob
|
named []*glob.Pattern
|
||||||
host []glob.Glob
|
host []*glob.Pattern
|
||||||
}
|
}
|
||||||
|
|
||||||
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
ret := validVolumeMatcher{
|
ret := validVolumeMatcher{
|
||||||
named: make([]glob.Glob, 0, len(validVolumes)),
|
named: make([]*glob.Pattern, 0, len(validVolumes)),
|
||||||
host: make([]glob.Glob, 0, len(validVolumes)),
|
host: make([]*glob.Pattern, 0, len(validVolumes)),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range validVolumes {
|
for _, v := range validVolumes {
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
name: docker-proxy
|
||||||
|
description: Verify mounted Docker access through post steps
|
||||||
|
runs:
|
||||||
|
using: node24
|
||||||
|
main: index.js
|
||||||
|
post: index.js
|
||||||
@@ -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
@@ -0,0 +1,8 @@
|
|||||||
|
name: docker-proxy
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
proxy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./action
|
||||||
@@ -2,7 +2,7 @@ module gitea.com/gitea/runner
|
|||||||
|
|
||||||
go 1.27
|
go 1.27
|
||||||
|
|
||||||
toolchain go1.27.0
|
toolchain go1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.20.0
|
connectrpc.com/connect v1.20.0
|
||||||
@@ -17,7 +17,7 @@ require (
|
|||||||
github.com/docker/go-units v0.5.0
|
github.com/docker/go-units v0.5.0
|
||||||
github.com/go-git/go-billy/v5 v5.9.1
|
github.com/go-git/go-billy/v5 v5.9.1
|
||||||
github.com/go-git/go-git/v5 v5.19.2
|
github.com/go-git/go-git/v5 v5.19.2
|
||||||
github.com/gobwas/glob v0.2.3
|
github.com/gobwas/glob v1.0.0
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/julienschmidt/httprouter v1.3.0
|
github.com/julienschmidt/httprouter v1.3.0
|
||||||
@@ -30,8 +30,8 @@ require (
|
|||||||
github.com/opencontainers/image-spec v1.1.1
|
github.com/opencontainers/image-spec v1.1.1
|
||||||
github.com/opencontainers/selinux v1.15.1
|
github.com/opencontainers/selinux v1.15.1
|
||||||
github.com/prometheus/client_golang v1.24.1
|
github.com/prometheus/client_golang v1.24.1
|
||||||
github.com/prometheus/client_model v0.6.2
|
github.com/prometheus/client_model v0.6.3
|
||||||
github.com/sirupsen/logrus v1.10.1
|
github.com/sirupsen/logrus v1.10.2
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.12.1
|
github.com/stretchr/testify v1.12.1
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
|
||||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
@@ -157,8 +157,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
@@ -173,8 +173,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
|
|||||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
|
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||||
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
|
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
|||||||
+56
-9
@@ -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
|
||||||
Reference in new issue
Block a user