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:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user