Compare commits

..
Author SHA1 Message Date
Lunny Xiao de9db9c0fa fix: suppress tar leading slash warnings 2026-09-06 20:17:55 -07:00
Lunny Xiao d1da6ffa52 fix: create guest workspace in real macOS VM integration test 2026-09-06 18:37:15 -07:00
Lunny Xiao 880e0a148a fix: prepare macOS VM workspace and archive without AppleDouble 2026-09-05 21:56:15 -07:00
Lunny Xiao 2db24c7401 enhance: add macOS VM executor backend
Replace the Tart-based prototype with a runner-oriented macOS VM
executor backed by runner-executor-macos and Apple Virtualization.

Assisted-by: Codet:MODEL_VERSION
2026-09-05 13:20:03 -07:00
49 changed files with 1395 additions and 1707 deletions

No files matched your search

+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: pre-pull test images
env:
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3 # renovate: datasource=docker
TEST_SERVICE_IMAGE: nginx:alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
run: |
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
for attempt in 1 2 3; do
+15 -5
View File
@@ -18,7 +18,7 @@ DOCKER_TAG ?= nightly
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].2 # renovate: datasource=go
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected].1 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/[email protected] # renovate: datasource=go
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
@@ -96,8 +96,13 @@ go-check:
fi
.PHONY: fmt-check
fmt-check: ## check the Go code formatting
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt --diff-colored
fmt-check: fmt
@diff=$$(git diff --color=always -- '*.go'); \
if [ -n "$$diff" ]; then \
echo "Please run 'make fmt' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi
.PHONY: deps-tools
deps-tools: ## install tool dependencies
@@ -144,8 +149,13 @@ tidy: ## run go mod tidy
fi
.PHONY: tidy-check
tidy-check: ## check that go.mod and go.sum are tidy
$(GO) mod tidy -diff
tidy-check: tidy
@diff=$$(git diff --color=always -- go.mod go.sum); \
if [ -n "$$diff" ]; then \
echo "Please run 'make tidy' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi
.PHONY: test
test: ## test everything (integration tests self-skip without docker/network)
+12 -17
View File
@@ -201,10 +201,10 @@ A label is written as:
| Part | Meaning |
| --- | --- |
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
| `args` | Only used by the `docker` schema: the image to run the job in. |
| `schema` | Either `docker`, `host`, or `macos-vm`. Defaults to `host` when omitted. |
| `args` | Used by `docker` for the image, and by `macos-vm` for the source VM. |
Two schemas are supported:
Three schemas are supported:
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
@@ -218,15 +218,21 @@ Two schemas are supported:
macos:host
```
- **`macos-vm://<source-vm>`** — the job runs inside an ephemeral macOS VM created by `runner-executor-macos`. The VM image must include the runner guest agent. Service containers are not supported with this schema. See `docs/macos-vm-executor.md` for configuration, integration testing, and cleanup details:
```text
macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest
```
So with the labels
```text
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host,macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest
```
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host.
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, one with `runs-on: macos` is executed directly on the host, and one with `runs-on: macos-latest` is executed in the macOS VM.
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host`, `docker`, and `macos-vm` are treated as schemas.
If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs: in `runner.default_image` where docker is available, on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
@@ -266,17 +272,6 @@ A job in a container reaches a service by its id on the job network, on the port
Unlike GitHub, a job whose steps run on the host (a `host` label without `container:`) starts no service containers, so `job.services` and `job.container` stay empty. Give such a job a `container:` when it needs services.
#### Docker from a job (`GITEA_DOCKER_WORKSPACE`)
A container a job starts through the Docker socket cannot bind-mount the workspace by the job's own path, the daemon does not have it. `GITEA_DOCKER_WORKSPACE` holds the path the daemon sees. Use it as the prefix of workspace binds, with `.` as the fallback for local use, here in a `docker-compose.yaml`:
```yaml
volumes:
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
```
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
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
+37 -90
View File
@@ -391,11 +391,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
}
}
// A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
_, _, present := refRevision(r, input.Ref)
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
if refresh {
if !isOfflineMode {
err = r.FetchContext(ctx, &fetchOptions)
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
return err
@@ -415,7 +411,23 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
}
}
rev, refType, _ := refRevision(r, input.Ref)
// At this point we need to know if it's a tag or a branch
// And the easiest way to do it is duck typing
//
// If err is nil, it's a tag so let's proceed with that hash like we would if
// it was a sha
refType := "tag"
rev = plumbing.Revision(path.Join("refs", "tags", input.Ref))
if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) {
rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref))
if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) {
refType = "sha"
rev = plumbing.Revision(input.Ref)
} else {
refType = "branch"
rev = plumbing.Revision(rName)
}
}
if hash, err = r.ResolveRevision(rev); err != nil {
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
@@ -447,7 +459,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
reusedMsg := ""
switch {
case refresh && !shallow:
case !isOfflineMode && !shallow:
// In shallow mode the depth-limited fetch above already advanced the ref.
if err = w.PullContext(ctx, &pullOptions); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
logger.Debugf("Unable to pull %s: %v", refName, err)
@@ -489,37 +501,27 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
}
}
// cloneAtDepth falls back to a full clone when the shallow attempt fails.
// cloneAtDepth clones input.URL into input.Dir using opts.
// With input.Depth > 0 it first tries a shallow, single-branch clone of input.Ref, falling back when error.
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
if input.Depth > 0 {
if plumbing.IsHash(input.Ref) {
r, err := fetchPinnedSHA(ctx, input, opts)
for _, refName := range []plumbing.ReferenceName{
plumbing.NewBranchReferenceName(input.Ref),
plumbing.NewTagReferenceName(input.Ref),
} {
shallowOpts := opts
shallowOpts.Depth = input.Depth
shallowOpts.SingleBranch = true
shallowOpts.ReferenceName = refName
shallowOpts.Tags = git.NoTags
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
if err == nil {
return r, nil
}
logger.Debugf("Shallow fetch of %s at %s failed: %v", input.URL, input.Ref, err)
if err := removePartialClone(input.Dir); err != nil {
return nil, err
}
} else {
for _, refName := range []plumbing.ReferenceName{
plumbing.NewBranchReferenceName(input.Ref),
plumbing.NewTagReferenceName(input.Ref),
} {
shallowOpts := opts
shallowOpts.Depth = input.Depth
shallowOpts.SingleBranch = true
shallowOpts.ReferenceName = refName
shallowOpts.Tags = git.NoTags
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
if err == nil {
return r, nil
}
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
if err := removePartialClone(input.Dir); err != nil {
return nil, err
}
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
if rmErr := os.RemoveAll(input.Dir); rmErr != nil {
return nil, fmt.Errorf("remove partial clone %s: %w", input.Dir, rmErr)
}
}
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
@@ -528,65 +530,14 @@ func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
}
func removePartialClone(dir string) error {
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("remove partial clone %s: %w", dir, err)
}
return nil
}
func fetchPinnedSHA(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions) (*git.Repository, error) {
r, err := git.PlainInit(input.Dir, false)
if err != nil {
return nil, err
}
if _, err := r.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{input.URL}}); err != nil {
return nil, err
}
if err := r.FetchContext(ctx, &git.FetchOptions{
RefSpecs: []config.RefSpec{pinnedRefSpec(input.Ref)},
Depth: input.Depth,
Tags: git.NoTags,
Auth: opts.Auth,
Progress: opts.Progress,
InsecureSkipTLS: opts.InsecureSkipTLS,
}); err != nil {
return nil, err
}
return r, nil
}
// pinnedRefSpec keeps a hash-shaped name out of refs/heads and refs/tags.
func pinnedRefSpec(sha string) config.RefSpec {
return config.RefSpec(fmt.Sprintf("+%s:refs/pinned/%s", sha, sha))
}
// refRevision picks the revision to check out and reports whether it resolves locally.
func refRevision(r *git.Repository, ref string) (plumbing.Revision, string, bool) {
if plumbing.IsHash(ref) {
// git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself.
_, err := r.CommitObject(plumbing.NewHash(ref))
return plumbing.Revision(ref), "sha", err == nil
}
if _, err := r.Tag(ref); err == nil {
return plumbing.Revision(path.Join("refs", "tags", ref)), "tag", true
}
remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref))
if _, err := r.Reference(remoteRef, false); err == nil {
return plumbing.Revision(remoteRef), "branch", true
}
rev := plumbing.Revision(ref)
_, err := r.ResolveRevision(rev)
return rev, "sha", err == nil
}
// isShallow reports whether the local repository was cloned with a limited depth.
func isShallow(r *git.Repository) bool {
shallows, err := r.Storer.Shallow()
return err == nil && len(shallows) > 0
}
// shallowFetchRefSpec limits a shallow update to the requested ref, falling back to the broad refspec when it is not present locally.
// shallowFetchRefSpec returns the single refspec that updates only input.Ref, keeping a shallow clone from re-downloading every branch's history.
// ok is false when the ref is not present locally as a tag or remote-tracking branch, in which case the broad default refspec is used.
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
tagRef := plumbing.NewTagReferenceName(ref)
if _, err := r.Reference(tagRef, false); err == nil {
@@ -597,9 +548,5 @@ func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
branchRef := plumbing.NewBranchReferenceName(ref)
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
}
if plumbing.IsHash(ref) {
// The broad refspec carries only advertised tips, never a pinned commit.
return pinnedRefSpec(ref), true
}
return "", false
}
+6 -110
View File
@@ -16,7 +16,6 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
@@ -25,9 +24,6 @@ import (
gogit "github.com/go-git/go-git/v5"
gogitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/transport"
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
gogitfile "github.com/go-git/go-git/v5/plumbing/transport/file"
log "github.com/sirupsen/logrus"
logrustest "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
@@ -439,7 +435,7 @@ func TestGitCloneExecutorShallow(t *testing.T) {
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", m))
}
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
unadvertisedSHA := gitRevParse(t, workDir, "HEAD~1")
sha := gitRevParse(t, workDir, "HEAD~1") // c2, a SHA that go-git cannot shallow-clone
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
@@ -465,43 +461,14 @@ func TestGitCloneExecutorShallow(t *testing.T) {
assert.Equal(t, "c3", gitHeadSubject(t, dir))
})
unadvertisedRemote := func(t *testing.T, allowUnadvertised bool) string {
remote := filepath.Join(t.TempDir(), "remote.git")
require.NoError(t, gitCmd("clone", "--bare", remoteDir, remote))
require.NoError(t, gitCmd("-C", remote, "config", "uploadpack.allowAnySHA1InWant", strconv.FormatBool(allowUnadvertised)))
return remote
}
t.Run("commit hash falls back to a full clone when the remote refuses unadvertised objects", func(t *testing.T) {
t.Run("SHA falls back to a full clone", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
URL: remoteDir, Ref: sha, Dir: dir, Depth: 1,
})(t.Context()))
assert.NoFileExists(t, shallowMarker(dir))
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
})
t.Run("commit hash is fetched shallowly, moved to another hash, then reused without reaching the remote", func(t *testing.T) {
remote := unadvertisedRemote(t, true)
dir := t.TempDir()
cloneAt := func(ref string) error {
return NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remote, Ref: ref, Dir: dir, Depth: 1,
})(t.Context())
}
require.NoError(t, cloneAt(unadvertisedSHA))
assert.FileExists(t, shallowMarker(dir))
assert.Equal(t, 1, gitRevCount(t, dir))
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
olderSHA := gitRevParse(t, workDir, "HEAD~2")
require.NoError(t, cloneAt(olderSHA))
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
require.NoError(t, os.RemoveAll(remote))
require.NoError(t, cloneAt(olderSHA))
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
// go-git cannot shallow-clone a raw SHA, so it falls back to a full clone; the absence of a shallow marker proves the fallback happened.
assert.NoFileExists(t, shallowMarker(dir), "a SHA ref must not produce a shallow clone")
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
})
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
@@ -524,77 +491,6 @@ func TestGitCloneExecutorShallow(t *testing.T) {
})
}
func TestGitCloneExecutorColdCloneSkipsRefresh(t *testing.T) {
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
workDir := t.TempDir()
require.NoError(t, gitCmd("clone", remoteDir, workDir))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1"))
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v1"))
for name, tt := range map[string]struct {
Ref string
Depth int
}{
"shallow branch": {"main", 1},
"full clone tag": {"v1", 0},
} {
t.Run(name, func(t *testing.T) {
counter := installCountingTransport(t)
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: tt.Ref, Dir: t.TempDir(), Depth: tt.Depth,
})(t.Context()))
assert.Equal(t, int64(1), counter.sessions.Load())
})
}
}
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
remoteDir := t.TempDir()
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
workDir := t.TempDir()
require.NoError(t, gitCmd("clone", remoteDir, workDir))
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "pinned"))
pinned := gitRevParse(t, workDir, "HEAD")
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "decoy"))
require.NoError(t, gitCmd("-C", workDir, "tag", pinned))
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
require.NoError(t, gitCmd("-C", workDir, "push", "origin", pinned))
for name, depth := range map[string]int{"shallow": 1, "full clone": 0} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
URL: remoteDir, Ref: pinned, Dir: dir, Depth: depth,
})(t.Context()))
assert.Equal(t, pinned, gitRevParse(t, dir, "HEAD"))
})
}
}
type countingTransport struct {
transport.Transport
sessions atomic.Int64
}
func (c *countingTransport) NewUploadPackSession(ep *transport.Endpoint, auth transport.AuthMethod) (transport.UploadPackSession, error) {
c.sessions.Add(1)
return c.Transport.NewUploadPackSession(ep, auth)
}
func installCountingTransport(t *testing.T) *countingTransport {
t.Helper()
counter := &countingTransport{Transport: gogitfile.DefaultClient}
gogitclient.InstallProtocol("file", counter)
t.Cleanup(func() { gogitclient.InstallProtocol("file", gogitfile.DefaultClient) })
return counter
}
func gitRevParse(t *testing.T, dir, rev string) string {
t.Helper()
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
+1 -16
View File
@@ -9,7 +9,6 @@ import (
"errors"
"fmt"
"io"
"sync"
"gitea.com/gitea/runner/act/common"
@@ -76,19 +75,6 @@ const (
// fragment, missingContainerError composes it into the message every operation shares.
var ErrContainerNotFound = errors.New("does not exist")
// DockerProxy is a job's docker socket, fronting the daemon's for the job's lifetime.
type DockerProxy struct {
Socket string
close func(context.Context) error
closeOnce sync.Once
closeErr error
}
func (p *DockerProxy) Close(ctx context.Context) error {
p.closeOnce.Do(func() { p.closeErr = p.close(ctx) })
return p.closeErr
}
// Info is a snapshot of a container, as of one inspect.
type Info struct {
ID string
@@ -98,14 +84,13 @@ type Info struct {
// HealthOutput is the last healthcheck probe's output.
HealthOutput string
Ports map[string]string // container port ("5432") to the host port it is published on
Mounts map[string]string // container path to its source on the daemon
}
// Container for managing docker run containers
type Container interface {
Create(capAdd, capDrop []string) common.Executor
Copy(destPath string, files ...*FileEntry) common.Executor
CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
Inspect(ctx context.Context) (*Info, error)
DumpLogs(ctx context.Context) error
-395
View File
@@ -1,395 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
package container
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"mime"
"net"
"net/http"
"net/http/httputil"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"gitea.com/gitea/runner/act/common"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/client"
)
const (
jobLabel = "com.gitea.runner.job"
maxCreateBody = 8 << 20
dockerProxyProbeTimeout = 5 * time.Second
)
var (
createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`)
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
)
func NewDockerProxy(ctx context.Context, job string) *DockerProxy {
if host := os.Getenv("DOCKER_HOST"); runtime.GOOS != "linux" || host != "" && !strings.HasPrefix(host, "unix://") {
return nil
}
probeCtx, cancel := context.WithTimeout(ctx, dockerProxyProbeTimeout)
defer cancel()
cli, err := GetDockerClient(probeCtx)
if err != nil {
return nil
}
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{})
if err != nil {
return false, err
}
if len(images.Items) == 0 {
return false, errors.New("no image available for the docker proxy probe")
}
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}},
HostConfig: &container.HostConfig{Mounts: []mount.Mount{
{Type: mount.TypeBind, Source: marker.Name(), Target: "/gitea-runner-probe", ReadOnly: true},
}},
})
if cerrdefs.IsInvalidArgument(err) {
return false, nil
}
if err != nil {
return false, err
}
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dockerProxyProbeTimeout)
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.
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
info, err := os.Stat(daemonSocket)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSocket == 0 {
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) {
return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket)
}
transport := &http.Transport{DialContext: dial}
forward := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.Out.URL.Scheme = "http"
r.Out.URL.Host = "docker"
},
Transport: transport,
}
streams, cancelStreams := context.WithCancel(context.Background())
creates, cancelCreates := context.WithCancel(context.Background())
var admission sync.Mutex
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 {
status := http.StatusBadRequest
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
status = http.StatusRequestEntityTooLarge
}
http.Error(w, err.Error(), status)
return
}
} else if r.Method == http.MethodPost && rawStreamPath.MatchString(r.URL.Path) {
tunnel(w, r, dial, forward)
return
}
forward.ServeHTTP(w, r)
})}
served := make(chan struct{})
go func() {
defer close(served)
_ = server.Serve(listener)
}()
return &DockerProxy{Socket: socket, close: func(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
admission.Lock()
listenerErr := listener.Close()
cancelStreams()
admission.Unlock()
<-served
shutdownErr := server.Shutdown(ctx)
cancelCreates()
serverErr := server.Close()
handlers.Wait()
transport.CloseIdleConnections()
return errors.Join(ctx.Err(), listenerErr, shutdownErr, serverErr, os.RemoveAll(instance))
}}, nil
}
func addLabel(r *http.Request, job string) error {
body, err := io.ReadAll(r.Body)
if err != nil {
return err
}
if len(bytes.TrimSpace(body)) == 0 {
body = []byte("{}")
}
var fields map[string]json.RawMessage
var config struct{ Labels map[string]string }
if err := json.Unmarshal(body, &fields); err != nil {
return fmt.Errorf("invalid create request: %w", err)
}
if err := json.Unmarshal(body, &config); err != nil {
return fmt.Errorf("invalid create labels: %w", err)
}
if fields == nil {
fields = make(map[string]json.RawMessage)
}
maps.DeleteFunc(fields, func(name string, _ json.RawMessage) bool {
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
}
if body, err = json.Marshal(fields); err != nil {
return err
}
r.Body = io.NopCloser(bytes.NewReader(body))
r.ContentLength = int64(len(body))
r.TransferEncoding = 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
func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error), forward *httputil.ReverseProxy) {
upstream, err := dial(r.Context(), "", "")
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer upstream.Close()
stop := context.AfterFunc(r.Context(), func() { _ = upstream.Close() })
defer stop()
if err := r.Write(upstream); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
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 {
return
}
defer downstream.Close()
if _, err := fmt.Fprintf(buffered, "%s %s\r\n", response.Proto, response.Status); err != nil {
return
}
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() {
defer close(done)
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()
} else if writer, ok := upstream.(interface{ CloseWrite() error }); ok {
_ = writer.CloseWrite()
} else {
_ = upstream.Close()
}
}()
_, _ = io.Copy(downstream, reader)
_ = downstream.Close()
_ = upstream.Close()
<-done
}
func RemoveDockerJobResources(ctx context.Context, job string) error {
cli, err := GetDockerClient(ctx)
if err != nil {
return err
}
defer cli.Close()
return removeLabelled(ctx, cli, job)
}
func removeLabelled(ctx context.Context, cli client.APIClient, job string) error {
logger := common.Logger(ctx)
filters := make(client.Filters).Add("label", jobLabel+"="+job)
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
errs := []error{err}
for _, c := range containers.Items {
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))
}
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
errs = append(errs, err)
for _, n := range networks.Items {
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
}
}
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters})
errs = append(errs, err)
for _, v := range volumes.Items {
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))
}
}
return errors.Join(errs...)
}
-469
View File
@@ -1,469 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
package container
import (
"archive/tar"
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/image"
"github.com/moby/moby/api/types/mount"
"github.com/moby/moby/api/types/volume"
mobyclient "github.com/moby/moby/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func (m *mockDockerClient) VolumeList(ctx context.Context, opts mobyclient.VolumeListOptions) (mobyclient.VolumeListResult, error) {
args := m.Called(ctx, opts)
return args.Get(0).(mobyclient.VolumeListResult), args.Error(1)
}
func (m *mockDockerClient) VolumeRemove(ctx context.Context, id string, opts mobyclient.VolumeRemoveOptions) (mobyclient.VolumeRemoveResult, error) {
args := m.Called(ctx, id, opts)
return args.Get(0).(mobyclient.VolumeRemoveResult), args.Error(1)
}
// unix socket paths are limited to about a hundred bytes, which the default macOS TMPDIR exceeds
func shortTempDir(t testing.TB) string {
t.Helper()
t.Setenv("TMPDIR", "/tmp")
return t.TempDir()
}
func daemonSocketPath(t testing.TB, cli mobyclient.APIClient) string {
t.Helper()
host := cli.DaemonHost()
if !strings.HasPrefix(host, "unix://") {
t.Skipf("skipping: daemon at %s is not a unix socket", host)
}
return strings.TrimPrefix(host, "unix://")
}
func TestDockerProxy(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix socket ownership is unavailable on Windows")
}
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")
listener, err := net.Listen("unix", daemonSocket)
require.NoError(t, err)
daemon := &http.Server{ReadHeaderTimeout: time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/create"):
body, _ := io.ReadAll(r.Body)
if r.URL.RawQuery == "wait" {
close(createStarted)
<-releaseCreate
}
bodies <- body
w.WriteHeader(http.StatusCreated)
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()
if err != nil {
return
}
defer conn.Close()
if r.Header.Get("Upgrade") != "" {
_, _ = 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:
w.Header().Set("Api-Version", "1.47")
_, _ = w.Write([]byte("OK " + r.Method + " " + r.URL.Path))
}
})}
go func() { _ = daemon.Serve(listener) }()
t.Cleanup(func() { _ = daemon.Close() })
proxy, err := StartDockerProxy(daemonSocket, shortTempDir(t), "job-1")
require.NoError(t, err)
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)
}}}
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) {
for _, testCase := range []struct{ path, body, want string }{
{
"/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`,
`{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"job-1"}}`,
},
{
"/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"+testCase.path, "application/json", strings.NewReader(testCase.body))
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
assert.JSONEq(t, testCase.want, string(<-bodies), testCase.body)
}
})
t.Run("passes other requests through", func(t *testing.T) {
resp, err := client.Get("http://docker/v1.47/_ping")
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "1.47", resp.Header.Get("Api-Version"))
assert.Equal(t, "OK GET /v1.47/_ping", string(body))
})
t.Run("tunnels raw streams through stdin EOF", func(t *testing.T) {
for _, stream := range []struct{ path, upgrade string }{
{path: "/v1.47/exec/abc/start"},
{path: "/containers/abc/attach", upgrade: "Connection: Upgrade\r\nUpgrade: tcp\r\n"},
} {
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)
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)
<-createStarted
require.NoError(t, creator.CloseWrite())
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, 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) {
containerFailure := errors.New("container removal failed")
listFailure := errors.New("listing failed")
volumeFailure := errors.New("volume removal failed")
ctx := context.Background()
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
cli := &mockDockerClient{}
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil).Once()
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil).Once()
cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
Return(mobyclient.ContainerRemoveResult{}, containerFailure).Once()
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).Return(mobyclient.NetworkListResult{}, listFailure).Once()
cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}).
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil).Once()
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, volumeFailure).Once()
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)
}
func TestDockerProxyWithDaemon(t *testing.T) {
requireDocker(t)
ctx := context.Background()
require.NoError(t, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
direct, err := GetDockerClient(ctx)
require.NoError(t, err)
defer direct.Close()
dir := shortTempDir(t)
seen, err := daemonSeesDir(ctx, direct, dir)
require.NoError(t, err)
t.Logf("daemon sees the runner's filesystem: %v", seen)
job := "proxy-test-" + strconv.FormatInt(time.Now().UnixNano(), 36)
proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
require.NoError(t, err)
t.Cleanup(func() { _ = proxy.Close(context.Background()) })
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
require.NoError(t, err)
defer viaProxy.Close()
created, err := viaProxy.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "300"}, Labels: map[string]string{"own": "1"}},
})
require.NoError(t, err)
t.Cleanup(func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) })
_, err = viaProxy.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
require.NoError(t, err)
net, err := viaProxy.NetworkCreate(ctx, job, mobyclient.NetworkCreateOptions{})
require.NoError(t, err)
t.Cleanup(func() { _, _ = direct.NetworkRemove(ctx, net.ID, mobyclient.NetworkRemoveOptions{}) })
_, err = viaProxy.VolumeCreate(ctx, mobyclient.VolumeCreateOptions{Name: job})
require.NoError(t, err)
t.Cleanup(func() { _, _ = direct.VolumeRemove(ctx, job, mobyclient.VolumeRemoveOptions{Force: true}) })
inspected, err := direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
require.NoError(t, err)
assert.Equal(t, map[string]string{"own": "1", jobLabel: job}, inspected.Container.Config.Labels)
vol, err := direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
require.NoError(t, err)
assert.Equal(t, job, vol.Volume.Labels[jobLabel])
exec, err := viaProxy.ExecCreate(ctx, created.ID, mobyclient.ExecCreateOptions{Cmd: []string{"cat"}, AttachStdin: true, AttachStdout: true, TTY: true})
require.NoError(t, err)
attached, err := viaProxy.ExecAttach(ctx, exec.ID, mobyclient.ExecAttachOptions{TTY: true})
require.NoError(t, err)
_, err = attached.Conn.Write([]byte("hello\n"))
require.NoError(t, err)
echoed, err := attached.Reader.ReadString('\n')
require.NoError(t, err)
assert.Equal(t, "hello\r\n", echoed)
attached.Close()
require.NoError(t, proxy.Close(ctx))
require.NoError(t, RemoveDockerJobResources(ctx, job))
_, err = direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err))
_, err = direct.NetworkInspect(ctx, net.ID, mobyclient.NetworkInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err))
_, err = direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
assert.True(t, cerrdefs.IsNotFound(err))
}
func BenchmarkDockerProxy(b *testing.B) {
ctx := context.Background()
direct, err := GetDockerClient(ctx)
require.NoError(b, err)
defer direct.Close()
if _, err := direct.Ping(ctx, mobyclient.PingOptions{}); err != nil {
b.Skipf("docker daemon unreachable: %v", err)
}
proxy, err := StartDockerProxy(daemonSocketPath(b, direct), shortTempDir(b), "bench")
require.NoError(b, err)
defer func() { _ = proxy.Close(ctx) }()
viaProxy, err := mobyclient.New(mobyclient.WithHost("unix://" + proxy.Socket))
require.NoError(b, err)
defer viaProxy.Close()
require.NoError(b, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
created, err := direct.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "600"}}})
require.NoError(b, err)
defer func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) }()
_, err = direct.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
require.NoError(b, err)
var archive bytes.Buffer
writer := tar.NewWriter(&archive)
payload := make([]byte, 64<<20)
require.NoError(b, writer.WriteHeader(&tar.Header{Name: "blob", Mode: 0o600, Size: int64(len(payload))}))
_, _ = writer.Write(payload)
require.NoError(b, writer.Close())
for name, cli := range map[string]mobyclient.APIClient{"direct": direct, "proxy": viaProxy} {
b.Run("ping/"+name, func(b *testing.B) {
for b.Loop() {
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
b.Fatal(err)
}
}
})
b.Run("copy64MiB/"+name, func(b *testing.B) {
b.SetBytes(int64(archive.Len()))
for b.Loop() {
_, err := cli.CopyToContainer(ctx, created.ID, mobyclient.CopyToContainerOptions{DestinationPath: "/tmp", Content: bytes.NewReader(archive.Bytes())})
if err != nil {
b.Fatal(err)
}
}
})
}
}
type probeClient struct {
mobyclient.APIClient
create func(mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error)
remove func(context.Context, string, mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error)
}
func (c *probeClient) ImageList(context.Context, mobyclient.ImageListOptions) (mobyclient.ImageListResult, error) {
return mobyclient.ImageListResult{Items: []image.Summary{{ID: "probe-image"}}}, nil
}
func (c *probeClient) ContainerCreate(_ context.Context, opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
return c.create(opts)
}
func (c *probeClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
return c.remove(ctx, id, opts)
}
func TestDaemonSeesDir(t *testing.T) {
dir := t.TempDir()
markers := make(map[string]bool)
for _, testCase := range []struct {
name string
private bool
removeErr error
}{
{name: "cleanup after cancellation"},
{name: "private filesystem with stale marker", private: true},
{name: "cleanup error after cancellation", removeErr: errors.New("cleanup failed")},
} {
t.Run(testCase.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
daemonDir := dir
if testCase.private {
daemonDir = t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(daemonDir, "probe"), []byte("stale"), 0o600))
}
removed := false
cli := &probeClient{
create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
require.Len(t, opts.HostConfig.Mounts, 1)
marker := opts.HostConfig.Mounts[0].Source
assert.Equal(t, mount.Mount{Type: mount.TypeBind, Source: marker, Target: "/gitea-runner-probe", ReadOnly: true}, opts.HostConfig.Mounts[0])
assert.False(t, markers[marker])
markers[marker] = true
info, err := os.Stat(marker)
require.NoError(t, err)
assert.True(t, info.Mode().IsRegular())
cancel()
if _, err := os.Stat(filepath.Join(daemonDir, filepath.Base(marker))); errors.Is(err, os.ErrNotExist) {
return mobyclient.ContainerCreateResult{}, cerrdefs.ErrInvalidArgument
}
return mobyclient.ContainerCreateResult{ID: "probe"}, nil
},
remove: func(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
removed = true
assert.Equal(t, "probe", id)
assert.Equal(t, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true}, opts)
require.NoError(t, ctx.Err())
_, bounded := ctx.Deadline()
assert.True(t, bounded)
return mobyclient.ContainerRemoveResult{}, testCase.removeErr
},
}
seen, err := daemonSeesDir(ctx, cli, dir)
require.ErrorIs(t, err, testCase.removeErr)
assert.Equal(t, !testCase.private && testCase.removeErr == nil, seen)
assert.Equal(t, !testCase.private, removed)
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Empty(t, entries)
})
}
}
-27
View File
@@ -1,27 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
package container
import (
"errors"
"os"
"path/filepath"
"syscall"
)
func copyDockerSocketPermissions(socket string, info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("docker socket ownership is unavailable")
}
if err := os.Chown(socket, int(stat.Uid), int(stat.Gid)); err != nil {
return err
}
if err := os.Chown(filepath.Dir(socket), int(stat.Uid), -1); err != nil {
return err
}
return os.Chmod(socket, info.Mode().Perm())
}
-15
View File
@@ -1,15 +0,0 @@
// 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")
}
+31 -28
View File
@@ -138,12 +138,12 @@ func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.
).IfNot(common.Dryrun)
}
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
func (cr *containerReference) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
return common.NewPipelineExecutor(
common.NewInfoExecutor("docker cp src=%s dst=%s", srcPath, destPath),
cr.connect(),
cr.find(),
cr.copyDir(destPath, srcPath, useGitIgnore, skipGitDir),
cr.copyDir(destPath, srcPath, useGitIgnore),
func(ctx context.Context) error {
// If this fails, then folders have wrong permissions on non root container
if cr.UID != 0 || cr.GID != 0 {
@@ -252,10 +252,6 @@ func containerInfoFromInspect(inspect container.InspectResponse) *Info {
ID: inspect.ID,
Health: HealthNone,
Ports: map[string]string{}, // an empty map, never null, in the expression context
Mounts: make(map[string]string, len(inspect.Mounts)),
}
for _, mountPoint := range inspect.Mounts {
info.Mounts[mountPoint.Destination] = mountPoint.Source
}
if state := inspect.State; state != nil {
@@ -488,11 +484,14 @@ func (cr *containerReference) remove() common.Executor {
RemoveVolumes: true,
Force: true,
})
if cerrdefs.IsConflict(err) {
err = cr.waitForRemoval(ctx, idOrName)
}
if err != nil && !cerrdefs.IsNotFound(err) {
return fmt.Errorf("failed to remove container %s: %w", idOrName, err)
switch {
case cerrdefs.IsConflict(err):
// the daemon's own AutoRemove teardown is running, and it releases the volume
// references and the network endpoint only once it finishes
cr.waitForRemoval(ctx, idOrName)
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)
@@ -501,7 +500,7 @@ func (cr *containerReference) remove() common.Executor {
}
}
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) error {
func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName string) {
// 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
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
@@ -511,13 +510,18 @@ func (cr *containerReference) waitForRemoval(ctx context.Context, idOrName strin
Condition: container.WaitConditionRemoved,
})
select {
case result := <-waitResult.Result:
if result.Error != nil {
return errors.New(result.Error.Message)
}
return nil
case err := <-waitResult.Error:
return err
case <-waitResult.Result:
case <-waitResult.Error:
case <-ctx.Done():
// 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
go func() {
select {
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)
}
}
@@ -936,7 +940,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
}
}
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore bool) common.Executor {
return func(ctx context.Context) error {
if cr.id == "" {
return cr.missingContainerError("copy directory to %s", dstPath)
@@ -977,10 +981,9 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
}
fc := &filecollector.FileCollector{
Ignorer: ignorer,
SrcPath: srcPath,
SrcPrefix: srcPrefix,
SkipGitDir: skipGitDir,
Ignorer: ignorer,
SrcPath: srcPath,
SrcPrefix: srcPrefix,
Handler: &filecollector.TarCollector{
TarWriter: tw,
UID: cr.UID,
@@ -1269,15 +1272,15 @@ func overlayVolumes(dst, src *container.HostConfig) {
type validVolumeMatcher struct {
allowAll bool
named []*glob.Pattern
host []*glob.Pattern
named []glob.Glob
host []glob.Glob
}
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
logger := common.Logger(ctx)
ret := validVolumeMatcher{
named: make([]*glob.Pattern, 0, len(validVolumes)),
host: make([]*glob.Pattern, 0, len(validVolumes)),
named: make([]glob.Glob, 0, len(validVolumes)),
host: make([]glob.Glob, 0, len(validVolumes)),
}
for _, v := range validVolumes {
+13 -34
View File
@@ -345,22 +345,18 @@ func TestDockerWaitFailure(t *testing.T) {
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
// be logged as one.
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
removeFailure := errors.New("driver failed to remove root filesystem")
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
for _, tc := range []struct {
name string
err error
wantWait bool
waitErr error
wantErr error
name string
err error
wantWait bool
wantFailure bool
}{
{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: "removed cleanly", err: nil},
{name: "real failure", err: removeFailure, wantErr: removeFailure},
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true},
} {
t.Run(tc.name, func(t *testing.T) {
logger, hook := test.NewNullLogger()
@@ -370,24 +366,21 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
if tc.wantWait {
removed := make(chan container.WaitResponse, 1)
waitErrors := make(chan error, 1)
if tc.waitErr == nil {
removed <- container.WaitResponse{}
} else {
waitErrors <- tc.waitErr
}
removed <- container.WaitResponse{}
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
Return(mobyclient.ContainerWaitResult{Result: removed, Error: waitErrors})
Return(mobyclient.ContainerWaitResult{Result: removed})
}
cr := &containerReference{id: "abc", cli: client}
require.ErrorIs(t, cr.remove()(ctx), tc.wantErr)
if tc.wantErr != nil {
require.NoError(t, cr.remove()(ctx))
// a failure keeps the id, so a later Remove() can retry it
if tc.wantFailure {
assert.Equal(t, "abc", cr.id)
assert.Len(t, hook.AllEntries(), 1)
} else {
assert.Empty(t, cr.id)
assert.Empty(t, hook.AllEntries())
}
assert.Empty(t, hook.AllEntries())
client.AssertExpectations(t)
})
}
@@ -478,7 +471,7 @@ func TestRejectsMissingContainer(t *testing.T) {
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
}
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
check("copyDir", cr.copyDir("/var/run/act", "/src", false, false)(ctx))
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
check("GetContainerArchive", err)
@@ -808,20 +801,6 @@ func TestContainerInfoFromInspect(t *testing.T) {
assert.Equal(t, "abc123", info.ID)
assert.Equal(t, HealthNone, info.Health)
})
t.Run("reports the mount sources by container path", func(t *testing.T) {
info := containerInfoFromInspect(container.InspectResponse{
Mounts: []container.MountPoint{
{Type: mount.TypeVolume, Name: "job", Source: "/var/lib/docker/volumes/job/_data", Destination: "/workspace/owner/repo"},
{Type: mount.TypeBind, Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock"},
},
})
assert.Equal(t, map[string]string{
"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data",
"/var/run/docker.sock": "/var/run/docker.sock",
}, info.Mounts)
})
}
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
-12
View File
@@ -29,18 +29,6 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
return false, errors.New("Unsupported Operation")
}
func RemoveDockerJobResources(_ context.Context, _ string) error {
return nil
}
func NewDockerProxy(_ context.Context, _ string) *DockerProxy {
return nil
}
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
return nil, errors.New("Unsupported Operation")
}
// NewDockerBuildExecutor function to create a run executor for the container
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
return func(ctx context.Context) error {
+4 -5
View File
@@ -92,7 +92,7 @@ func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Exec
}
}
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
srcPrefix := filepath.Dir(srcPath)
@@ -110,10 +110,9 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGi
ignorer = gitignore.NewMatcher(ps)
}
fc := &filecollector.FileCollector{
Ignorer: ignorer,
SrcPath: srcPath,
SrcPrefix: srcPrefix,
SkipGitDir: skipGitDir,
Ignorer: ignorer,
SrcPath: srcPath,
SrcPrefix: srcPrefix,
Handler: &filecollector.CopyCollector{
DstDir: destPath,
},
+1 -23
View File
@@ -47,32 +47,10 @@ func TestCopyDir(t *testing.T) {
_ = os.MkdirAll(e.TmpDir, 0o700)
_ = os.MkdirAll(e.ToolCache, 0o700)
_ = os.MkdirAll(e.ActPath, 0o700)
err := e.CopyDir(e.Workdir, e.Path, true, false)(ctx)
err := e.CopyDir(e.Workdir, e.Path, true)(ctx)
assert.NoError(t, err)
}
func TestCopyDirSkipGitDir(t *testing.T) {
for name, skipGitDir := range map[string]bool{"kept for a workspace copy": false, "skipped for an action copy": true} {
t.Run(name, func(t *testing.T) {
src := filepath.Join(t.TempDir(), "action")
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
dest := filepath.Join(t.TempDir(), "dest")
e := &HostEnvironment{StdOut: os.Stdout}
require.NoError(t, e.CopyDir(dest, src+string(filepath.Separator), false, skipGitDir)(context.Background()))
assert.FileExists(t, filepath.Join(dest, "action.yml"))
if skipGitDir {
assert.NoDirExists(t, filepath.Join(dest, ".git"))
} else {
assert.FileExists(t, filepath.Join(dest, ".git", "objects", "pack"))
}
})
}
}
func TestGetContainerArchive(t *testing.T) {
dir := t.TempDir()
ctx := context.Background()
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"time"
)
type macOSVM struct {
Name string `json:"name"`
State string `json:"state"`
Running bool `json:"running"`
AccessedAt string `json:"accessed_at"`
}
// CleanupOrphanMacOSVMs deletes local macOS VMs left behind by this runner that
// have not been accessed since cutoff.
func CleanupOrphanMacOSVMs(ctx context.Context, executorPath, uuid string, cutoff time.Time) error {
if uuid == "" {
return nil
}
output, err := exec.CommandContext(ctx, executorPath, "list", "--format", "json").Output()
if err != nil {
return fmt.Errorf("list macOS VMs: %w", err)
}
var vms []macOSVM
if err := json.Unmarshal(output, &vms); err != nil {
return fmt.Errorf("parse macOS VM list: %w", err)
}
var errs []error
for _, name := range macOSVMsToDelete(vms, uuid, cutoff) {
if err := exec.CommandContext(ctx, executorPath, "destroy", name).Run(); err != nil {
errs = append(errs, fmt.Errorf("destroy macOS VM %s: %w", name, err))
}
}
return errors.Join(errs...)
}
func macOSVMsToDelete(vms []macOSVM, uuid string, cutoff time.Time) []string {
prefix := "GITEA-MACOS-VM-" + uuid + "-"
var stale []string
for _, vm := range vms {
if vm.Running || !strings.HasPrefix(vm.Name, prefix) {
continue
}
accessed, err := parseMacOSVMTime(vm.AccessedAt)
if err != nil || accessed.After(cutoff) {
continue
}
stale = append(stale, vm.Name)
}
return stale
}
func parseMacOSVMTime(raw string) (time.Time, error) {
if raw == "" {
return time.Time{}, errors.New("empty access time")
}
if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
return parsed, nil
}
return time.Parse(time.RFC3339, raw)
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMacOSVMsToDelete(t *testing.T) {
cutoff := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC)
vms := []macOSVM{
{Name: "GITEA-MACOS-VM-runner-1-stale", AccessedAt: "2026-09-04T10:00:00Z"},
{Name: "GITEA-MACOS-VM-runner-1-running", AccessedAt: "2026-09-04T10:00:00Z", Running: true},
{Name: "GITEA-MACOS-VM-runner-1-fresh", AccessedAt: "2026-09-05T11:00:00Z"},
{Name: "GITEA-MACOS-VM-runner-2-stale", AccessedAt: "2026-09-04T10:00:00Z"},
}
assert.Equal(t, []string{"GITEA-MACOS-VM-runner-1-stale"}, macOSVMsToDelete(vms, "runner-1", cutoff))
}
func TestParseMacOSVMTime(t *testing.T) {
parsed, err := parseMacOSVMTime("2026-09-05T10:00:00Z")
require.NoError(t, err)
assert.Equal(t, time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC), parsed)
parsed, err = parseMacOSVMTime("2026-09-05T10:00:00.123456789Z")
require.NoError(t, err)
assert.Equal(t, time.Date(2026, 9, 5, 10, 0, 0, 123456789, time.UTC), parsed)
_, err = parseMacOSVMTime("")
require.Error(t, err)
}
+450
View File
@@ -0,0 +1,450 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"archive/tar"
"context"
"errors"
"fmt"
"io"
"maps"
"os/exec"
"path"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"time"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/filecollector"
"github.com/go-git/go-billy/v5/helper/polyfill"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
)
const (
defaultMacOSVMBootTimeout = 5 * time.Minute
macOSVMReadyPollInterval = 500 * time.Millisecond
macOSVMStopWaitTimeout = 10 * time.Second
)
// MacOSVMEnvironment runs workflow steps in an ephemeral macOS VM.
type MacOSVMEnvironment struct {
Path string // guest scratch directory
TmpDir string // guest temp directory
ToolCache string // guest tool cache directory
Workdir string // host-side workspace path
GuestWorkdir string // guest-side workspace path
ActPath string // guest act directory
Image string
VMName string
ExecutorPath string
CPU int
Memory int
BootTimeout time.Duration
Stdout io.Writer
Stderr io.Writer
executorPath string
running bool
runCmd *exec.Cmd
runDone chan struct{}
runErr error
}
// MacOSVMEnvironmentInput is the input for NewMacOSVMEnvironment.
type MacOSVMEnvironmentInput struct {
Path string
TmpDir string
ToolCache string
Workdir string
GuestWorkdir string
ActPath string
Image string
VMName string
ExecutorPath string
CPU int
Memory int
BootTimeout time.Duration
Stdout io.Writer
Stderr io.Writer
}
func NewMacOSVMEnvironment(input MacOSVMEnvironmentInput) (*MacOSVMEnvironment, error) {
if input.Image == "" {
return nil, errors.New("macOS VM image is required")
}
if input.VMName == "" {
return nil, errors.New("macOS VM name is required")
}
if input.BootTimeout <= 0 {
input.BootTimeout = defaultMacOSVMBootTimeout
}
if input.ExecutorPath == "" {
input.ExecutorPath = "runner-executor-macos"
}
return &MacOSVMEnvironment{
Path: input.Path,
TmpDir: input.TmpDir,
ToolCache: input.ToolCache,
Workdir: input.Workdir,
GuestWorkdir: input.GuestWorkdir,
ActPath: input.ActPath,
Image: input.Image,
VMName: input.VMName,
ExecutorPath: input.ExecutorPath,
CPU: input.CPU,
Memory: input.Memory,
BootTimeout: input.BootTimeout,
Stdout: input.Stdout,
Stderr: input.Stderr,
executorPath: input.ExecutorPath,
}, nil
}
var _ ExecutionsEnvironment = &MacOSVMEnvironment{}
func (e *MacOSVMEnvironment) Create(_, _ []string) common.Executor {
return func(ctx context.Context) error {
if common.Dryrun(ctx) {
return nil
}
// A previous run can leave the deterministic job VM behind. Destroy it first.
_ = e.runExecutor(ctx, nil, io.Discard, io.Discard, "destroy", e.VMName)
args := []string{"provision", e.Image, e.VMName}
if e.CPU > 0 {
args = append(args, "--cpu", strconv.Itoa(e.CPU))
}
if e.Memory > 0 {
args = append(args, "--memory", strconv.Itoa(e.Memory))
}
return e.runExecutor(ctx, nil, e.Stdout, e.Stderr, args...)
}
}
func (e *MacOSVMEnvironment) Copy(destPath string, files ...*FileEntry) common.Executor {
return func(ctx context.Context) error {
if common.Dryrun(ctx) {
return nil
}
for _, file := range files {
dest := path.Join(destPath, file.Name)
script := `mkdir -p "$1" && cat > "$2" && chmod "$3" "$2"`
mode := file.Mode
if mode == 0 {
mode = 0o644
}
args := []string{"exec", "--stdin", e.VMName, "/bin/sh", "-c", script, "sh", path.Dir(dest), dest, strconv.FormatInt(mode, 8)}
if err := e.runExecutor(ctx, strings.NewReader(file.Body), e.Stdout, e.Stderr, args...); err != nil {
return err
}
}
return nil
}
}
func (e *MacOSVMEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
return func(ctx context.Context) error {
if common.Dryrun(ctx) {
return nil
}
pipeReader, pipeWriter := io.Pipe()
writeDone := make(chan error, 1)
go func() {
err := e.writeTar(ctx, pipeWriter, srcPath, useGitIgnore)
_ = pipeWriter.CloseWithError(err)
writeDone <- err
}()
script := `export COPYFILE_DISABLE=1; mkdir -p "$1" && /usr/bin/tar -xf - -C "$1"`
cmdErr := e.runExecutor(ctx, pipeReader, e.Stdout, e.Stderr, "exec", "--stdin", e.VMName, "/bin/sh", "-c", script, "sh", destPath)
_ = pipeReader.Close()
writeErr := <-writeDone
return errors.Join(cmdErr, writeErr)
}
}
func (e *MacOSVMEnvironment) writeTar(ctx context.Context, writer io.Writer, srcPath string, useGitIgnore bool) error {
tarWriter := tar.NewWriter(writer)
defer tarWriter.Close()
srcPrefix := filepath.Dir(srcPath)
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
srcPrefix += string(filepath.Separator)
}
var ignorer gitignore.Matcher
if useGitIgnore {
patterns, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
if err != nil {
common.Logger(ctx).Debugf("Error loading .gitignore: %v", err)
}
ignorer = gitignore.NewMatcher(patterns)
}
collector := &filecollector.FileCollector{
Ignorer: ignorer,
SrcPath: srcPath,
SrcPrefix: srcPrefix,
Handler: &filecollector.TarCollector{TarWriter: tarWriter},
}
return filepath.Walk(srcPath, collector.CollectFiles(ctx, []string{}))
}
func (e *MacOSVMEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
if common.Dryrun(ctx) {
return nil, errors.New("DRYRUN is not supported in GetContainerArchive")
}
cmd := exec.CommandContext(ctx, e.executorPath, "exec", e.VMName, "/usr/bin/env", "COPYFILE_DISABLE=1", "/usr/bin/tar", "-cf", "-", "-C", "/", strings.TrimPrefix(srcPath, "/"))
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
cmd.Stderr = e.Stderr
if err := cmd.Start(); err != nil {
return nil, err
}
return &macOSVMArchiveReader{ReadCloser: stdout, cmd: cmd}, nil
}
type macOSVMArchiveReader struct {
io.ReadCloser
cmd *exec.Cmd
}
func (r *macOSVMArchiveReader) Close() error {
closeErr := r.ReadCloser.Close()
return errors.Join(closeErr, r.cmd.Wait())
}
func (e *MacOSVMEnvironment) Inspect(ctx context.Context) (*Info, error) {
if common.Dryrun(ctx) {
return &Info{Health: HealthNone, Ports: map[string]string{}}, nil
}
state := "exited"
if e.running {
state = StateRunning
}
return &Info{ID: e.VMName, State: state, Health: HealthNone, Ports: map[string]string{}}, nil
}
func (e *MacOSVMEnvironment) DumpLogs(context.Context) error {
return nil
}
func (e *MacOSVMEnvironment) Pull(forcePull bool) common.Executor {
return func(ctx context.Context) error {
if common.Dryrun(ctx) {
return nil
}
return nil
}
}
func (e *MacOSVMEnvironment) Start(bool) common.Executor {
return func(ctx context.Context) error {
if common.Dryrun(ctx) {
return nil
}
cmd := exec.Command(e.executorPath, "start", "--no-graphics", e.VMName)
cmd.Stdout = e.Stdout
cmd.Stderr = e.Stderr
if err := cmd.Start(); err != nil {
return err
}
e.runCmd = cmd
e.runDone = make(chan struct{})
go func() {
e.runErr = cmd.Wait()
close(e.runDone)
}()
e.running = true
return e.waitReady(ctx)
}
}
func (e *MacOSVMEnvironment) waitReady(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, e.BootTimeout)
defer cancel()
var lastErr error
for {
if err := e.runExecutor(ctx, nil, io.Discard, io.Discard, "exec", e.VMName, "/usr/bin/true"); err == nil {
return nil
} else {
lastErr = err
}
select {
case <-ctx.Done():
return fmt.Errorf("macOS VM %s did not become ready: %w", e.VMName, lastErr)
case <-e.runDone:
if e.runErr != nil {
return fmt.Errorf("macOS VM start exited before the VM became ready: %w", e.runErr)
}
return errors.New("macOS VM start exited before the VM became ready")
case <-time.After(macOSVMReadyPollInterval):
}
}
}
func (e *MacOSVMEnvironment) Exec(command []string, env map[string]string, _, workdir string) common.Executor {
return func(ctx context.Context) error {
if common.Dryrun(ctx) {
return nil
}
err := e.runExecutor(ctx, nil, e.Stdout, e.Stderr, macOSVMExecArgs(e.VMName, command, env, e.resolveWorkdir(workdir))...)
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
return ExitCodeError(exitErr.ExitCode())
}
return err
}
}
func macOSVMExecArgs(vmName string, command []string, env map[string]string, workdir string) []string {
args := []string{"exec", vmName, "/usr/bin/env"}
for _, key := range slices.Sorted(maps.Keys(env)) {
args = append(args, key+"="+env[key])
}
args = append(args, "/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", workdir)
return append(args, command...)
}
func (e *MacOSVMEnvironment) resolveWorkdir(workdir string) string {
switch {
case workdir == "":
return e.GuestWorkdir
case strings.HasPrefix(workdir, "/"):
return workdir
default:
return path.Join(e.GuestWorkdir, workdir)
}
}
func (e *MacOSVMEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
return parseEnvFile(e, srcPath, env)
}
func (e *MacOSVMEnvironment) UpdateFromImageEnv(*map[string]string) common.Executor {
return func(context.Context) error {
return nil
}
}
func (e *MacOSVMEnvironment) Remove() common.Executor {
return func(ctx context.Context) error {
if common.Dryrun(ctx) {
return nil
}
cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
defer cancel()
if e.runCmd != nil && e.runDone != nil {
select {
case <-e.runDone:
default:
if err := e.runExecutor(cleanCtx, nil, e.Stdout, e.Stderr, "stop", e.VMName, "--timeout", "20"); err != nil {
common.Logger(ctx).Debugf("macOS VM stop failed for VM %s: %v", e.VMName, err)
}
select {
case <-e.runDone:
case <-time.After(macOSVMStopWaitTimeout):
if e.runCmd.Process != nil {
_ = e.runCmd.Process.Kill()
}
common.Logger(ctx).Warnf("timed out waiting for macOS VM start to exit for VM %s", e.VMName)
}
}
}
e.running = false
if err := e.runExecutor(cleanCtx, nil, e.Stdout, e.Stderr, "destroy", e.VMName); err != nil {
common.Logger(ctx).Warnf("failed to destroy macOS VM %s: %v", e.VMName, err)
}
return nil
}
}
func (e *MacOSVMEnvironment) Close() common.Executor {
return func(ctx context.Context) error {
if common.Dryrun(ctx) {
return nil
}
if e.runCmd != nil && e.runCmd.Process != nil {
_ = e.runCmd.Process.Kill()
}
e.runCmd = nil
return nil
}
}
func (e *MacOSVMEnvironment) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
oldOut := e.Stdout
oldErr := e.Stderr
e.Stdout = stdout
e.Stderr = stderr
return oldOut, oldErr
}
func (e *MacOSVMEnvironment) ToContainerPath(hostPath string) string {
if filepath.Clean(hostPath) == filepath.Clean(e.Workdir) {
return e.GuestWorkdir
}
if rel, err := filepath.Rel(e.Workdir, hostPath); err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return path.Join(e.GuestWorkdir, filepath.ToSlash(rel))
}
return filepath.ToSlash(hostPath)
}
func (e *MacOSVMEnvironment) GetActPath() string {
return e.ActPath
}
func (*MacOSVMEnvironment) GetPathVariableName() string {
return "PATH"
}
func (*MacOSVMEnvironment) DefaultPathVariable() string {
return "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
}
func (*MacOSVMEnvironment) JoinPathVariable(paths ...string) string {
return strings.Join(paths, ":")
}
func (e *MacOSVMEnvironment) GetRunnerContext(context.Context) map[string]any {
return map[string]any{
"os": "macOS",
"arch": goArchToActionArch(runtime.GOARCH),
"temp": e.TmpDir,
"tool_cache": e.ToolCache,
}
}
func (*MacOSVMEnvironment) IsEnvironmentCaseInsensitive() bool {
return false
}
func (e *MacOSVMEnvironment) runExecutor(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args ...string) error {
if stdout == nil {
stdout = e.Stdout
}
if stderr == nil {
stderr = e.Stderr
}
cmd := exec.CommandContext(ctx, e.executorPath, args...)
cmd.Stdin = stdin
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("macOS VM executor %s: %w", args[0], err)
}
return nil
}
@@ -0,0 +1,97 @@
//go:build macos_vm_integration
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"archive/tar"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestMacOSVMEnvironmentIntegration(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("macOS VMs run on macOS")
}
if _, err := exec.LookPath("runner-executor-macos"); err != nil {
t.Skip("runner-executor-macos executable not found")
}
image := os.Getenv("MACOS_VM_TEST_IMAGE")
if image == "" {
t.Skip("set MACOS_VM_TEST_IMAGE to a macOS VM image to run this test")
}
ctx := context.Background()
vmName := fmt.Sprintf("gitea-runner-test-%d", time.Now().UnixNano())
base := "/Users/admin/runner"
if os.Getenv("RUNNER_EXECUTOR_MACOS_DEV_MODE") == "1" {
base = t.TempDir()
}
env, err := NewMacOSVMEnvironment(MacOSVMEnvironmentInput{
Path: base + "/scratch/" + vmName,
TmpDir: base + "/scratch/" + vmName + "/tmp",
ToolCache: base + "/scratch/" + vmName + "/tool_cache",
Workdir: t.TempDir(),
GuestWorkdir: base + "/workspace/" + vmName,
ActPath: base + "/scratch/" + vmName + "/act",
Image: image,
VMName: vmName,
ExecutorPath: "runner-executor-macos",
BootTimeout: 10 * time.Minute,
Stdout: io.Discard,
Stderr: io.Discard,
})
require.NoError(t, err)
t.Cleanup(func() {
_ = env.Remove()(context.Background())
})
require.NoError(t, env.Create(nil, nil)(ctx))
require.NoError(t, env.Start(false)(ctx))
if os.Getenv("RUNNER_EXECUTOR_MACOS_DEV_MODE") == "1" {
require.NoError(t, os.MkdirAll(env.GuestWorkdir, 0o755))
}
require.NoError(t, env.Exec([]string{"/bin/mkdir", "-p", env.GuestWorkdir}, nil, "", "/")(ctx))
require.NoError(t, env.Copy("/tmp", &FileEntry{
Name: "gitea-runner-macos.txt",
Mode: 0o644,
Body: "hello from macos-vm",
})(ctx))
archive, err := env.GetContainerArchive(ctx, "/tmp/gitea-runner-macos.txt")
require.NoError(t, err)
body := readFirstTarFile(t, archive)
require.NoError(t, archive.Close())
require.Equal(t, "hello from macos-vm", body)
var output bytes.Buffer
oldOut, oldErr := env.ReplaceLogWriter(&output, &output)
defer env.ReplaceLogWriter(oldOut, oldErr)
require.NoError(t, env.Exec([]string{"/usr/bin/uname", "-s"}, nil, "", "")(ctx))
require.Contains(t, output.String(), "Darwin")
require.NoError(t, env.Remove()(ctx))
}
func readFirstTarFile(t *testing.T, archive io.Reader) string {
t.Helper()
reader := tar.NewReader(archive)
header, err := reader.Next()
require.NoError(t, err)
require.Contains(t, header.Name, "gitea-runner-macos.txt")
body, err := io.ReadAll(reader)
require.NoError(t, err)
return string(body)
}
@@ -0,0 +1,91 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package container
import (
"context"
"io"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewMacOSVMEnvironment(t *testing.T) {
env, err := NewMacOSVMEnvironment(MacOSVMEnvironmentInput{
Image: "ghcr.io/cirruslabs/macos-sonoma-base:latest",
VMName: "job-vm",
})
require.NoError(t, err)
assert.Equal(t, "ghcr.io/cirruslabs/macos-sonoma-base:latest", env.Image)
assert.Equal(t, "job-vm", env.VMName)
assert.Equal(t, defaultMacOSVMBootTimeout, env.BootTimeout)
assert.Equal(t, "runner-executor-macos", env.executorPath)
_, err = NewMacOSVMEnvironment(MacOSVMEnvironmentInput{VMName: "job-vm"})
require.Error(t, err)
_, err = NewMacOSVMEnvironment(MacOSVMEnvironmentInput{Image: "macos"})
require.Error(t, err)
}
func TestMacOSVMExecArgs(t *testing.T) {
args := macOSVMExecArgs("vm", []string{"bash", "-c", "echo hi"}, map[string]string{"B": "two", "A": "one"}, "/Users/admin/work")
assert.Equal(t, []string{
"exec", "vm", "/usr/bin/env",
"A=one", "B=two",
"/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", "/Users/admin/work",
"bash", "-c", "echo hi",
}, args)
args = macOSVMExecArgs("vm", []string{"true"}, nil, "")
assert.Equal(t, []string{
"exec", "vm", "/usr/bin/env",
"/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", "",
"true",
}, args)
}
func TestMacOSVMEnvironmentResolveWorkdir(t *testing.T) {
env := &MacOSVMEnvironment{GuestWorkdir: "/Users/admin/work"}
assert.Equal(t, "/Users/admin/work", env.resolveWorkdir(""))
assert.Equal(t, "/Users/admin/work/sub", env.resolveWorkdir("sub"))
assert.Equal(t, "/tmp", env.resolveWorkdir("/tmp"))
}
func TestMacOSVMEnvironmentToContainerPath(t *testing.T) {
env := &MacOSVMEnvironment{Workdir: "/workspace/owner/repo", GuestWorkdir: "/Users/admin/work/owner/repo"}
assert.Equal(t, "/Users/admin/work/owner/repo", env.ToContainerPath("/workspace/owner/repo"))
assert.Equal(t, "/Users/admin/work/owner/repo/sub", env.ToContainerPath("/workspace/owner/repo/sub"))
assert.Equal(t, "/elsewhere", env.ToContainerPath("/elsewhere"))
}
func TestMacOSVMEnvironmentGetRunnerContext(t *testing.T) {
env := &MacOSVMEnvironment{
TmpDir: "/Users/admin/scratch/tmp",
ToolCache: "/Users/admin/scratch/tool_cache",
}
ctx := env.GetRunnerContext(context.Background())
assert.Equal(t, "macOS", ctx["os"])
assert.Equal(t, goArchToActionArch(runtime.GOARCH), ctx["arch"])
assert.Equal(t, "/Users/admin/scratch/tmp", ctx["temp"])
assert.Equal(t, "/Users/admin/scratch/tool_cache", ctx["tool_cache"])
}
func TestMacOSVMEnvironmentReplaceLogWriter(t *testing.T) {
oldOut := io.Discard
oldErr := io.Discard
env := &MacOSVMEnvironment{Stdout: oldOut, Stderr: oldErr}
newOut, newErr := env.ReplaceLogWriter(io.Discard, io.Discard)
assert.Equal(t, oldOut, newOut)
assert.Equal(t, oldErr, newErr)
}
func TestMacOSVMEnvironmentDefaultPathVariable(t *testing.T) {
env := &MacOSVMEnvironment{}
assert.Equal(t, "PATH", env.GetPathVariableName())
assert.Equal(t, "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin", env.DefaultPathVariable())
assert.Equal(t, "/a:/b", env.JoinPathVariable("/a", "/b"))
assert.False(t, env.IsEnvironmentCaseInsensitive())
}
+4 -11
View File
@@ -94,11 +94,10 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
}
type FileCollector struct {
Ignorer gitignore.Matcher
SrcPath string
SrcPrefix string
SkipGitDir bool
Handler Handler
Ignorer gitignore.Matcher
SrcPath string
SrcPrefix string
Handler Handler
}
func openGitIndex(path string) (*index.Index, error) {
@@ -125,12 +124,6 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." {
return nil
}
if fc.SkipGitDir && split[len(split)-1] == ".git" {
if fi.IsDir() {
return filepath.SkipDir
}
return nil
}
var entry *index.Entry
if i != nil {
entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/"))
+1 -1
View File
@@ -154,7 +154,7 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
return err
}
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore, true)(ctx)
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(ctx)
}
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
+2 -2
View File
@@ -227,7 +227,7 @@ func TestActionRunner(t *testing.T) {
ctx := context.Background()
cm := &containerMock{}
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false, true).Return(func(ctx context.Context) error { return nil })
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false).Return(func(ctx context.Context) error { return nil })
envMatcher := mock.MatchedBy(func(env map[string]string) bool {
for k, v := range tt.expectedEnv {
@@ -337,7 +337,7 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
copyEntered := make(chan struct{})
cm := &containerMock{}
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false, true).Return(func(ctx context.Context) error {
cm.On("CopyDir", "/var/run/act/actions/", actionDir+"/", false).Return(func(ctx context.Context) error {
close(copyEntered)
<-releaseCopy
return nil
+2 -2
View File
@@ -57,8 +57,8 @@ func (cm *containerMock) Copy(destPath string, files ...*container.FileEntry) co
return args.Get(0).(func(context.Context) error)
}
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
args := cm.Called(destPath, srcPath, useGitIgnore, skipGitDir)
func (cm *containerMock) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
args := cm.Called(destPath, srcPath, useGitIgnore)
return args.Get(0).(func(context.Context) error)
}
-102
View File
@@ -1,102 +0,0 @@
// 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)
}
}
+6 -7
View File
@@ -263,19 +263,18 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
return err
})
return common.Executor(func(ctx context.Context) error {
if err := info.startContainer()(ctx); err != nil {
return err
}
return newStepsExecutor(rc, preSteps, steps).Finally(func(ctx context.Context) error {
stepsExecutor := newStepsExecutor(rc, preSteps, steps)
return common.NewPipelineExecutor(info.startContainer(), stepsExecutor.
Finally(func(ctx context.Context) error {
// Record an interrupt (backstop for interrupts that land outside the main
// step loop) so the post steps observe the cancelled/failed job status.
rc.markInterrupted(ctx.Err())
postCtx, cancel := postStepsContext(ctx)
defer cancel()
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
+5 -38
View File
@@ -300,8 +300,6 @@ func TestNewJobExecutor(t *testing.T) {
result string
hasError bool
output string
startError error
cancelOnStart bool
}{
{
name: "zeroSteps",
@@ -437,21 +435,6 @@ func TestNewJobExecutor(t *testing.T) {
result: "failure",
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 {
@@ -462,8 +445,7 @@ func TestNewJobExecutor(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
ctx, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
defer cancel()
ctx := common.WithJobErrorContainer(context.Background())
jim := &jobInfoMock{}
sfm := &stepFactoryMock{}
rc := &RunContext{
@@ -488,12 +470,9 @@ func TestNewJobExecutor(t *testing.T) {
jim.On("steps").Return(tt.steps)
if len(tt.steps) > 0 {
jim.On("startContainer").Return(func(_ context.Context) error {
jim.On("startContainer").Return(func(ctx context.Context) error {
executorOrder = append(executorOrder, "startContainer")
if tt.cancelOnStart {
cancel()
}
return tt.startError
return nil
})
}
@@ -527,7 +506,7 @@ func TestNewJobExecutor(t *testing.T) {
defer sm.AssertExpectations(t)
}
if len(tt.steps) > 0 && tt.startError == nil {
if len(tt.steps) > 0 {
jim.On("matrix").Return(map[string]any{})
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
@@ -541,17 +520,12 @@ func TestNewJobExecutor(t *testing.T) {
if contains("stopContainer", tt.executedSteps) {
jim.On("stopContainer").Return(func(ctx context.Context) error {
executorOrder = append(executorOrder, "stopContainer")
require.NoError(t, ctx.Err())
_, bounded := ctx.Deadline()
require.True(t, bounded)
return nil
})
}
jim.On("result", tt.result)
}
if len(tt.steps) > 0 {
jim.On("closeContainer").Return(func(ctx context.Context) error {
executorOrder = append(executorOrder, "closeContainer")
return nil
@@ -560,14 +534,7 @@ func TestNewJobExecutor(t *testing.T) {
executor := newJobExecutor(jim, sfm, rc)
err := executor(ctx)
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.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
assert.Empty(t, rc.Run.Job().Outputs["bad"])
assert.Equal(t, tt.executedSteps, executorOrder)
+1 -1
View File
@@ -244,7 +244,7 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
var copied string
cm.On("CopyDir", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(func(context.Context) error {
cm.On("CopyDir", mock.Anything, mock.Anything, mock.Anything).Return(func(context.Context) error {
body, err := os.ReadFile(script)
require.NoError(t, err)
copied = string(body)
+167 -79
View File
@@ -17,6 +17,7 @@ import (
"io"
maps0 "maps"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
@@ -28,6 +29,7 @@ import (
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/act/ghcontext"
"gitea.com/gitea/runner/internal/pkg/labels"
"gitea.com/gitea/runner/internal/pkg/lock"
"gitea.dev/actionslib/pkg/exprparser"
@@ -96,7 +98,6 @@ type RunContext struct {
jobContainerID string
hasBash *bool // memoized implicit-shell probe, only set on the top-level RunContext
jobNetworkName string
dockerProxy *container.DockerProxy
// stepEnv is a copy of the running step's environment, so that workflow commands parsed out
// of the container's output can be judged against it. Written by runStepExecutor and read on
// the log-writer goroutine, hence unsecureCommandMu, which also guards unsecureCommandErr.
@@ -254,28 +255,11 @@ func (rc *RunContext) validVolumes() []string {
if rc.Config.BindWorkdir {
volumes = append(volumes, rc.Config.Workdir)
}
if rc.dockerProxy != nil {
volumes = append(volumes, rc.dockerProxy.Socket)
}
// TODO: add a new configuration to control whether the docker daemon can be mounted
return append(volumes, name, name+"-env",
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
}
func (rc *RunContext) jobDockerSocket() string {
if rc.dockerProxy != nil {
return rc.dockerProxy.Socket
}
return getDockerDaemonSocketMountPath(rc.containerDaemonSocket())
}
func (rc *RunContext) startDockerProxy(ctx context.Context) {
if !filepath.IsAbs(strings.TrimPrefix(rc.containerDaemonSocket(), "unix://")) || common.Dryrun(ctx) {
return
}
rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName())
}
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
func (rc *RunContext) toolCache(fallback string) string {
if path := rc.GetEnv()["RUNNER_TOOL_CACHE"]; path != "" {
@@ -345,8 +329,8 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string, error) {
// the runner's own mounts below yield to the targets the job claims
binds, mounts, claimed := splitVolumes(volumes)
if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock")
if daemonSocket := rc.containerDaemonSocket(); daemonSocket != "-" && !claimed["/var/run/docker.sock"] {
binds = append(binds, getDockerDaemonSocketMountPath(daemonSocket)+":/var/run/docker.sock")
}
if rc.Config.SharedToolCache {
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
@@ -442,6 +426,113 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
}
}
// printStartMacOSVMGroup mirrors the "Starting job container" section for macOS VMs.
func printStartMacOSVMGroup(ctx context.Context, image, name string) func() {
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
rawLogger.Infof("::group::Starting job VM")
rawLogger.Infof("image: %s", image)
rawLogger.Infof("name: %s", name)
return func() {
rawLogger.Infof("::endgroup::")
}
}
func (rc *RunContext) startMacOSVMEnvironment() common.Executor {
return func(ctx context.Context) error {
if len(rc.Run.Job().Services) > 0 {
return errors.New("service containers are not supported with the macOS VM executor; see docs/macos-vm-executor.md")
}
image := rc.macOSVMImage()
logWriter := rc.commandLogWriter(ctx)
name := rc.macOSVMName()
rc.Env["JOB_CONTAINER_NAME"] = name
guestWorkdir := rc.macOSVMGuestWorkdir()
scratch := path.Join("/", rc.macOSVMWorkdirParent(), "scratch", name)
actPath := path.Join(scratch, "act")
tmpDir := path.Join(scratch, "tmp")
toolCache := rc.toolCache(path.Join(scratch, "tool_cache"))
jobContainer, err := container.NewMacOSVMEnvironment(container.MacOSVMEnvironmentInput{
Path: scratch,
TmpDir: tmpDir,
ToolCache: toolCache,
Workdir: rc.Config.Workdir,
GuestWorkdir: guestWorkdir,
ActPath: actPath,
Image: image,
VMName: name,
ExecutorPath: rc.Config.MacOSVM.ExecutorPath,
CPU: rc.Config.MacOSVM.CPU,
Memory: rc.Config.MacOSVM.Memory,
BootTimeout: rc.Config.MacOSVM.BootTimeout,
Stdout: logWriter,
Stderr: logWriter,
})
if err != nil {
return err
}
rc.JobContainer = jobContainer
rc.cleanUpJobContainer = jobContainer.Remove()
for k, v := range rc.getRunnerContext(ctx) {
if v, ok := v.(string); ok {
rc.Env["RUNNER_"+strings.ToUpper(k)] = v
}
}
for _, env := range os.Environ() {
if k, v, ok := strings.Cut(env, "="); ok {
if _, ok := rc.Env[k]; !ok {
rc.Env[k] = v
}
}
}
defer printStartMacOSVMGroup(ctx, image, name)()
return common.NewPipelineExecutor(
jobContainer.Pull(rc.Config.ForcePull),
jobContainer.Create(nil, nil),
jobContainer.Start(false),
jobContainer.Exec([]string{"/bin/mkdir", "-p", guestWorkdir, actPath, tmpDir, toolCache}, nil, "", "/"),
rc.captureJobContainerInfo(),
jobContainer.Copy(jobContainer.GetActPath()+"/", &container.FileEntry{
Name: "workflow/event.json",
Mode: 0o644,
Body: rc.EventJSON,
}, &container.FileEntry{
Name: "workflow/envs.txt",
Mode: 0o666,
Body: "",
}),
)(ctx)
}
}
func (rc *RunContext) macOSVMImage() string {
return strings.TrimPrefix(rc.platformImage, labels.MacOSVMSchemePrefix)
}
func (rc *RunContext) macOSVMName() string {
nameParts := []string{"GITEA-MACOS-VM", rc.Config.RunnerUUID, rc.Config.ContainerNamePrefix, "WORKFLOW-" + rc.Run.Workflow.Name, "JOB-" + rc.Run.JobID, rc.Name}
if rc.caller != nil {
nameParts = append(nameParts, "CALLED-BY-"+rc.caller.runContext.JobName)
}
return createContainerName(nameParts...)
}
func (rc *RunContext) macOSVMGuestWorkdir() string {
rel := strings.TrimLeft(filepath.ToSlash(rc.Config.Workdir), "/")
return path.Join("/", rc.macOSVMWorkdirParent(), rel)
}
func (rc *RunContext) macOSVMWorkdirParent() string {
if rc.Config.MacOSVM.WorkdirParent != "" {
return rc.Config.MacOSVM.WorkdirParent
}
return "/Users/admin/runner"
}
// printStartJobContainerGroup mirrors actions/runner's "Starting job container"
// section: emit the group header and summary, return a closer for ::endgroup::.
func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() {
@@ -479,11 +570,16 @@ func (rc *RunContext) startJobContainer() common.Executor {
envList = append(envList, rc.runnerEnv(ctx)...)
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>)
// if using service containers, will create a new network for the containers.
// and it will be removed after at last.
networkName, createAndDeleteNetwork := rc.networkNameForGitea()
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork)
// add service containers
for serviceID, spec := range rc.Run.Job().Services {
@@ -574,6 +670,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
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`
jobContainerNetwork := networkName
@@ -581,8 +679,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
if err != nil {
return err
}
ext := container.LinuxContainerEnvironmentExtensions{}
containerInput := &container.NewContainerInput{
rc.JobContainer = newContainer(&container.NewContainerInput{
Cmd: nil,
Entrypoint: []string{"/bin/sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
@@ -591,8 +688,10 @@ func (rc *RunContext) startJobContainer() common.Executor {
Password: password,
Name: name,
Env: envList,
Mounts: mounts,
NetworkMode: jobContainerNetwork,
NetworkAliases: []string{rc.Name},
Binds: binds,
Stdout: logWriter,
Stderr: logWriter,
Privileged: rc.Config.Privileged,
@@ -601,29 +700,20 @@ func (rc *RunContext) startJobContainer() common.Executor {
RunnerOptions: rc.Config.ContainerOptions,
WorkflowOptions: workflowOptions,
AutoRemove: true,
ValidVolumes: rc.validVolumes(),
AllocatePTY: rc.Config.AllocatePTY,
}
rc.JobContainer = newContainer(containerInput)
})
if rc.JobContainer == nil {
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
defer printStartJobContainerGroup(ctx, image, name, networkName)()
return common.NewPipelineExecutor(
rc.pullServicesImages(rc.Config.ForcePull),
rc.JobContainer.Pull(rc.Config.ForcePull),
rc.stopJobContainer(),
container.NewDockerNetworkCreateExecutor(networkName, rc.Config.ContainerNetworkCreateOptions).
IfBool(createAndDeleteNetwork),
rc.startServiceContainers(),
@@ -653,46 +743,41 @@ 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 {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
errs := []error{rc.closeDockerProxy(ctx)}
if rc.JobContainer != nil {
removeJobContainer := rc.JobContainer != nil
var errs []error
if removeJobContainer {
errs = append(errs, rc.JobContainer.Remove()(ctx))
}
if len(rc.serviceContainers) > 0 {
logger.Infof("Cleaning up services for job %s", rc.JobName)
errs = append(errs, rc.stopServiceContainers()(ctx))
if err := rc.stopServiceContainers()(ctx); err != nil {
logger.Errorf("Error while cleaning services: %v", err)
}
}
if !common.Dryrun(ctx) {
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName()))
}
if rc.JobContainer != nil {
if removeJobContainer {
// after the containers using them, services can hold these via `--volumes-from`
name := rc.jobContainerName()
errs = append(errs,
container.NewDockerVolumeRemoveExecutor(name, false)(ctx),
container.NewDockerVolumeRemoveExecutor(name+"-env", false)(ctx))
}
if createAndDeleteNetwork {
// last, once every container has detached
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
errs = append(errs, container.NewDockerNetworkRemoveExecutor(networkName)(ctx))
if err := container.NewDockerNetworkRemoveExecutor(networkName)(ctx); err != nil {
logger.Errorf("Error while cleaning network: %v", err)
}
}
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) {
if len(rc.ExtraPath) > 0 {
path := rc.JobContainer.GetPathVariableName()
@@ -752,6 +837,7 @@ func (rc *RunContext) UpdateExtraPath(ctx context.Context, githubEnvPath string)
return nil
}
// stopJobContainer removes the job container (if it exists) and its volume (if it exists)
func (rc *RunContext) stopJobContainer() common.Executor {
return func(ctx context.Context) error {
if rc.cleanUpJobContainer != nil {
@@ -788,17 +874,10 @@ func (rc *RunContext) startServiceContainers() common.Executor {
func (rc *RunContext) stopServiceContainers() common.Executor {
return func(ctx context.Context) error {
execs := []common.Executor{}
errs := make([]error, len(rc.serviceContainers))
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
})
for _, svc := range rc.serviceContainers {
execs = append(execs, svc.container.Remove().Finally(svc.container.Close()))
}
errs = append(errs, common.NewParallelExecutor(len(execs), execs...)(ctx))
return errors.Join(errs...)
return common.NewParallelExecutor(len(execs), execs...)(ctx)
}
}
@@ -944,9 +1023,6 @@ func (rc *RunContext) captureJobContainerInfo() common.Executor {
return nil
}
rc.jobContainerID = info.ID
if source := info.Mounts[rc.githubWorkspace()]; source != "" {
rc.Env["GITEA_DOCKER_WORKSPACE"] = source
}
return nil
}
}
@@ -1013,9 +1089,12 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
func (rc *RunContext) startContainer() common.Executor {
return func(ctx context.Context) error {
var err error
if rc.IsHostEnv() {
switch {
case rc.IsHostEnv():
err = rc.startHostEnvironment()(ctx)
} else {
case rc.IsMacOSVMEnv():
err = rc.startMacOSVMEnvironment()(ctx)
default:
err = rc.startJobContainer()(ctx)
}
if err != nil {
@@ -1028,13 +1107,17 @@ func (rc *RunContext) startContainer() common.Executor {
}
func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
defer cancel()
cleanup := rc.cleanUpJobContainer
if cleanup == nil {
cleanup = rc.closeDockerProxy
if rc.cleanUpJobContainer == nil {
return
}
if err := cleanup(cleanCtx); err != nil {
cleanCtx := ctx
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)
}
}
@@ -1043,6 +1126,11 @@ func (rc *RunContext) IsHostEnv() bool {
return strings.EqualFold(rc.platformImage, "-self-hosted")
}
// IsMacOSVMEnv reports whether the resolved platform image requests a macOS VM.
func (rc *RunContext) IsMacOSVMEnv() bool {
return strings.HasPrefix(rc.platformImage, labels.MacOSVMSchemePrefix)
}
func (rc *RunContext) stopContainer() common.Executor {
return rc.stopJobContainer()
}
+36 -69
View File
@@ -10,8 +10,6 @@ import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"runtime"
"strings"
@@ -20,6 +18,7 @@ import (
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/container"
"gitea.com/gitea/runner/internal/pkg/labels"
"gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model"
@@ -223,9 +222,8 @@ func (fakeContainer) Start(bool) common.Executor { return func(context.Context)
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) GetActPath() string { return "/var/run/act" }
func (fakeContainer) ToContainerPath(path string) string { return path }
func (fakeContainer) Close() common.Executor { return func(context.Context) error { return nil } }
func (fakeContainer) GetActPath() string { return "/var/run/act" }
func (fakeContainer) Create([]string, []string) common.Executor {
return func(context.Context) error { return nil }
}
@@ -272,26 +270,9 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
require.NoError(t, rc.resolvePlatformImage(t.Context()))
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
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()))
// the inputs are built before the missing daemon fails the first call
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
require.Error(t, rc.startJobContainer()(t.Context()))
return inputs
}
@@ -647,7 +628,7 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
}
err := rc.cleanupJobResources("external-network", false)(common.WithDryrun(t.Context(), true))
err := rc.cleanupJobResources("external-network", false)(context.Background())
require.NoError(t, err)
service.AssertExpectations(t)
}
@@ -656,12 +637,11 @@ func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
t.Setenv("DOCKER_HOST", "unix:///nonexistent.sock")
removeError, closeError := errors.New("remove service"), errors.New("close service")
jobContainer := &containerMock{}
jobContainer.On("Remove").Return(func(context.Context) error { return errors.New("removal failed") }).Once()
service := &containerMock{}
service.On("Remove").Return(func(context.Context) error { return removeError }).Once()
service.On("Close").Return(func(context.Context) error { return closeError }).Once()
service.On("Remove").Return(func(context.Context) error { return nil }).Once()
service.On("Close").Return(func(context.Context) error { return nil }).Once()
rc := &RunContext{
Name: "job",
@@ -673,11 +653,7 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
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)
require.Error(t, rc.cleanupJobResources("job-network", true)(ctx))
jobContainer.AssertExpectations(t)
service.AssertExpectations(t)
}
@@ -1094,19 +1070,12 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
calls int
err error
sentinel any
cancel context.CancelFunc
}
newRC := func(c *capture) *RunContext {
return &RunContext{
JobName: "job",
cleanUpJobContainer: func(ctx context.Context) error {
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.sentinel = ctx.Value(sentinel)
return nil
@@ -1114,10 +1083,9 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
}
}
t.Run("detaches teardown from cancellation during cleanup", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), sentinel, "v"))
defer cancel()
c := capture{cancel: cancel}
t.Run("runs teardown on the live context", func(t *testing.T) {
var c capture
ctx := context.WithValue(context.Background(), sentinel, "v")
newRC(&c).cleanupFailedStart(ctx)
@@ -1135,7 +1103,7 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
assert.Equal(t, 1, c.calls)
require.NoError(t, c.err)
assert.Equal(t, "v", c.sentinel)
assert.Nil(t, c.sentinel)
})
t.Run("no-op when there is nothing to clean up", func(t *testing.T) {
@@ -1327,29 +1295,6 @@ func TestGetJobContextReportsContainers(t *testing.T) {
}, jobContext.Services)
}
func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) {
job := &containerMock{}
job.On("Inspect", mock.Anything).Return(&container.Info{
ID: "job-container-id",
Mounts: map[string]string{"/workspace/owner/repo": "/var/lib/docker/volumes/job/_data"},
}, nil)
rc := &RunContext{
Config: &Config{Workdir: "/workspace/owner/repo/"},
Env: map[string]string{},
JobContainer: job,
}
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
assert.Equal(t, "job-container-id", rc.jobContainerID)
assert.Equal(t, "/var/lib/docker/volumes/job/_data", rc.Env["GITEA_DOCKER_WORKSPACE"])
rc.Config.Workdir = "/elsewhere"
rc.Env = map[string]string{}
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
assert.NotContains(t, rc.Env, "GITEA_DOCKER_WORKSPACE")
}
// A job that never started a container reports an empty context, not a placeholder.
func TestGetJobContextWithoutContainer(t *testing.T) {
jobContext := (&RunContext{}).getJobContext()
@@ -1514,3 +1459,25 @@ func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"])
assert.Equal(t, "1", env["RUNNER_DEBUG"])
}
func TestRunContextMacOSVMPlatform(t *testing.T) {
rc := createRunsOnRunContext(t, "macos-latest")
rc.Config.PlatformPicker = func([]string) string {
return labels.MacOSVMSchemePrefix + "ghcr.io/cirruslabs/macos-sonoma-base:latest"
}
require.NoError(t, rc.resolvePlatformImage(t.Context()))
assert.True(t, rc.IsMacOSVMEnv())
assert.False(t, rc.IsHostEnv())
assert.Equal(t, "ghcr.io/cirruslabs/macos-sonoma-base:latest", rc.macOSVMImage())
}
func TestRunContextMacOSVMGuestWorkdir(t *testing.T) {
rc := createRunsOnRunContext(t, "macos-latest")
rc.Config.Workdir = "/workspace/owner/repo"
rc.Config.MacOSVM.WorkdirParent = "/Users/admin/runner"
rc.Config.RunnerUUID = "runner-1"
assert.Equal(t, "/Users/admin/runner/workspace/owner/repo", rc.macOSVMGuestWorkdir())
assert.Contains(t, rc.macOSVMName(), "GITEA-MACOS-VM-runner-1-")
}
+11
View File
@@ -79,9 +79,20 @@ type Config struct {
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
AllocatePTY bool // allocate a pseudo-TTY for each step's process
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
RunnerUUID string // UUID this runner registered with, used to identify its macOS VMs
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
MacOSVM MacOSVMConfig // configures the macOS VM executor
}
// MacOSVMConfig contains the macOS VM executor settings that come from the runner config.
type MacOSVMConfig struct {
ExecutorPath string // path to the runner-executor-macos binary
WorkdirParent string // guest directory under which the job workspace is created
CPU int // number of virtual CPUs, 0 keeps the image default
Memory int // VM memory in megabytes, 0 keeps the image default
BootTimeout time.Duration // how long to wait for the VM to become ready
}
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
+1 -1
View File
@@ -169,7 +169,7 @@ func (sar *stepActionRemote) main() common.Executor {
return fmt.Errorf("unable to interpolate with.path: %w", err)
}
copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), checkoutPath)
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx)
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx)
}
return sar.runAction(sar, sar.actionDir(), sar.remoteAction)(ctx)
-6
View File
@@ -1,6 +0,0 @@
name: docker-proxy
description: Verify mounted Docker access through post steps
runs:
using: node24
main: index.js
post: index.js
-53
View File
@@ -1,53 +0,0 @@
const assert = require('node:assert/strict');
const {once} = require('node:events');
const fs = require('node:fs');
const http = require('node:http');
async function request(method, path, body) {
const req = http.request({
socketPath: '/var/run/docker.sock',
method,
path,
headers: {'Content-Type': 'application/json'},
signal: AbortSignal.timeout(10000),
});
req.end(JSON.stringify(body));
const [res] = await once(req, 'response');
req.on('error', (error) => res.destroy(error));
let data = '';
for await (const chunk of res.setEncoding('utf8')) {
data += chunk;
}
assert(res.statusCode >= 200 && res.statusCode < 300, `${method} ${path}: ${res.statusCode} ${data}`);
return data;
}
async function main() {
assert(fs.statSync('/var/run/docker.sock').isSocket(), 'Docker mount must be a socket');
assert.equal(await request('GET', '/_ping'), 'OK');
const api = `/v${JSON.parse(await request('GET', '/version')).ApiVersion}`;
const name = process.env.PROXY_TEST_RESOURCE;
const job = process.env.JOB_CONTAINER_NAME;
const post = process.env.STATE_post === 'true';
assert(name);
assert(job);
if (!post) {
await request('POST', `${api}/volumes/create`, {Name: name});
await request('POST', `${api}/networks/create`, {Name: name});
await request('POST', `${api}/networks/${name}/connect`, {Container: job});
fs.appendFileSync(process.env.GITHUB_STATE, 'post=true\n');
}
const label = process.env.PROXY_TEST_MODE === 'proxy' ? job : undefined;
assert.equal(JSON.parse(await request('GET', `${api}/volumes/${name}`)).Labels?.['com.gitea.runner.job'], label);
const network = JSON.parse(await request('GET', `${api}/networks/${name}`));
assert.equal(network.Labels?.['com.gitea.runner.job'], label);
assert(Object.values(network.Containers).some((container) => container.Name === job));
if (post) {
console.log('docker proxy post verified');
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
-8
View File
@@ -1,8 +0,0 @@
name: docker-proxy
on: push
jobs:
proxy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./action
+93
View File
@@ -0,0 +1,93 @@
# macOS VM executor
The runner can execute a job in an ephemeral macOS VM by registering a
`macos-vm` label:
```yaml
runner:
labels:
- macos-latest:macos-vm://registry.example.com/macos-15.2-xcode-26.1
```
The VM is created and managed by `runner-executor-macos`, a dedicated Swift
binary built on Apple Virtualization.framework. The source VM must include the
runner guest agent.
## Configuration
```yaml
macos_vm:
executor_path: runner-executor-macos
workdir_parent: /Users/admin/runner
cpu: 0
memory: 0
boot_timeout: 5m
```
`workdir_parent` is the guest directory under which job workspaces are created.
`executor_path` is the binary to invoke; an empty value uses PATH lookup.
## Executor CLI
The runner uses a narrow, runner-oriented command set:
```text
runner-executor-macos provision <image> <name> [--cpu N] [--memory MB]
runner-executor-macos start <name> [--no-graphics]
runner-executor-macos exec <name> [--stdin] <argv...>
runner-executor-macos stop <name> [--timeout N]
runner-executor-macos destroy <name>
runner-executor-macos list --format json
runner-executor-macos version
```
`list --format json` returns:
```json
[
{
"name": "GITEA-MACOS-VM-<runner-uuid>-...",
"state": "stopped",
"running": false,
"accessed_at": "2026-09-05T12:00:00Z"
}
]
```
## Integration test
The end-to-end test is opt-in because it provisions and boots a real VM:
```bash
MACOS_VM_TEST_IMAGE=/path/or/reference \
go test -tags macos_vm_integration ./act/container -run '^TestMacOSVMEnvironmentIntegration$' -v
```
The test skips when `MACOS_VM_TEST_IMAGE` is unset.
## Orphaned VM cleanup
Job VM names start with `GITEA-MACOS-VM-<runner-uuid>-`. While the runner is
idle, it lists local VMs and destroys the non-running ones whose name has this
runner's UUID and whose access time is older than
`runner.workdir_cleanup_age`.
## Service containers
Service containers are intentionally rejected for macOS VM jobs today. Docker
service containers live on a Docker network and are reached by workflow service
id, which a macOS VM cannot resolve.
Possible future designs:
1. **Host-published Docker services + guest host mapping** — publish each
service port on the runner host and inject `<service-id> -> <host-ip>` into
the VM through the guest agent.
2. **macOS VM services** — run each service as another VM; this requires
service discovery and is much heavier than Docker containers.
3. **Environment-based discovery** — require workflows to read service
addresses from env vars instead of hostnames, breaking GitHub Actions
compatibility.
Option 1 is the most compatible direction once the guest agent and host network
configuration are stable.
+4 -4
View File
@@ -2,7 +2,7 @@ module gitea.com/gitea/runner
go 1.27
toolchain go1.27.1
toolchain go1.27.0
require (
connectrpc.com/connect v1.20.0
@@ -17,7 +17,7 @@ require (
github.com/docker/go-units v0.5.0
github.com/go-git/go-billy/v5 v5.9.1
github.com/go-git/go-git/v5 v5.19.2
github.com/gobwas/glob v1.0.0
github.com/gobwas/glob v0.2.3
github.com/google/go-cmp v0.7.0
github.com/joho/godotenv v1.5.1
github.com/julienschmidt/httprouter v1.3.0
@@ -30,8 +30,8 @@ require (
github.com/opencontainers/image-spec v1.1.1
github.com/opencontainers/selinux v1.15.1
github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.3
github.com/sirupsen/logrus v1.10.2
github.com/prometheus/client_model v0.6.2
github.com/sirupsen/logrus v1.10.1
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.12.1
+6 -6
View File
@@ -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-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
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/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/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_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
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/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/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
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/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+1
View File
@@ -62,6 +62,7 @@ func TestRegisterInputsValidate(t *testing.T) {
func TestValidateLabels(t *testing.T) {
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
require.NoError(t, validateLabels([]string{"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"}))
// a colon that is not a supported schema is part of the label name
require.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"}))
require.Error(t, validateLabels([]string{"ubuntu:host", ""}))
+24 -2
View File
@@ -144,6 +144,9 @@ func (r *Runner) Close() error {
// removeOrphanNetworks is a variable so tests can substitute one that needs no Docker daemon.
var removeOrphanNetworks = container.RemoveOrphanNetworks
// removeOrphanMacOSVMs is a variable so tests can substitute one that needs no macOS VM executor.
var removeOrphanMacOSVMs = container.CleanupOrphanMacOSVMs
// OnIdle performs lightweight maintenance during polling idle windows.
// It runs synchronously on the poller goroutine; shouldRunIdleCleanup
// throttles invocations to runner.idle_cleanup_interval so the impact on
@@ -166,6 +169,7 @@ func (r *Runner) OnIdle(ctx context.Context) {
r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir)
}
r.cleanupOrphanNetworks(ctx)
r.cleanupOrphanMacOSVMs(ctx)
}
// cleanupOrphanNetworks reclaims the per-job networks of jobs this runner did not live to
@@ -182,6 +186,16 @@ func (r *Runner) cleanupOrphanNetworks(ctx context.Context) {
}
}
func (r *Runner) cleanupOrphanMacOSVMs(ctx context.Context) {
if r.uuid == "" || !r.labels.RequireMacOSVM() || r.cfg.Runner.WorkdirCleanupAge <= 0 {
return
}
cutoff := r.now().Add(-r.cfg.Runner.WorkdirCleanupAge)
if err := removeOrphanMacOSVMs(ctx, r.cfg.MacOSVM.ExecutorPath, r.uuid, cutoff); err != nil {
log.Warnf("failed to clean up macOS VMs left behind by earlier jobs: %v", err)
}
}
func (r *Runner) shouldRunIdleCleanup() bool {
if r.cfg.Runner.WorkdirCleanupAge <= 0 || r.cfg.Runner.IdleCleanupInterval <= 0 {
return false
@@ -548,8 +562,16 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
Vars: task.Vars,
ValidVolumes: r.cfg.Container.ValidVolumes,
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
InsecureSkipTLS: r.cfg.Runner.Insecure,
RunnerName: r.name,
MacOSVM: runner.MacOSVMConfig{
ExecutorPath: r.cfg.MacOSVM.ExecutorPath,
WorkdirParent: r.cfg.MacOSVM.WorkdirParent,
CPU: r.cfg.MacOSVM.CPU,
Memory: r.cfg.MacOSVM.Memory,
BootTimeout: r.cfg.MacOSVM.BootTimeout,
},
InsecureSkipTLS: r.cfg.Runner.Insecure,
RunnerUUID: r.uuid,
RunnerName: r.name,
}
rr, err := runner.New(runnerConfig)
@@ -12,6 +12,7 @@ import (
"time"
"gitea.com/gitea/runner/internal/pkg/config"
"gitea.com/gitea/runner/internal/pkg/labels"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -331,3 +332,37 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
hostOnly.OnIdle(context.Background())
assert.Equal(t, []string{"runner-1"}, swept)
}
func TestRunnerOnIdleRemovesOrphanMacOSVMs(t *testing.T) {
now := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
cfg := &config.Config{
Runner: config.Runner{
WorkdirCleanupAge: 24 * time.Hour,
IdleCleanupInterval: time.Minute,
},
}
macOSVMLabel, err := labels.Parse("macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest")
require.NoError(t, err)
var swept []string
var sweptCutoff time.Time
origRemoveOrphanMacOSVMs := removeOrphanMacOSVMs
removeOrphanMacOSVMs = func(_ context.Context, _, runnerUUID string, accessedBefore time.Time) error {
swept = append(swept, runnerUUID)
sweptCutoff = accessedBefore
return nil
}
t.Cleanup(func() { removeOrphanMacOSVMs = origRemoveOrphanMacOSVMs })
r := &Runner{uuid: "runner-1", cfg: cfg, labels: labels.Labels{macOSVMLabel}, now: func() time.Time { return now }}
r.OnIdle(context.Background())
assert.Equal(t, []string{"runner-1"}, swept)
assert.Equal(t, now.Add(-24*time.Hour), sweptCutoff)
// a docker-only runner has no macOS VMs to sweep
dockerLabel, err := labels.Parse("ubuntu:docker://node:18")
require.NoError(t, err)
dockerOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, labels: labels.Labels{dockerLabel}, now: func() time.Time { return now }}
dockerOnly.OnIdle(context.Background())
assert.Equal(t, []string{"runner-1"}, swept)
}
+20 -5
View File
@@ -92,7 +92,8 @@ runner:
# at the price of the stock artifact actions refusing and the cache client keeping to v1.
#patch_actions: true
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# Like: "macos-arm64:host", "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
# or "macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
# If it's empty when registering, it will ask for inputting labels.
# If it's empty when execute `daemon`, will use labels in `.runner` file.
@@ -256,10 +257,11 @@ container:
#require_docker: false
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
#docker_timeout: 0s
# Mount the workspace from a host directory instead of a Docker volume, so jobs
# can bind-mount it by its own path into sibling containers (".:/app" in docker
# compose). Not needed when workflows use the GITEA_DOCKER_WORKSPACE variable.
# The workspace parent directory must be mounted into the runner container.
# Bind the workspace to the host filesystem instead of using Docker volumes.
# This is required for Docker-in-Docker (DinD) setups when jobs use docker compose
# with bind mounts (e.g., ".:/app"), as volume-based workspaces are not accessible
# from the DinD daemon's filesystem. When enabled, ensure the workspace parent
# directory is also mounted into the runner container.
#bind_workdir: false
# How long a job waits for a service container that declares a healthcheck to become
# healthy. A negative value (e.g. -1s) starts the steps without waiting.
@@ -270,6 +272,19 @@ host:
# If it's empty, $HOME/.cache/act/ will be used.
#workdir_parent:
macos_vm:
# Path to the runner-executor-macos binary. Empty uses PATH lookup.
#executor_path: runner-executor-macos
# The guest directory under which the job workspace is created. The VM must run
# as a user that can write there.
#workdir_parent: /Users/admin/runner
# Number of virtual CPUs. Omit or set to 0 to keep the image default.
#cpu: 0
# VM memory in megabytes. Omit or set to 0 to keep the image default.
#memory: 0
# How long to wait for the VM to become ready after it starts.
#boot_timeout: 5m
# Optional local task-admission checks. Disabled by default. When enabled, low
# disk space or a failing script pauses new task fetching; existing jobs continue.
# No health checks run while any job is active; the last result is reused until idle.
+20 -1
View File
@@ -157,7 +157,7 @@ type Container struct {
ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present
RequireDocker bool `yaml:"require_docker"` // Always require a reachable docker daemon, even if not required by runner
DockerTimeout time.Duration `yaml:"docker_timeout"` // Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or runner
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir mounts the workspace from a host directory instead of a Docker volume.
BindWorkdir bool `yaml:"bind_workdir"` // BindWorkdir binds the workspace to the host filesystem instead of using Docker volumes. Required for DinD when jobs use docker compose with bind mounts.
ServiceReadyTimeout time.Duration `yaml:"service_ready_timeout"` // ServiceReadyTimeout bounds how long a job waits for a service container that declares a healthcheck to report healthy. Negative disables waiting.
}
@@ -179,6 +179,15 @@ type Host struct {
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory.
}
// MacOSVM represents the configuration for the macOS VM executor.
type MacOSVM struct {
ExecutorPath string `yaml:"executor_path"` // ExecutorPath is the runner-executor-macos binary to invoke.
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent is the guest directory under which the job workspace is created.
CPU int `yaml:"cpu"` // CPU is the number of virtual CPUs. Zero keeps the image default.
Memory int `yaml:"memory"` // Memory is the VM memory in megabytes. Zero keeps the image default.
BootTimeout time.Duration `yaml:"boot_timeout"` // BootTimeout bounds how long to wait for the VM to become ready.
}
// Metrics represents the configuration for the Prometheus metrics endpoint.
type Metrics struct {
Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed.
@@ -203,6 +212,7 @@ type Config struct {
Cache Cache `yaml:"cache"` // Cache represents the configuration for caching.
Container Container `yaml:"container"` // Container represents the configuration for the container.
Host Host `yaml:"host"` // Host represents the configuration for the host.
MacOSVM MacOSVM `yaml:"macos_vm"` // MacOSVM represents the configuration for the macOS VM executor.
Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint.
HealthCheck HealthCheck `yaml:"health_check"` // HealthCheck controls opt-in local task-admission checks.
}
@@ -300,6 +310,15 @@ func LoadDefault(file string) (*Config, error) {
}
cfg.Host.WorkdirParent = filepath.Join(home, ".cache", "act")
}
if cfg.MacOSVM.ExecutorPath == "" {
cfg.MacOSVM.ExecutorPath = "runner-executor-macos"
}
if cfg.MacOSVM.WorkdirParent == "" {
cfg.MacOSVM.WorkdirParent = "/Users/admin/runner"
}
if cfg.MacOSVM.BootTimeout <= 0 {
cfg.MacOSVM.BootTimeout = 5 * time.Minute
}
if cfg.Runner.FetchTimeout <= 0 {
cfg.Runner.FetchTimeout = 5 * time.Second
}
+25
View File
@@ -67,6 +67,31 @@ func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) {
assert.Equal(t, DefaultImage, cfg.Runner.DefaultImage)
}
func TestLoadDefault_MacOSVMDefaults(t *testing.T) {
cfg, err := LoadDefault("")
require.NoError(t, err)
assert.Equal(t, "runner-executor-macos", cfg.MacOSVM.ExecutorPath)
assert.Equal(t, "/Users/admin/runner", cfg.MacOSVM.WorkdirParent)
assert.Equal(t, 5*time.Minute, cfg.MacOSVM.BootTimeout)
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
macos_vm:
executor_path: /usr/local/bin/runner-executor-macos
workdir_parent: /Users/runner
cpu: 4
memory: 8192
boot_timeout: 3m
`), 0o600))
cfg, err = LoadDefault(path)
require.NoError(t, err)
assert.Equal(t, "/usr/local/bin/runner-executor-macos", cfg.MacOSVM.ExecutorPath)
assert.Equal(t, "/Users/runner", cfg.MacOSVM.WorkdirParent)
assert.Equal(t, 4, cfg.MacOSVM.CPU)
assert.Equal(t, 8192, cfg.MacOSVM.Memory)
assert.Equal(t, 3*time.Minute, cfg.MacOSVM.BootTimeout)
}
func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) {
cfg, err := LoadDefault("")
require.NoError(t, err)
+19 -4
View File
@@ -9,11 +9,15 @@ import (
)
const (
SchemeHost = "host"
SchemeDocker = "docker"
SchemeHost = "host"
SchemeDocker = "docker"
SchemeMacOSVM = "macos-vm"
// SelfHostedPlatform is the platform marker act treats as "run on the host".
SelfHostedPlatform = "-self-hosted"
// MacOSVMSchemePrefix marks a platform image that act should run in a macOS VM.
MacOSVMSchemePrefix = "macos-vm://"
)
type Label struct {
@@ -42,7 +46,7 @@ func Parse(str string) (*Label, error) {
if len(splits) >= 3 {
label.Arg = splits[2]
}
if label.Schema != SchemeHost && label.Schema != SchemeDocker {
if label.Schema != SchemeHost && label.Schema != SchemeDocker && label.Schema != SchemeMacOSVM {
// Not a schema we know: the colon belongs to the label name itself.
return &Label{
Name: str,
@@ -64,6 +68,15 @@ func (l Labels) RequireDocker() bool {
return false
}
func (l Labels) RequireMacOSVM() bool {
for _, label := range l {
if label.Schema == SchemeMacOSVM {
return true
}
}
return false
}
// PickPlatform returns the platform of the first runs-on entry this runner has a label for, or "".
func (l Labels) PickPlatform(runsOn []string) string {
platforms := make(map[string]string, len(l))
@@ -72,10 +85,12 @@ func (l Labels) PickPlatform(runsOn []string) string {
case SchemeDocker:
// "//" will be ignored
platforms[label.Name] = strings.TrimPrefix(label.Arg, "//")
case SchemeMacOSVM:
platforms[label.Name] = MacOSVMSchemePrefix + strings.TrimPrefix(label.Arg, "//")
case SchemeHost:
platforms[label.Name] = SelfHostedPlatform
default:
// unreachable: Parse only produces host or docker schemas
// unreachable: Parse only produces host, docker, or macOS VM schemas
continue
}
}
+32
View File
@@ -25,6 +25,15 @@ func TestParse(t *testing.T) {
},
wantErr: false,
},
{
args: "macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
want: &Label{
Name: "macos-latest",
Schema: "macos-vm",
Arg: "//ghcr.io/cirruslabs/macos-sonoma-base:latest",
},
wantErr: false,
},
{
args: "ubuntu:host",
want: &Label{
@@ -103,6 +112,7 @@ func TestRequireDocker(t *testing.T) {
{"empty", nil, false},
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
{"has docker", []string{"ubuntu:host", "ubuntu:docker://node:18"}, true},
{"macos-vm does not require docker", []string{"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -111,10 +121,29 @@ func TestRequireDocker(t *testing.T) {
}
}
func TestRequireMacOSVM(t *testing.T) {
tests := []struct {
name string
strs []string
want bool
}{
{"empty", nil, false},
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
{"only docker", []string{"ubuntu:docker://node:18"}, false},
{"has macos-vm", []string{"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, mustParse(t, tt.strs...).RequireMacOSVM())
})
}
}
func TestPickPlatform(t *testing.T) {
ls := mustParse(t,
"ubuntu:docker://node:18",
"self-hosted:host",
"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
)
tests := []struct {
@@ -124,6 +153,7 @@ func TestPickPlatform(t *testing.T) {
}{
{"docker strips leading slashes", []string{"ubuntu"}, "node:18"},
{"host maps to self-hosted marker", []string{"self-hosted"}, SelfHostedPlatform},
{"macos-vm prefixes platform marker", []string{"macos-latest"}, MacOSVMSchemePrefix + "ghcr.io/cirruslabs/macos-sonoma-base:latest"},
{"first match wins", []string{"self-hosted", "ubuntu"}, SelfHostedPlatform},
{"unknown label picks nothing", []string{"windows"}, ""},
{"no runsOn picks nothing", nil, ""},
@@ -145,12 +175,14 @@ func TestToStrings(t *testing.T) {
ls := mustParse(t,
"ubuntu:docker://node:18",
"self-hosted:host",
"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
"bare",
"pool:e57e18d4",
)
require.Equal(t, []string{
"ubuntu:docker://node:18",
"self-hosted:host",
"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
"bare:host",
"pool:e57e18d4",
}, ls.ToStrings())
+9 -56
View File
@@ -9,8 +9,10 @@
#
# Usage: scripts/test-dind.sh [target] [-- go-test-args...]
# target: dind (default) or dind-rootless
# go-test-args: passed verbatim to `go test`. Defaults cover image env extraction,
# symlink copying and a mounted Docker job using cached images.
# go-test-args: passed verbatim to `go test`. The default exercises the daemon-facing tests
# that need no registry access (a fresh daemon, e.g. on fork-PR CI, can't
# authenticate pulls): the env-extraction build (FROM scratch) and the #981
# /var/run symlink copy regression (which reuses a preloaded alpine).
#
# Env:
# DIND_TEST_PORT host port for the daemon (default 32375)
@@ -23,25 +25,15 @@ case "${1:-}" in
dind|dind-rootless) target="$1"; shift ;;
esac
[ "${1:-}" = "--" ] && shift
default_tests=false
if [ $# -eq 0 ]; then
default_tests=true
set -- -count=1 -race -run '^TestDocker$|^TestDockerCopyToSymlinkPath$' ./act/container/
fi
[ $# -eq 0 ] && set -- -race -run '^TestDocker$|^TestDockerCopyToSymlinkPath$' ./act/container/
port="${DIND_TEST_PORT:-32375}"
name="gitea-runner-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.
host_docker="${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')}"
test_dir=""
host_docker="${DOCKER_HOST:-unix:///var/run/docker.sock}"
cleanup() {
docker -H "$host_docker" rm -fv "$name" >/dev/null 2>&1 || true
if [ -n "$test_dir" ]; then
rm -rf "$test_dir"
fi
}
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; }
trap cleanup EXIT
if [ -z "${DIND_TEST_IMAGE:-}" ]; then
@@ -79,7 +71,7 @@ else
fi
# 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 -H "$host_docker" run -d --privileged --name "$name" "${run_args[@]}" \
docker run -d --privileged --name "$name" "${run_args[@]}" \
-e DOCKER_TLS_CERTDIR= \
--entrypoint dockerd-entrypoint.sh \
"$image" --host=tcp://0.0.0.0:2375 >/dev/null
@@ -90,20 +82,11 @@ for _ in $(seq 1 60); do
docker version --format 'server docker {{.Server.Version}}' 2>/dev/null && break
sleep 1
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
# preceding `make test`), so the daemon-facing tests run without registry access.
echo "==> Seeding daemon with cached host images"
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
for img in ${DIND_TEST_PRELOAD:-alpine:latest}; do
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
fi
@@ -111,33 +94,3 @@ done
echo "==> Running tests against dind daemon"
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