mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-11-04 14:25:33 +08:00
Compare commits
4 Commits
v3.4.0
...
enhance/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de9db9c0fa | ||
|
|
d1da6ffa52 | ||
|
|
880e0a148a | ||
|
|
2db24c7401 |
29
README.md
29
README.md
@@ -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
|
||||
```
|
||||
|
||||
Containers, networks and volumes a job creates through the socket are removed when the job ends. Both apply to jobs in containers, on the host `.` already works.
|
||||
|
||||
#### Proxy
|
||||
|
||||
Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -75,17 +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
|
||||
}
|
||||
|
||||
// Close removes what the job created through the socket, then stops serving it.
|
||||
func (p *DockerProxy) Close(ctx context.Context) error {
|
||||
return p.close(ctx)
|
||||
}
|
||||
|
||||
// Info is a snapshot of a container, as of one inspect.
|
||||
type Info struct {
|
||||
ID string
|
||||
@@ -95,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
|
||||
|
||||
@@ -1,276 +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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"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
|
||||
)
|
||||
|
||||
var (
|
||||
createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`)
|
||||
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
|
||||
proxyProbe struct {
|
||||
sync.Mutex
|
||||
decided bool
|
||||
dir string
|
||||
}
|
||||
)
|
||||
|
||||
// DockerProxyDir returns where job proxy sockets live, "" while undecided or when the daemon cannot open the runner's files.
|
||||
func DockerProxyDir(ctx context.Context) string {
|
||||
proxyProbe.Lock()
|
||||
defer proxyProbe.Unlock()
|
||||
if proxyProbe.decided {
|
||||
return proxyProbe.dir
|
||||
}
|
||||
dir := filepath.Join(os.TempDir(), "gitea-runner-docker")
|
||||
ok, err := daemonSeesDir(ctx, dir)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Debugf("docker proxy probe postponed: %v", err)
|
||||
return ""
|
||||
}
|
||||
proxyProbe.decided = true
|
||||
if ok {
|
||||
proxyProbe.dir = dir
|
||||
} else {
|
||||
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's filesystem, jobs get the daemon socket directly")
|
||||
}
|
||||
return proxyProbe.dir
|
||||
}
|
||||
|
||||
func daemonSeesDir(ctx context.Context, dir string) (bool, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return false, err
|
||||
}
|
||||
probe := filepath.Join(dir, "probe")
|
||||
if err := os.WriteFile(probe, nil, 0o600); err != nil {
|
||||
return false, err
|
||||
}
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cli.Close()
|
||||
images, err := cli.ImageList(ctx, client.ImageListOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(images.Items) == 0 {
|
||||
return false, errors.New("no image to probe with yet")
|
||||
}
|
||||
// creating validates that a bind source exists on the daemon's side, nothing is started
|
||||
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: probe, Target: "/gitea-runner-probe"}}},
|
||||
})
|
||||
if cerrdefs.IsInvalidArgument(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = cli.ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true})
|
||||
return true, err
|
||||
}
|
||||
|
||||
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
|
||||
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := sha256.Sum256([]byte(job))
|
||||
socket := filepath.Join(dir, hex.EncodeToString(digest[:8])+".sock")
|
||||
_ = os.Remove(socket)
|
||||
listener, err := net.Listen("unix", socket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info, err := os.Stat(daemonSocket); err == nil {
|
||||
_ = os.Chmod(socket, info.Mode().Perm())
|
||||
}
|
||||
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,
|
||||
}
|
||||
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method != http.MethodPost:
|
||||
case createPath.MatchString(r.URL.Path):
|
||||
if err := addLabel(r, job); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
case rawStreamPath.MatchString(r.URL.Path):
|
||||
tunnel(w, r, dial)
|
||||
return
|
||||
}
|
||||
forward.ServeHTTP(w, r)
|
||||
})}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
return &DockerProxy{Socket: socket, close: func(ctx context.Context) error {
|
||||
err := removeJobResources(ctx, job)
|
||||
_ = server.Close()
|
||||
transport.CloseIdleConnections()
|
||||
_ = os.Remove(socket)
|
||||
return err
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func addLabel(r *http.Request, job string) error {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxCreateBody+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) > maxCreateBody {
|
||||
return errors.New("create request too large")
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return nil
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &fields); err != nil {
|
||||
return fmt.Errorf("invalid create request: %w", err)
|
||||
}
|
||||
key := "Labels"
|
||||
for name := range fields {
|
||||
if strings.EqualFold(name, key) {
|
||||
key = name
|
||||
break
|
||||
}
|
||||
}
|
||||
labels := map[string]string{}
|
||||
if raw := fields[key]; len(raw) > 0 && string(raw) != "null" {
|
||||
if err := json.Unmarshal(raw, &labels); err != nil {
|
||||
return fmt.Errorf("invalid create request: %w", err)
|
||||
}
|
||||
}
|
||||
labels[jobLabel] = job
|
||||
if fields[key], err = json.Marshal(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
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "connection cannot be hijacked", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
upstream, err := dial(r.Context(), "", "")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
if err := r.Write(upstream); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
downstream, buffered, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer downstream.Close()
|
||||
if _, err := io.CopyN(upstream, buffered, int64(buffered.Reader.Buffered())); err != nil {
|
||||
return
|
||||
}
|
||||
done := make(chan struct{}, 2)
|
||||
go func() {
|
||||
_, _ = io.Copy(upstream, downstream)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
go func() {
|
||||
_, _ = io.Copy(downstream, upstream)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
<-done
|
||||
}
|
||||
|
||||
func removeJobResources(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})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errs []error
|
||||
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})
|
||||
if err != nil {
|
||||
return errors.Join(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})
|
||||
if err != nil {
|
||||
return errors.Join(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...)
|
||||
}
|
||||
@@ -1,270 +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"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
"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) {
|
||||
bodies := make(chan []byte, 8)
|
||||
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)
|
||||
bodies <- body
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case strings.HasSuffix(r.URL.Path, "/start"):
|
||||
conn, buffered, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n"))
|
||||
_, _ = io.Copy(conn, buffered)
|
||||
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)
|
||||
client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", proxy.Socket)
|
||||
}}}
|
||||
|
||||
t.Run("labels creates", func(t *testing.T) {
|
||||
for path, body := range map[string]string{
|
||||
"/v1.47/containers/create": `{"Image":"alpine","Labels":{"own":"1"}}`,
|
||||
"/networks/create": `{"Name":"n"}`,
|
||||
"/volumes/create": `{"Name":"v","labels":null}`,
|
||||
} {
|
||||
resp, err := client.Post("http://docker"+path, "application/json", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var got map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(<-bodies, &got))
|
||||
key := "Labels"
|
||||
if _, ok := got["labels"]; ok {
|
||||
key = "labels"
|
||||
}
|
||||
var labels map[string]string
|
||||
require.NoError(t, json.Unmarshal(got[key], &labels))
|
||||
assert.Equal(t, "job-1", labels[jobLabel], path)
|
||||
if strings.Contains(body, "own") {
|
||||
assert.Equal(t, "1", labels["own"])
|
||||
assert.JSONEq(t, `"alpine"`, string(got["Image"]))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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", func(t *testing.T) {
|
||||
conn, err := net.Dial("unix", proxy.Socket)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
_, err = conn.Write([]byte("POST /v1.47/exec/abc/start HTTP/1.1\r\nHost: docker\r\nContent-Length: 2\r\n\r\n{}ping\n"))
|
||||
require.NoError(t, err)
|
||||
reader := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(reader, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, "application/vnd.docker.raw-stream", resp.Header.Get("Content-Type"))
|
||||
echoed, err := reader.ReadString('\n')
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "{}ping\n", echoed)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
||||
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)
|
||||
cli.On("ContainerKill", ctx, "c1", mock.Anything).Return(mobyclient.ContainerKillResult{}, nil)
|
||||
cli.On("ContainerRemove", ctx, "c1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||
Return(mobyclient.ContainerRemoveResult{}, nil)
|
||||
cli.On("NetworkList", ctx, mobyclient.NetworkListOptions{Filters: filters}).
|
||||
Return(mobyclient.NetworkListResult{Items: []network.Summary{{ID: "n1", Name: "app_default"}}}, nil)
|
||||
cli.On("NetworkRemove", ctx, "n1", mock.Anything).Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||
cli.On("VolumeList", ctx, mobyclient.VolumeListOptions{Filters: filters}).
|
||||
Return(mobyclient.VolumeListResult{Items: []volume.Volume{{Name: "app_data"}}}, nil)
|
||||
cli.On("VolumeRemove", ctx, "app_data", mobyclient.VolumeRemoveOptions{}).Return(mobyclient.VolumeRemoveResult{}, nil)
|
||||
|
||||
require.NoError(t, removeLabelled(ctx, cli, "job-1"))
|
||||
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, dir)
|
||||
require.NoError(t, err)
|
||||
t.Logf("daemon sees the runner's filesystem: %v", seen)
|
||||
|
||||
job := "proxy-test-" + t.Name()
|
||||
proxy, err := StartDockerProxy(daemonSocketPath(t, direct), dir, job)
|
||||
require.NoError(t, err)
|
||||
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))
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -944,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)
|
||||
@@ -985,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,
|
||||
|
||||
@@ -471,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)
|
||||
@@ -801,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) {
|
||||
|
||||
@@ -29,14 +29,6 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
||||
return false, errors.New("Unsupported Operation")
|
||||
}
|
||||
|
||||
func DockerProxyDir(ctx context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
act/container/macos_vm_cleanup.go
Normal file
73
act/container/macos_vm_cleanup.go
Normal 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
act/container/macos_vm_cleanup_test.go
Normal file
37
act/container/macos_vm_cleanup_test.go
Normal 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
act/container/macos_vm_environment.go
Normal file
450
act/container/macos_vm_environment.go
Normal 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
|
||||
}
|
||||
97
act/container/macos_vm_environment_integration_test.go
Normal file
97
act/container/macos_vm_environment_integration_test.go
Normal file
@@ -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)
|
||||
}
|
||||
91
act/container/macos_vm_environment_test.go
Normal file
91
act/container/macos_vm_environment_test.go
Normal file
@@ -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())
|
||||
}
|
||||
@@ -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):], "/"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,38 +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) {
|
||||
daemonSocket := rc.containerDaemonSocket()
|
||||
if daemonSocket == "-" || strings.HasPrefix(strings.ToLower(daemonSocket), "npipe://") {
|
||||
return
|
||||
}
|
||||
dir := container.DockerProxyDir(ctx)
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
proxy, err := container.StartDockerProxy(getDockerDaemonSocketMountPath(daemonSocket), dir, rc.jobContainerName())
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("docker proxy not started, the job gets the daemon socket directly: %v", err)
|
||||
return
|
||||
}
|
||||
rc.dockerProxy = proxy
|
||||
}
|
||||
|
||||
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
|
||||
func (rc *RunContext) toolCache(fallback string) string {
|
||||
if path := rc.GetEnv()["RUNNER_TOOL_CACHE"]; path != "" {
|
||||
@@ -355,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] {
|
||||
@@ -452,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() {
|
||||
@@ -483,7 +564,6 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
// For gitea, to support --volumes-from <container_name_or_id> in options.
|
||||
// We need to set the container name to the environment variable.
|
||||
rc.Env["JOB_CONTAINER_NAME"] = name
|
||||
rc.startDockerProxy(ctx)
|
||||
|
||||
envList := make([]string, 0)
|
||||
|
||||
@@ -680,12 +760,6 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
||||
logger.Errorf("Error while cleaning services: %v", err)
|
||||
}
|
||||
}
|
||||
if rc.dockerProxy != nil {
|
||||
if err := rc.dockerProxy.Close(ctx); err != nil {
|
||||
logger.Errorf("Error while removing what the job created: %v", err)
|
||||
}
|
||||
rc.dockerProxy = nil
|
||||
}
|
||||
if removeJobContainer {
|
||||
// after the containers using them, services can hold these via `--volumes-from`
|
||||
name := rc.jobContainerName()
|
||||
@@ -949,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
|
||||
}
|
||||
}
|
||||
@@ -1018,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 {
|
||||
@@ -1052,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()
|
||||
}
|
||||
|
||||
@@ -18,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"
|
||||
@@ -1294,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()
|
||||
@@ -1481,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-")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
93
docs/macos-vm-executor.md
Normal file
93
docs/macos-vm-executor.md
Normal 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.
|
||||
@@ -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", ""}))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user