mirror of
https://gitea.com/gitea/act_runner
synced 2026-09-21 19:37:07 +02:00
enhance: add macOS VM executor backend
Replace the Tart-based prototype with a runner-oriented macOS VM executor backed by runner-executor-macos and Apple Virtualization. Assisted-by: Codet:MODEL_VERSION
This commit is contained in:
@@ -201,10 +201,10 @@ A label is written as:
|
|||||||
| Part | Meaning |
|
| Part | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
|
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
|
||||||
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
|
| `schema` | Either `docker`, `host`, or `macos-vm`. Defaults to `host` when omitted. |
|
||||||
| `args` | Only used by the `docker` schema: the image to run the job in. |
|
| `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>`:
|
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
|
||||||
|
|
||||||
@@ -218,15 +218,21 @@ Two schemas are supported:
|
|||||||
macos:host
|
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
|
So with the labels
|
||||||
|
|
||||||
```text
|
```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).
|
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).
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 := `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/tar", "-cf", "-", 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, "/bin/true"); err == nil {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("macOS VM %s did not become ready: %w", e.VMName, lastErr)
|
||||||
|
case <-e.runDone:
|
||||||
|
if e.runErr != nil {
|
||||||
|
return fmt.Errorf("macOS VM start exited before the VM became ready: %w", e.runErr)
|
||||||
|
}
|
||||||
|
return errors.New("macOS VM start exited before the VM became ready")
|
||||||
|
case <-time.After(macOSVMReadyPollInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) Exec(command []string, env map[string]string, _, workdir string) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := e.runExecutor(ctx, nil, e.Stdout, e.Stderr, macOSVMExecArgs(e.VMName, command, env, e.resolveWorkdir(workdir))...)
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
|
return ExitCodeError(exitErr.ExitCode())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func macOSVMExecArgs(vmName string, command []string, env map[string]string, workdir string) []string {
|
||||||
|
args := []string{"exec", vmName, "/usr/bin/env"}
|
||||||
|
for _, key := range slices.Sorted(maps.Keys(env)) {
|
||||||
|
args = append(args, key+"="+env[key])
|
||||||
|
}
|
||||||
|
args = append(args, "/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", workdir)
|
||||||
|
return append(args, command...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) resolveWorkdir(workdir string) string {
|
||||||
|
switch {
|
||||||
|
case workdir == "":
|
||||||
|
return e.GuestWorkdir
|
||||||
|
case strings.HasPrefix(workdir, "/"):
|
||||||
|
return workdir
|
||||||
|
default:
|
||||||
|
return path.Join(e.GuestWorkdir, workdir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||||
|
return parseEnvFile(e, srcPath, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) UpdateFromImageEnv(*map[string]string) common.Executor {
|
||||||
|
return func(context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) Remove() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cleanCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if e.runCmd != nil && e.runDone != nil {
|
||||||
|
select {
|
||||||
|
case <-e.runDone:
|
||||||
|
default:
|
||||||
|
if err := e.runExecutor(cleanCtx, nil, e.Stdout, e.Stderr, "stop", e.VMName, "--timeout", "20"); err != nil {
|
||||||
|
common.Logger(ctx).Debugf("macOS VM stop failed for VM %s: %v", e.VMName, err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-e.runDone:
|
||||||
|
case <-time.After(macOSVMStopWaitTimeout):
|
||||||
|
if e.runCmd.Process != nil {
|
||||||
|
_ = e.runCmd.Process.Kill()
|
||||||
|
}
|
||||||
|
common.Logger(ctx).Warnf("timed out waiting for macOS VM start to exit for VM %s", e.VMName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.running = false
|
||||||
|
if err := e.runExecutor(cleanCtx, nil, e.Stdout, e.Stderr, "destroy", e.VMName); err != nil {
|
||||||
|
common.Logger(ctx).Warnf("failed to destroy macOS VM %s: %v", e.VMName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) Close() common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if common.Dryrun(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if e.runCmd != nil && e.runCmd.Process != nil {
|
||||||
|
_ = e.runCmd.Process.Kill()
|
||||||
|
}
|
||||||
|
e.runCmd = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
|
||||||
|
oldOut := e.Stdout
|
||||||
|
oldErr := e.Stderr
|
||||||
|
e.Stdout = stdout
|
||||||
|
e.Stderr = stderr
|
||||||
|
return oldOut, oldErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) ToContainerPath(hostPath string) string {
|
||||||
|
if filepath.Clean(hostPath) == filepath.Clean(e.Workdir) {
|
||||||
|
return e.GuestWorkdir
|
||||||
|
}
|
||||||
|
if rel, err := filepath.Rel(e.Workdir, hostPath); err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
return path.Join(e.GuestWorkdir, filepath.ToSlash(rel))
|
||||||
|
}
|
||||||
|
return filepath.ToSlash(hostPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) GetActPath() string {
|
||||||
|
return e.ActPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MacOSVMEnvironment) GetPathVariableName() string {
|
||||||
|
return "PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MacOSVMEnvironment) DefaultPathVariable() string {
|
||||||
|
return "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MacOSVMEnvironment) JoinPathVariable(paths ...string) string {
|
||||||
|
return strings.Join(paths, ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) GetRunnerContext(context.Context) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"os": "macOS",
|
||||||
|
"arch": goArchToActionArch(runtime.GOARCH),
|
||||||
|
"temp": e.TmpDir,
|
||||||
|
"tool_cache": e.ToolCache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MacOSVMEnvironment) IsEnvironmentCaseInsensitive() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MacOSVMEnvironment) runExecutor(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args ...string) error {
|
||||||
|
if stdout == nil {
|
||||||
|
stdout = e.Stdout
|
||||||
|
}
|
||||||
|
if stderr == nil {
|
||||||
|
stderr = e.Stderr
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, e.executorPath, args...)
|
||||||
|
cmd.Stdin = stdin
|
||||||
|
cmd.Stdout = stdout
|
||||||
|
cmd.Stderr = stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("macOS VM executor %s: %w", args[0], err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//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())
|
||||||
|
env, err := NewMacOSVMEnvironment(MacOSVMEnvironmentInput{
|
||||||
|
Path: "/Users/admin/runner/scratch/" + vmName,
|
||||||
|
TmpDir: "/Users/admin/runner/scratch/" + vmName + "/tmp",
|
||||||
|
ToolCache: "/Users/admin/runner/scratch/" + vmName + "/tool_cache",
|
||||||
|
Workdir: t.TempDir(),
|
||||||
|
GuestWorkdir: "/Users/admin/runner/workspace/" + vmName,
|
||||||
|
ActPath: "/Users/admin/runner/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))
|
||||||
|
|
||||||
|
require.NoError(t, env.Copy("/tmp", &FileEntry{
|
||||||
|
Name: "gitea-runner-macos.txt",
|
||||||
|
Mode: 0o644,
|
||||||
|
Body: "hello from macos-vm",
|
||||||
|
})(ctx))
|
||||||
|
|
||||||
|
archive, err := env.GetContainerArchive(ctx, "/tmp/gitea-runner-macos.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := readFirstTarFile(t, archive)
|
||||||
|
require.NoError(t, archive.Close())
|
||||||
|
require.Equal(t, "hello from macos-vm", body)
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
oldOut, oldErr := env.ReplaceLogWriter(&output, &output)
|
||||||
|
defer env.ReplaceLogWriter(oldOut, oldErr)
|
||||||
|
require.NoError(t, env.Exec([]string{"/usr/bin/uname", "-s"}, nil, "", "")(ctx))
|
||||||
|
require.Contains(t, output.String(), "Darwin")
|
||||||
|
|
||||||
|
require.NoError(t, env.Remove()(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFirstTarFile(t *testing.T, archive io.Reader) string {
|
||||||
|
t.Helper()
|
||||||
|
reader := tar.NewReader(archive)
|
||||||
|
header, err := reader.Next()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, header.Name, "gitea-runner-macos.txt")
|
||||||
|
body, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package container
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewMacOSVMEnvironment(t *testing.T) {
|
||||||
|
env, err := NewMacOSVMEnvironment(MacOSVMEnvironmentInput{
|
||||||
|
Image: "ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||||
|
VMName: "job-vm",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "ghcr.io/cirruslabs/macos-sonoma-base:latest", env.Image)
|
||||||
|
assert.Equal(t, "job-vm", env.VMName)
|
||||||
|
assert.Equal(t, defaultMacOSVMBootTimeout, env.BootTimeout)
|
||||||
|
assert.Equal(t, "runner-executor-macos", env.executorPath)
|
||||||
|
|
||||||
|
_, err = NewMacOSVMEnvironment(MacOSVMEnvironmentInput{VMName: "job-vm"})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = NewMacOSVMEnvironment(MacOSVMEnvironmentInput{Image: "macos"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacOSVMExecArgs(t *testing.T) {
|
||||||
|
args := macOSVMExecArgs("vm", []string{"bash", "-c", "echo hi"}, map[string]string{"B": "two", "A": "one"}, "/Users/admin/work")
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"exec", "vm", "/usr/bin/env",
|
||||||
|
"A=one", "B=two",
|
||||||
|
"/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", "/Users/admin/work",
|
||||||
|
"bash", "-c", "echo hi",
|
||||||
|
}, args)
|
||||||
|
|
||||||
|
args = macOSVMExecArgs("vm", []string{"true"}, nil, "")
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"exec", "vm", "/usr/bin/env",
|
||||||
|
"/bin/sh", "-c", `cd "$1" && shift && exec "$@"`, "sh", "",
|
||||||
|
"true",
|
||||||
|
}, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacOSVMEnvironmentResolveWorkdir(t *testing.T) {
|
||||||
|
env := &MacOSVMEnvironment{GuestWorkdir: "/Users/admin/work"}
|
||||||
|
assert.Equal(t, "/Users/admin/work", env.resolveWorkdir(""))
|
||||||
|
assert.Equal(t, "/Users/admin/work/sub", env.resolveWorkdir("sub"))
|
||||||
|
assert.Equal(t, "/tmp", env.resolveWorkdir("/tmp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacOSVMEnvironmentToContainerPath(t *testing.T) {
|
||||||
|
env := &MacOSVMEnvironment{Workdir: "/workspace/owner/repo", GuestWorkdir: "/Users/admin/work/owner/repo"}
|
||||||
|
assert.Equal(t, "/Users/admin/work/owner/repo", env.ToContainerPath("/workspace/owner/repo"))
|
||||||
|
assert.Equal(t, "/Users/admin/work/owner/repo/sub", env.ToContainerPath("/workspace/owner/repo/sub"))
|
||||||
|
assert.Equal(t, "/elsewhere", env.ToContainerPath("/elsewhere"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacOSVMEnvironmentGetRunnerContext(t *testing.T) {
|
||||||
|
env := &MacOSVMEnvironment{
|
||||||
|
TmpDir: "/Users/admin/scratch/tmp",
|
||||||
|
ToolCache: "/Users/admin/scratch/tool_cache",
|
||||||
|
}
|
||||||
|
ctx := env.GetRunnerContext(context.Background())
|
||||||
|
assert.Equal(t, "macOS", ctx["os"])
|
||||||
|
assert.Equal(t, goArchToActionArch(runtime.GOARCH), ctx["arch"])
|
||||||
|
assert.Equal(t, "/Users/admin/scratch/tmp", ctx["temp"])
|
||||||
|
assert.Equal(t, "/Users/admin/scratch/tool_cache", ctx["tool_cache"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacOSVMEnvironmentReplaceLogWriter(t *testing.T) {
|
||||||
|
oldOut := io.Discard
|
||||||
|
oldErr := io.Discard
|
||||||
|
env := &MacOSVMEnvironment{Stdout: oldOut, Stderr: oldErr}
|
||||||
|
newOut, newErr := env.ReplaceLogWriter(io.Discard, io.Discard)
|
||||||
|
assert.Equal(t, oldOut, newOut)
|
||||||
|
assert.Equal(t, oldErr, newErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacOSVMEnvironmentDefaultPathVariable(t *testing.T) {
|
||||||
|
env := &MacOSVMEnvironment{}
|
||||||
|
assert.Equal(t, "PATH", env.GetPathVariableName())
|
||||||
|
assert.Equal(t, "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin", env.DefaultPathVariable())
|
||||||
|
assert.Equal(t, "/a:/b", env.JoinPathVariable("/a", "/b"))
|
||||||
|
assert.False(t, env.IsEnvironmentCaseInsensitive())
|
||||||
|
}
|
||||||
+118
-2
@@ -17,6 +17,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
maps0 "maps"
|
maps0 "maps"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -28,6 +29,7 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/ghcontext"
|
"gitea.com/gitea/runner/act/ghcontext"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
@@ -424,6 +426,112 @@ 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),
|
||||||
|
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"
|
// printStartJobContainerGroup mirrors actions/runner's "Starting job container"
|
||||||
// section: emit the group header and summary, return a closer for ::endgroup::.
|
// section: emit the group header and summary, return a closer for ::endgroup::.
|
||||||
func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() {
|
func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() {
|
||||||
@@ -980,9 +1088,12 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
|
|||||||
func (rc *RunContext) startContainer() common.Executor {
|
func (rc *RunContext) startContainer() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
var err error
|
var err error
|
||||||
if rc.IsHostEnv() {
|
switch {
|
||||||
|
case rc.IsHostEnv():
|
||||||
err = rc.startHostEnvironment()(ctx)
|
err = rc.startHostEnvironment()(ctx)
|
||||||
} else {
|
case rc.IsMacOSVMEnv():
|
||||||
|
err = rc.startMacOSVMEnvironment()(ctx)
|
||||||
|
default:
|
||||||
err = rc.startJobContainer()(ctx)
|
err = rc.startJobContainer()(ctx)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1014,6 +1125,11 @@ func (rc *RunContext) IsHostEnv() bool {
|
|||||||
return strings.EqualFold(rc.platformImage, "-self-hosted")
|
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 {
|
func (rc *RunContext) stopContainer() common.Executor {
|
||||||
return rc.stopJobContainer()
|
return rc.stopJobContainer()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
@@ -1458,3 +1459,25 @@ func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
|
|||||||
assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"])
|
assert.Equal(t, "/workspace/owner", env["RUNNER_WORKSPACE"])
|
||||||
assert.Equal(t, "1", env["RUNNER_DEBUG"])
|
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)
|
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
|
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)
|
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
|
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
|
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
|
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
|
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
||||||
|
|||||||
@@ -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) {
|
func TestValidateLabels(t *testing.T) {
|
||||||
require.NoError(t, validateLabels([]string{"ubuntu:host", "ubuntu:docker://node:18"}))
|
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
|
// 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.NoError(t, validateLabels([]string{"pool:e57e18d4-10d4-406f-93bf-60f127221bdd"}))
|
||||||
require.Error(t, validateLabels([]string{"ubuntu:host", ""}))
|
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.
|
// removeOrphanNetworks is a variable so tests can substitute one that needs no Docker daemon.
|
||||||
var removeOrphanNetworks = container.RemoveOrphanNetworks
|
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.
|
// OnIdle performs lightweight maintenance during polling idle windows.
|
||||||
// It runs synchronously on the poller goroutine; shouldRunIdleCleanup
|
// It runs synchronously on the poller goroutine; shouldRunIdleCleanup
|
||||||
// throttles invocations to runner.idle_cleanup_interval so the impact on
|
// 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.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir)
|
||||||
}
|
}
|
||||||
r.cleanupOrphanNetworks(ctx)
|
r.cleanupOrphanNetworks(ctx)
|
||||||
|
r.cleanupOrphanMacOSVMs(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupOrphanNetworks reclaims the per-job networks of jobs this runner did not live to
|
// 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 {
|
func (r *Runner) shouldRunIdleCleanup() bool {
|
||||||
if r.cfg.Runner.WorkdirCleanupAge <= 0 || r.cfg.Runner.IdleCleanupInterval <= 0 {
|
if r.cfg.Runner.WorkdirCleanupAge <= 0 || r.cfg.Runner.IdleCleanupInterval <= 0 {
|
||||||
return false
|
return false
|
||||||
@@ -548,8 +562,16 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
Vars: task.Vars,
|
Vars: task.Vars,
|
||||||
ValidVolumes: r.cfg.Container.ValidVolumes,
|
ValidVolumes: r.cfg.Container.ValidVolumes,
|
||||||
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
||||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
MacOSVM: runner.MacOSVMConfig{
|
||||||
RunnerName: r.name,
|
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)
|
rr, err := runner.New(runnerConfig)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/internal/pkg/config"
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -331,3 +332,37 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
|
|||||||
hostOnly.OnIdle(context.Background())
|
hostOnly.OnIdle(context.Background())
|
||||||
assert.Equal(t, []string{"runner-1"}, swept)
|
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.
|
# at the price of the stock artifact actions refusing and the cache client keeping to v1.
|
||||||
#patch_actions: true
|
#patch_actions: true
|
||||||
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
# 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 .
|
# 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 registering, it will ask for inputting labels.
|
||||||
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
||||||
@@ -271,6 +272,19 @@ host:
|
|||||||
# If it's empty, $HOME/.cache/act/ will be used.
|
# If it's empty, $HOME/.cache/act/ will be used.
|
||||||
#workdir_parent:
|
#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
|
# Optional local task-admission checks. Disabled by default. When enabled, low
|
||||||
# disk space or a failing script pauses new task fetching; existing jobs continue.
|
# 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.
|
# No health checks run while any job is active; the last result is reused until idle.
|
||||||
|
|||||||
@@ -179,6 +179,15 @@ type Host struct {
|
|||||||
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory.
|
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.
|
// Metrics represents the configuration for the Prometheus metrics endpoint.
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed.
|
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.
|
Cache Cache `yaml:"cache"` // Cache represents the configuration for caching.
|
||||||
Container Container `yaml:"container"` // Container represents the configuration for the container.
|
Container Container `yaml:"container"` // Container represents the configuration for the container.
|
||||||
Host Host `yaml:"host"` // Host represents the configuration for the host.
|
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.
|
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.
|
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")
|
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 {
|
if cfg.Runner.FetchTimeout <= 0 {
|
||||||
cfg.Runner.FetchTimeout = 5 * time.Second
|
cfg.Runner.FetchTimeout = 5 * time.Second
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,31 @@ func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) {
|
|||||||
assert.Equal(t, DefaultImage, cfg.Runner.DefaultImage)
|
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) {
|
func TestLoadDefault_HealthChecksAreOptIn(t *testing.T) {
|
||||||
cfg, err := LoadDefault("")
|
cfg, err := LoadDefault("")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SchemeHost = "host"
|
SchemeHost = "host"
|
||||||
SchemeDocker = "docker"
|
SchemeDocker = "docker"
|
||||||
|
SchemeMacOSVM = "macos-vm"
|
||||||
|
|
||||||
// SelfHostedPlatform is the platform marker act treats as "run on the host".
|
// SelfHostedPlatform is the platform marker act treats as "run on the host".
|
||||||
SelfHostedPlatform = "-self-hosted"
|
SelfHostedPlatform = "-self-hosted"
|
||||||
|
|
||||||
|
// MacOSVMSchemePrefix marks a platform image that act should run in a macOS VM.
|
||||||
|
MacOSVMSchemePrefix = "macos-vm://"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Label struct {
|
type Label struct {
|
||||||
@@ -42,7 +46,7 @@ func Parse(str string) (*Label, error) {
|
|||||||
if len(splits) >= 3 {
|
if len(splits) >= 3 {
|
||||||
label.Arg = splits[2]
|
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.
|
// Not a schema we know: the colon belongs to the label name itself.
|
||||||
return &Label{
|
return &Label{
|
||||||
Name: str,
|
Name: str,
|
||||||
@@ -64,6 +68,15 @@ func (l Labels) RequireDocker() bool {
|
|||||||
return false
|
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 "".
|
// PickPlatform returns the platform of the first runs-on entry this runner has a label for, or "".
|
||||||
func (l Labels) PickPlatform(runsOn []string) string {
|
func (l Labels) PickPlatform(runsOn []string) string {
|
||||||
platforms := make(map[string]string, len(l))
|
platforms := make(map[string]string, len(l))
|
||||||
@@ -72,10 +85,12 @@ func (l Labels) PickPlatform(runsOn []string) string {
|
|||||||
case SchemeDocker:
|
case SchemeDocker:
|
||||||
// "//" will be ignored
|
// "//" will be ignored
|
||||||
platforms[label.Name] = strings.TrimPrefix(label.Arg, "//")
|
platforms[label.Name] = strings.TrimPrefix(label.Arg, "//")
|
||||||
|
case SchemeMacOSVM:
|
||||||
|
platforms[label.Name] = MacOSVMSchemePrefix + strings.TrimPrefix(label.Arg, "//")
|
||||||
case SchemeHost:
|
case SchemeHost:
|
||||||
platforms[label.Name] = SelfHostedPlatform
|
platforms[label.Name] = SelfHostedPlatform
|
||||||
default:
|
default:
|
||||||
// unreachable: Parse only produces host or docker schemas
|
// unreachable: Parse only produces host, docker, or macOS VM schemas
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ func TestParse(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: false,
|
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",
|
args: "ubuntu:host",
|
||||||
want: &Label{
|
want: &Label{
|
||||||
@@ -103,6 +112,7 @@ func TestRequireDocker(t *testing.T) {
|
|||||||
{"empty", nil, false},
|
{"empty", nil, false},
|
||||||
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
|
{"only host", []string{"ubuntu:host", "self-hosted"}, false},
|
||||||
{"has docker", []string{"ubuntu:host", "ubuntu:docker://node:18"}, true},
|
{"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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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) {
|
func TestPickPlatform(t *testing.T) {
|
||||||
ls := mustParse(t,
|
ls := mustParse(t,
|
||||||
"ubuntu:docker://node:18",
|
"ubuntu:docker://node:18",
|
||||||
"self-hosted:host",
|
"self-hosted:host",
|
||||||
|
"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||||
)
|
)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -124,6 +153,7 @@ func TestPickPlatform(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"docker strips leading slashes", []string{"ubuntu"}, "node:18"},
|
{"docker strips leading slashes", []string{"ubuntu"}, "node:18"},
|
||||||
{"host maps to self-hosted marker", []string{"self-hosted"}, SelfHostedPlatform},
|
{"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},
|
{"first match wins", []string{"self-hosted", "ubuntu"}, SelfHostedPlatform},
|
||||||
{"unknown label picks nothing", []string{"windows"}, ""},
|
{"unknown label picks nothing", []string{"windows"}, ""},
|
||||||
{"no runsOn picks nothing", nil, ""},
|
{"no runsOn picks nothing", nil, ""},
|
||||||
@@ -145,12 +175,14 @@ func TestToStrings(t *testing.T) {
|
|||||||
ls := mustParse(t,
|
ls := mustParse(t,
|
||||||
"ubuntu:docker://node:18",
|
"ubuntu:docker://node:18",
|
||||||
"self-hosted:host",
|
"self-hosted:host",
|
||||||
|
"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||||
"bare",
|
"bare",
|
||||||
"pool:e57e18d4",
|
"pool:e57e18d4",
|
||||||
)
|
)
|
||||||
require.Equal(t, []string{
|
require.Equal(t, []string{
|
||||||
"ubuntu:docker://node:18",
|
"ubuntu:docker://node:18",
|
||||||
"self-hosted:host",
|
"self-hosted:host",
|
||||||
|
"macos-latest:macos-vm://ghcr.io/cirruslabs/macos-sonoma-base:latest",
|
||||||
"bare:host",
|
"bare:host",
|
||||||
"pool:e57e18d4",
|
"pool:e57e18d4",
|
||||||
}, ls.ToStrings())
|
}, ls.ToStrings())
|
||||||
|
|||||||
Reference in New Issue
Block a user