feat: tart macOS vm's as job container (#33)

adds the tart:// protocol to platform mapping

e.g. `-P macos-14=tart://ghcr.io/cirruslabs/macos-sonoma-base:latest` if you have a mac.

`add-path` is probably broken
This commit is contained in:
ChristopherHX
2025-01-29 17:27:04 +01:00
committed by GitHub
parent 592dc4bf2c
commit 677e073448
10 changed files with 832 additions and 26 deletions

View File

@@ -655,6 +655,9 @@ func (rc *RunContext) startContainer() common.Executor {
if rc.IsHostEnv(ctx) {
return rc.startHostEnvironment()(ctx)
}
if rc.IsTartEnv(ctx) {
return rc.startTartEnvironment()(ctx)
}
return rc.startJobContainer()(ctx)
}
}
@@ -665,6 +668,12 @@ func (rc *RunContext) IsHostEnv(ctx context.Context) bool {
return image == "" && strings.EqualFold(platform, "-self-hosted")
}
func (rc *RunContext) IsTartEnv(ctx context.Context) bool {
platform := rc.runsOnImage(ctx)
image := rc.containerImage(ctx)
return image == "" && strings.HasPrefix(platform, "tart://")
}
func (rc *RunContext) stopContainer() common.Executor {
return rc.stopJobContainer()
}

View File

@@ -0,0 +1,111 @@
package runner
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/actions-oss/act-cli/pkg/common"
"github.com/actions-oss/act-cli/pkg/container"
"github.com/actions-oss/act-cli/pkg/tart"
)
func (rc *RunContext) startTartEnvironment() common.Executor {
return func(ctx context.Context) error {
logger := common.Logger(ctx)
rawLogger := logger.WithField("raw_output", true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
if rc.Config.LogOutput {
rawLogger.Infof("%s", s)
} else {
rawLogger.Debugf("%s", s)
}
return true
})
cacheDir := rc.ActionCacheDir()
randBytes := make([]byte, 8)
_, _ = rand.Read(randBytes)
miscpath := filepath.Join(cacheDir, hex.EncodeToString(randBytes))
actPath := filepath.Join(miscpath, "act")
if err := os.MkdirAll(actPath, 0o777); err != nil {
return err
}
path := filepath.Join(miscpath, "hostexecutor")
if err := os.MkdirAll(path, 0o777); err != nil {
return err
}
runnerTmp := filepath.Join(miscpath, "tmp")
if err := os.MkdirAll(runnerTmp, 0o777); err != nil {
return err
}
toolCache := filepath.Join(cacheDir, "tool_cache")
platImage := rc.runsOnImage(ctx)
platURI, _ := url.Parse(platImage)
query := platURI.Query()
tenv := &tart.Environment{
HostEnvironment: container.HostEnvironment{
Path: path,
TmpDir: runnerTmp,
ToolCache: toolCache,
Workdir: rc.Config.Workdir,
ActPath: actPath,
CleanUp: func() {
os.RemoveAll(miscpath)
},
StdOut: logWriter,
},
Config: tart.Config{
SSHUsername: "admin",
SSHPassword: "admin",
Softnet: query.Get("softnet") == "1",
Headless: query.Get("headless") != "0",
AlwaysPull: query.Get("pull") != "0",
},
Env: &tart.Env{
JobImage: platURI.Host + platURI.EscapedPath(),
JobID: rc.jobContainerName(),
},
Miscpath: miscpath,
}
rc.JobContainer = tenv
if query.Has("sshusername") {
tenv.Config.SSHUsername = query.Get("sshusername")
}
if query.Has("sshpassword") {
tenv.Config.SSHPassword = query.Get("sshpassword")
}
rc.cleanUpJobContainer = rc.JobContainer.Remove()
for k, v := range rc.JobContainer.GetRunnerContext(ctx) {
if v, ok := v.(string); ok {
rc.Env[fmt.Sprintf("RUNNER_%s", strings.ToUpper(k))] = v
}
}
// for _, env := range os.Environ() {
// if k, v, ok := strings.Cut(env, "="); ok {
// // don't override
// if _, ok := rc.Env[k]; !ok {
// rc.Env[k] = v
// }
// }
// }
return common.NewPipelineExecutor(
// rc.JobContainer.Remove(),
rc.JobContainer.Start(false),
rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/", &container.FileEntry{
Name: "workflow/event.json",
Mode: 0o644,
Body: rc.EventJSON,
}, &container.FileEntry{
Name: "workflow/envs.txt",
Mode: 0o666,
Body: "",
}),
)(ctx)
}
}

View File

@@ -0,0 +1,16 @@
//go:build !darwin
package runner
import (
"context"
"fmt"
"github.com/actions-oss/act-cli/pkg/common"
)
func (rc *RunContext) startTartEnvironment() common.Executor {
return func(_ context.Context) error {
return fmt.Errorf("You need macOS for tart")
}
}

View File

@@ -358,6 +358,33 @@ func TestRunEvent(t *testing.T) {
}
}
func TestTartNotSupportedOnNonDarwin(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
tables := []TestJobFileInfo{}
if runtime.GOOS != "darwin" {
platforms := map[string]string{
"ubuntu-latest": "tart://ghcr.io/cirruslabs/macos-sonoma-base:latest",
}
tables = append(tables, []TestJobFileInfo{
// Shells
{workdir, "basic", "push", "tart not supported", platforms, secrets},
}...)
}
for _, table := range tables {
t.Run(table.workflowPath, func(t *testing.T) {
table.runTest(ctx, t, &Config{})
})
}
}
func TestRunEventHostEnvironment(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")

View File

@@ -0,0 +1,9 @@
package tart
type Config struct {
SSHUsername string
SSHPassword string
Softnet bool
Headless bool
AlwaysPull bool
}

18
pkg/tart/env_darwin.go Normal file
View File

@@ -0,0 +1,18 @@
package tart
type Env struct {
JobID string
JobImage string
FailureExitCode int
Registry *Registry
}
type Registry struct {
Address string
User string
Password string
}
func (e Env) VirtualMachineID() string {
return e.JobID
}

View File

@@ -0,0 +1,220 @@
package tart
import (
"context"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"github.com/kballard/go-shellquote"
"github.com/actions-oss/act-cli/pkg/common"
"github.com/actions-oss/act-cli/pkg/container"
)
type Environment struct {
container.HostEnvironment
vm *VM
Config Config
Env *Env
Miscpath string
}
// "/Volumes/My Shared Files/act/"
func (e *Environment) ToHostPath(path string) string {
actPath := filepath.Clean("/private/tmp/act/")
altPath := filepath.Clean(path)
if strings.HasPrefix(altPath, actPath) {
return e.Miscpath + altPath[len(actPath):]
}
return altPath
}
func (e *Environment) ToContainerPath(path string) string {
path = e.HostEnvironment.ToContainerPath(path)
actPath := filepath.Clean(e.Miscpath)
altPath := filepath.Clean(path)
if strings.HasPrefix(altPath, actPath) {
return "/private/tmp/act/" + altPath[len(actPath):]
}
return altPath
}
func (e *Environment) Exec(command []string /*cmdline string, */, env map[string]string, user, workdir string) common.Executor {
return e.ExecWithCmdLine(command, "", env, user, workdir)
}
func (e *Environment) ExecWithCmdLine(command []string, cmdline string, env map[string]string, user, workdir string) common.Executor {
return func(ctx context.Context) error {
if err := e.exec(ctx, command, cmdline, env, user, workdir); err != nil {
select {
case <-ctx.Done():
return fmt.Errorf("this step has been cancelled: %w", err)
default:
return err
}
}
return nil
}
}
func (e *Environment) Start(b bool) common.Executor {
return e.HostEnvironment.Start(b).Then(func(ctx context.Context) error {
return e.start(ctx)
})
}
func (e *Environment) start(ctx context.Context) error {
actEnv := e.Env
config := e.Config
if config.AlwaysPull {
log.Printf("Pulling the latest version of %s...\n", actEnv.JobImage)
_, _, err := ExecWithEnv(ctx, nil,
"pull", actEnv.JobImage)
if err != nil {
return err
}
}
log.Println("Cloning and configuring a new VM...")
vm, err := CreateNewVM(ctx, *actEnv, 0, 0)
if err != nil {
_ = e.Stop(ctx)
return err
}
var customDirectoryMounts []string
_ = os.MkdirAll(e.Miscpath, 0666)
customDirectoryMounts = append(customDirectoryMounts, "act:"+e.Miscpath)
e.vm = vm
err = vm.Start(config, actEnv, customDirectoryMounts)
if err != nil {
_ = e.Stop(ctx)
return err
}
return e.execRaw(ctx, "ln -sf '/Volumes/My Shared Files/act' /private/tmp/act")
}
func (e *Environment) Stop(ctx context.Context) error {
log.Println("Stop VM?")
actEnv := e.Env
var vm *VM
if e.vm != nil {
vm = e.vm
} else {
vm = ExistingVM(*actEnv)
}
if err := vm.Stop(); err != nil {
log.Printf("Failed to stop VM: %v", err)
}
if err := vm.Delete(); err != nil {
log.Printf("Failed to delete VM: %v", err)
return err
}
return nil
}
func (e *Environment) Remove() common.Executor {
return func(ctx context.Context) error {
_ = e.Stop(ctx)
log.Println("Remove VM?")
if e.CleanUp != nil {
e.CleanUp()
}
_ = os.RemoveAll(e.Path)
return e.Close()(ctx)
}
}
func (e *Environment) exec(ctx context.Context, command []string, _ string, env map[string]string, _, workdir string) error {
var wd string
if workdir != "" {
if filepath.IsAbs(workdir) {
wd = filepath.Clean(workdir)
} else {
wd = filepath.Clean(filepath.Join(e.Path, workdir))
}
} else {
wd = e.ToContainerPath(e.Path)
}
envs := ""
for k, v := range env {
envs += shellquote.Join(k) + "=" + shellquote.Join(v) + " "
}
return e.execRaw(ctx, "cd "+shellquote.Join(wd)+"\nenv "+envs+shellquote.Join(command...)+"\nexit $?")
}
func (e *Environment) execRaw(ctx context.Context, script string) error {
actEnv := e.Env
var vm *VM
if e.vm != nil {
vm = e.vm
} else {
vm = ExistingVM(*actEnv)
}
// Monitor "tart run" command's output so it's not silenced
go vm.MonitorTartRunOutput()
config := e.Config
ssh, err := vm.OpenSSH(ctx, config)
if err != nil {
return err
}
defer ssh.Close()
session, err := ssh.NewSession()
if err != nil {
return err
}
defer session.Close()
os.Stdout.WriteString(script + "\n")
session.Stdin = strings.NewReader(
script,
)
session.Stdout = e.StdOut
session.Stderr = e.StdOut
err = session.Shell()
if err != nil {
return err
}
return session.Wait()
}
func (e *Environment) GetActPath() string {
return e.ToContainerPath(e.HostEnvironment.GetActPath())
}
func (e *Environment) Copy(destPath string, files ...*container.FileEntry) common.Executor {
return e.HostEnvironment.Copy(e.ToHostPath(destPath), files...)
}
func (e *Environment) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
return e.HostEnvironment.CopyTarStream(ctx, e.ToHostPath(destPath), tarStream)
}
func (e *Environment) CopyDir(destPath string, srcPath string, useGitIgnore bool) common.Executor {
return e.HostEnvironment.CopyDir(e.ToHostPath(destPath), srcPath, useGitIgnore)
}
func (e *Environment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
return e.HostEnvironment.GetContainerArchive(ctx, e.ToHostPath(srcPath))
}
func (e *Environment) GetRunnerContext(ctx context.Context) map[string]interface{} {
rctx := e.HostEnvironment.GetRunnerContext(ctx)
rctx["temp"] = e.ToContainerPath(e.TmpDir)
rctx["tool_cache"] = e.ToContainerPath(e.ToolCache)
return rctx
}

282
pkg/tart/vm_darwin.go Normal file
View File

@@ -0,0 +1,282 @@
package tart
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/avast/retry-go"
"golang.org/x/crypto/ssh"
)
const tartCommandName = "tart"
var (
ErrTartNotFound = errors.New("tart command not found")
ErrTartFailed = errors.New("tart command returned non-zero exit code")
ErrVMFailed = errors.New("VM errored")
)
type VM struct {
id string
runcmd *exec.Cmd
}
func ExistingVM(actEnv Env) *VM {
return &VM{
id: actEnv.VirtualMachineID(),
}
}
func CreateNewVM(
ctx context.Context,
actEnv Env,
cpuOverride uint64,
memoryOverride uint64,
) (*VM, error) {
log.Print("CreateNewVM")
vm := &VM{
id: actEnv.VirtualMachineID(),
}
if err := vm.cloneAndConfigure(ctx, actEnv, cpuOverride, memoryOverride); err != nil {
return nil, fmt.Errorf("failed to clone the VM: %w", err)
}
return vm, nil
}
func (vm *VM) cloneAndConfigure(
ctx context.Context,
actEnv Env,
cpuOverride uint64,
memoryOverride uint64,
) error {
_, _, err := Exec(ctx, "clone", actEnv.JobImage, vm.id)
if err != nil {
return err
}
if cpuOverride != 0 {
_, _, err = Exec(ctx, "set", "--cpu", strconv.FormatUint(cpuOverride, 10), vm.id)
if err != nil {
return err
}
}
if memoryOverride != 0 {
_, _, err = Exec(ctx, "set", "--memory", strconv.FormatUint(memoryOverride, 10), vm.id)
if err != nil {
return err
}
}
return nil
}
func (vm *VM) Start(config Config, _ *Env, customDirectoryMounts []string) error {
os.Remove(vm.tartRunOutputPath())
var runArgs = []string{"run"}
if config.Softnet {
runArgs = append(runArgs, "--net-softnet")
}
if config.Headless {
runArgs = append(runArgs, "--no-graphics")
}
for _, customDirectoryMount := range customDirectoryMounts {
runArgs = append(runArgs, "--dir", customDirectoryMount)
}
runArgs = append(runArgs, vm.id)
cmd := exec.Command(tartCommandName, runArgs...)
outputFile, err := os.OpenFile(vm.tartRunOutputPath(), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600)
if err != nil {
return err
}
_, _ = outputFile.WriteString(strings.Join(runArgs, " ") + "\n")
cmd.Stdout = outputFile
cmd.Stderr = outputFile
cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
}
err = cmd.Start()
if err != nil {
return err
}
vm.runcmd = cmd
return nil
}
func (vm *VM) MonitorTartRunOutput() {
outputFile, err := os.Open(vm.tartRunOutputPath())
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to open VM's output file, "+
"looks like the VM wasn't started in \"prepare\" step?\n")
return
}
defer func() {
_ = outputFile.Close()
}()
for {
n, err := io.Copy(os.Stdout, outputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to display VM's output: %v\n", err)
break
}
if n == 0 {
time.Sleep(100 * time.Millisecond)
continue
}
}
}
func (vm *VM) OpenSSH(ctx context.Context, config Config) (*ssh.Client, error) {
ip, err := vm.IP(ctx)
if err != nil {
return nil, err
}
addr := ip + ":22"
var netConn net.Conn
if err := retry.Do(func() error {
dialer := net.Dialer{}
netConn, err = dialer.DialContext(ctx, "tcp", addr)
return err
}, retry.Context(ctx)); err != nil {
return nil, fmt.Errorf("%w: failed to connect via SSH: %v", ErrVMFailed, err)
}
sshConfig := &ssh.ClientConfig{
HostKeyCallback: func(_ string, _ net.Addr, _ ssh.PublicKey) error {
return nil
},
User: config.SSHUsername,
Auth: []ssh.AuthMethod{
ssh.Password(config.SSHPassword),
},
}
sshConn, chans, reqs, err := ssh.NewClientConn(netConn, addr, sshConfig)
if err != nil {
return nil, fmt.Errorf("%w: failed to connect via SSH: %v", ErrVMFailed, err)
}
return ssh.NewClient(sshConn, chans, reqs), nil
}
func (vm *VM) IP(ctx context.Context) (string, error) {
stdout, _, err := Exec(ctx, "ip", "--wait", "60", vm.id)
if err != nil {
return "", err
}
return strings.TrimSpace(stdout), nil
}
func (vm *VM) Stop() error {
log.Println("Stop VM REAL?")
if vm.runcmd != nil {
log.Println("send sigint?")
_ = vm.runcmd.Process.Signal(os.Interrupt)
log.Println("wait?")
_ = vm.runcmd.Wait()
log.Println("wait done?")
return nil
}
_, _, err := Exec(context.Background(), "stop", vm.id)
return err
}
func (vm *VM) Delete() error {
_, _, err := Exec(context.Background(), "delete", vm.id)
if err != nil {
return fmt.Errorf("%w: failed to delete VM %s: %v", ErrVMFailed, vm.id, err)
}
return nil
}
func Exec(
ctx context.Context,
args ...string,
) (string, string, error) {
return ExecWithEnv(ctx, nil, args...)
}
func ExecWithEnv(
ctx context.Context,
env map[string]string,
args ...string,
) (string, string, error) {
cmd := exec.CommandContext(ctx, tartCommandName, args...)
// Base environment
cmd.Env = cmd.Environ()
// Environment overrides
for key, value := range env {
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value))
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
return "", "", fmt.Errorf("%w: %s command not found in PATH, make sure Tart is installed",
ErrTartNotFound, tartCommandName)
}
if _, ok := err.(*exec.ExitError); ok {
// Tart command failed, redefine the error
// to be the Tart-specific output
err = fmt.Errorf("%w: %q", ErrTartFailed, firstNonEmptyLine(stderr.String(), stdout.String()))
}
}
return stdout.String(), stderr.String(), err
}
func firstNonEmptyLine(outputs ...string) string {
for _, output := range outputs {
for _, line := range strings.Split(output, "\n") {
if line != "" {
return line
}
}
}
return ""
}
func (vm *VM) tartRunOutputPath() string {
return filepath.Join(os.TempDir(), fmt.Sprintf("%s-tart-run-output.log", vm.id))
}