diff --git a/cmd/internal/agentworkspace/build.go b/cmd/internal/agentworkspace/build.go
index ee7c43e29..8a0870626 100644
--- a/cmd/internal/agentworkspace/build.go
+++ b/cmd/internal/agentworkspace/build.go
@@ -77,7 +77,7 @@ func (cmd *BuildCmd) Run(ctx context.Context) error {
}()
}
- runner, err := CreateRunner(workspaceInfo)
+ runner, err := CreateRunner(cancelCtx, workspaceInfo)
if err != nil {
return err
}
diff --git a/cmd/internal/agentworkspace/delete.go b/cmd/internal/agentworkspace/delete.go
index 5d267c7c5..328c22571 100644
--- a/cmd/internal/agentworkspace/delete.go
+++ b/cmd/internal/agentworkspace/delete.go
@@ -104,7 +104,7 @@ func removeContainer(
removeVolumes bool,
) error {
log.Debugf("removing Devsy container from server: workspaceId=%s", workspaceInfo.Workspace.ID)
- runner, err := CreateRunner(workspaceInfo)
+ runner, err := CreateRunner(ctx, workspaceInfo)
if err != nil {
return err
}
diff --git a/cmd/internal/agentworkspace/logs.go b/cmd/internal/agentworkspace/logs.go
index 2d72bead9..2726a3b68 100644
--- a/cmd/internal/agentworkspace/logs.go
+++ b/cmd/internal/agentworkspace/logs.go
@@ -56,6 +56,7 @@ func (cmd *LogsCmd) Run(ctx context.Context) error {
}
runner, err := devcontainer.NewRunner(
+ ctx,
config.ContainerDevsyHelperLocation,
config.DefaultAgentDownloadURL(),
workspaceInfo,
diff --git a/cmd/internal/agentworkspace/status.go b/cmd/internal/agentworkspace/status.go
index cb9e087bc..48e6450a6 100644
--- a/cmd/internal/agentworkspace/status.go
+++ b/cmd/internal/agentworkspace/status.go
@@ -51,7 +51,7 @@ func (cmd *StatusCmd) Run(ctx context.Context) error {
}
// create runner
- runner, err := CreateRunner(workspaceInfo)
+ runner, err := CreateRunner(ctx, workspaceInfo)
if err != nil {
return err
}
diff --git a/cmd/internal/agentworkspace/stop.go b/cmd/internal/agentworkspace/stop.go
index 6dd137ec1..7309b33f1 100644
--- a/cmd/internal/agentworkspace/stop.go
+++ b/cmd/internal/agentworkspace/stop.go
@@ -66,7 +66,7 @@ func stopContainer(
workspaceInfo *provider2.AgentWorkspaceInfo,
) error {
log.Debugf("stopping Devsy container")
- runner, err := CreateRunner(workspaceInfo)
+ runner, err := CreateRunner(ctx, workspaceInfo)
if err != nil {
return err
}
diff --git a/cmd/internal/agentworkspace/up.go b/cmd/internal/agentworkspace/up.go
index c8b54d307..8ce715a0d 100644
--- a/cmd/internal/agentworkspace/up.go
+++ b/cmd/internal/agentworkspace/up.go
@@ -197,7 +197,7 @@ func (cmd *UpCmd) devsyUp(
ctx context.Context,
workspaceInfo *provider.AgentWorkspaceInfo,
) (*config2.Result, error) {
- runner, err := CreateRunner(workspaceInfo)
+ runner, err := CreateRunner(ctx, workspaceInfo)
if err != nil {
return nil, err
}
@@ -209,9 +209,11 @@ func (cmd *UpCmd) devsyUp(
}
func CreateRunner(
+ ctx context.Context,
workspaceInfo *provider.AgentWorkspaceInfo,
) (devcontainer.Runner, error) {
return devcontainer.NewRunner(
+ ctx,
config.ContainerDevsyHelperLocation,
config.DefaultAgentDownloadURL(),
workspaceInfo,
diff --git a/cmd/internal/container_tunnel.go b/cmd/internal/container_tunnel.go
index 4f63acf18..b3fb4af1b 100644
--- a/cmd/internal/container_tunnel.go
+++ b/cmd/internal/container_tunnel.go
@@ -69,7 +69,7 @@ func (cmd *ContainerTunnelCmd) Run(ctx context.Context) error {
}
// create runner
- runner, err := agentworkspace.CreateRunner(workspaceInfo)
+ runner, err := agentworkspace.CreateRunner(ctx, workspaceInfo)
if err != nil {
return err
}
diff --git a/cmd/internal/runusercommands.go b/cmd/internal/runusercommands.go
index 4b7b2742c..1bba6762c 100644
--- a/cmd/internal/runusercommands.go
+++ b/cmd/internal/runusercommands.go
@@ -359,7 +359,10 @@ func (cmd *RunUserCommandsCmd) resolveContainer(
}
workspaceConfig := client.WorkspaceConfig()
- runtime := workspace2.NewDockerRuntime(workspaceConfig, cmd.DockerPath)
+ runtime, err := workspace2.NewContainerRuntime(workspaceConfig, cmd.DockerPath)
+ if err != nil {
+ return nil, nil, err
+ }
containerDetails, err := runtime.FindRunning(
ctx, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), cmd.IDLabels,
@@ -393,8 +396,11 @@ func (cmd *RunUserCommandsCmd) resolveContainer(
envArgs = append(envArgs, cmd.buildCLIRemoteEnvArgs()...)
params := &workspace.LifecycleExecParams{
- Ctx: ctx,
- Helper: &docker.DockerHelper{DockerCommand: runtime.DockerCommand()},
+ Ctx: ctx,
+ Helper: &docker.DockerHelper{
+ DockerCommand: runtime.Command(),
+ Environment: runtime.Environment(),
+ },
ContainerID: containerDetails.ID,
EnvArgs: envArgs,
Workdir: workspace2.ResolveExecWorkdir(result, client.Workspace()),
diff --git a/cmd/workspace/exec.go b/cmd/workspace/exec.go
index f657ee6e4..c5ea42f6c 100644
--- a/cmd/workspace/exec.go
+++ b/cmd/workspace/exec.go
@@ -152,7 +152,10 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error {
}
workspaceConfig := client.WorkspaceConfig()
- runtime := workspace2.NewDockerRuntime(workspaceConfig, cmd.DockerPath)
+ runtime, err := workspace2.NewContainerRuntime(workspaceConfig, cmd.DockerPath)
+ if err != nil {
+ return err
+ }
containerDetails, err := runtime.FindRunning(
ctx, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), cmd.IDLabels,
@@ -180,7 +183,8 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error {
emitJSON := mode == output.ModeJSON
err = cmd.execInContainer(ctx, execOpts{
- dockerCmd: runtime.DockerCommand(),
+ dockerCmd: runtime.Command(),
+ dockerEnv: runtime.Environment(),
target: target,
workdir: workdir,
envMap: envMap,
@@ -240,7 +244,8 @@ func (cmd *ExecCmd) runWithContainerID(ctx context.Context, args []string) error
emitJSON := mode == output.ModeJSON
err = cmd.execInContainer(ctx, execOpts{
- dockerCmd: runtime.DockerCommand(),
+ dockerCmd: runtime.Command(),
+ dockerEnv: runtime.Environment(),
target: target,
workdir: workdir,
envMap: envMap,
@@ -304,6 +309,7 @@ func resolveUserEnvProbe(result *devcconfig.Result, cliOverride string) string {
type execOpts struct {
dockerCmd string
+ dockerEnv []string
target workspace2.ContainerTarget
workdir string
envMap map[string]string
@@ -329,7 +335,7 @@ func (cmd *ExecCmd) execInContainer(ctx context.Context, opts execOpts, args []s
redacted := strings.Join(redactExecArgs(execArgs), " ")
log.Debugf("Executing in container: %s %s", opts.dockerCmd, redacted)
- helper := &docker.DockerHelper{DockerCommand: opts.dockerCmd}
+ helper := &docker.DockerHelper{DockerCommand: opts.dockerCmd, Environment: opts.dockerEnv}
return helper.Run(ctx, execArgs, docker.Streams{
Stdin: os.Stdin,
Stdout: os.Stdout,
diff --git a/desktop/src/renderer/public/icons/providers/apple.svg b/desktop/src/renderer/public/icons/providers/apple.svg
new file mode 100644
index 000000000..343a2f4c0
--- /dev/null
+++ b/desktop/src/renderer/public/icons/providers/apple.svg
@@ -0,0 +1,3 @@
+
diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderIcon.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderIcon.svelte
index 59330d0cb..25df74e61 100644
--- a/desktop/src/renderer/src/lib/components/provider/ProviderIcon.svelte
+++ b/desktop/src/renderer/src/lib/components/provider/ProviderIcon.svelte
@@ -5,6 +5,7 @@ let { name, class: className = "size-8" }: { name: string; class?: string } =
const ICON_MAP: Record = {
docker: "./icons/providers/docker.svg",
podman: "./icons/providers/podman.svg",
+ apple: "./icons/providers/apple.svg",
aws: "./icons/providers/aws.svg",
amazon: "./icons/providers/aws.svg",
gcloud: "./icons/providers/gcp.svg",
diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte
index c18f111e9..8567ad09e 100644
--- a/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte
+++ b/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte
@@ -37,6 +37,7 @@ import type { UnlistenFn } from "$lib/ipc/types.js"
const PRESETS = [
{ name: "docker", description: "Local Docker containers" },
{ name: "podman", description: "Local Podman containers" },
+ { name: "apple", description: "Apple containers (macOS 26+, Apple silicon)" },
{ name: "ssh", description: "Remote SSH machines" },
{ name: "kubernetes", description: "Kubernetes clusters" },
{ name: "aws", description: "Amazon Web Services" },
diff --git a/pkg/agent/delivery/factory.go b/pkg/agent/delivery/factory.go
index f61a0ecaa..a6d744bbd 100644
--- a/pkg/agent/delivery/factory.go
+++ b/pkg/agent/delivery/factory.go
@@ -25,40 +25,26 @@ type FactoryOptions struct {
}
func NewAgentDelivery(opts FactoryOptions) AgentDelivery {
- driverType := opts.WorkspaceConfig.Agent.Driver
-
- switch {
+ switch driverType := opts.WorkspaceConfig.Agent.Driver; {
case driverType == provider.CustomDriver:
- log.Debugf("using legacy shell delivery for custom driver")
- log.Warnf(
- "legacy shell delivery is deprecated; platform-native delivery will replace this in a future release",
- )
- return &LegacyShellDelivery{
- ExecFunc: opts.ExecFunc,
- DownloadURL: "",
- }
+ return legacyShellDelivery(opts, "custom driver")
case driverType == provider.KubernetesDriver:
if opts.PodExec == nil {
- log.Debugf("kubernetes pod exec unavailable, using legacy shell delivery")
- log.Warnf(
- "legacy shell delivery is deprecated; platform-native delivery will replace this in a future release",
- )
- return &LegacyShellDelivery{
- ExecFunc: opts.ExecFunc,
- DownloadURL: "",
- }
+ return legacyShellDelivery(opts, "kubernetes pod exec unavailable")
}
log.Debugf("using kubernetes-native delivery (exec stream)")
return &KubernetesDelivery{Exec: opts.PodExec}
+ case driverType == provider.AppleDriver:
+ // Shell delivery launches the agent in one exec, which keeps the VM
+ // alive; it is the supported mechanism here, not a deprecated fallback.
+ log.Debugf("using shell-based delivery for apple driver")
+ return &LegacyShellDelivery{ExecFunc: opts.ExecFunc, DownloadURL: ""}
+
case opts.IsRemoteDocker:
log.Debugf("using remote docker delivery (docker cp)")
- return &RemoteDockerDelivery{
- DockerCommand: opts.DockerCommand,
- Environment: opts.DockerEnv,
- ContainerID: opts.ContainerID,
- }
+ return remoteDockerDelivery(opts)
case driverType == "" || driverType == provider.DockerDriver:
if isDockerLocal(opts.DockerCommand) {
@@ -70,21 +56,29 @@ func NewAgentDelivery(opts FactoryOptions) AgentDelivery {
}
}
log.Debugf("using remote docker delivery for non-local docker daemon")
- return &RemoteDockerDelivery{
- DockerCommand: opts.DockerCommand,
- Environment: opts.DockerEnv,
- ContainerID: opts.ContainerID,
- }
+ return remoteDockerDelivery(opts)
default:
- log.Debugf("using legacy shell delivery for driver: %s", driverType)
- log.Warnf(
- "legacy shell delivery is deprecated; platform-native delivery will replace this in a future release",
- )
- return &LegacyShellDelivery{
- ExecFunc: opts.ExecFunc,
- DownloadURL: "",
- }
+ return legacyShellDelivery(opts, fmt.Sprintf("driver: %s", driverType))
+ }
+}
+
+func remoteDockerDelivery(opts FactoryOptions) AgentDelivery {
+ return &RemoteDockerDelivery{
+ DockerCommand: opts.DockerCommand,
+ Environment: opts.DockerEnv,
+ ContainerID: opts.ContainerID,
+ }
+}
+
+func legacyShellDelivery(opts FactoryOptions, reason string) AgentDelivery {
+ log.Debugf("using legacy shell delivery for %s", reason)
+ log.Warnf(
+ "legacy shell delivery is deprecated; platform-native delivery will replace this in a future release",
+ )
+ return &LegacyShellDelivery{
+ ExecFunc: opts.ExecFunc,
+ DownloadURL: "",
}
}
diff --git a/pkg/agent/delivery/factory_test.go b/pkg/agent/delivery/factory_test.go
index cfad6049a..a2672ac26 100644
--- a/pkg/agent/delivery/factory_test.go
+++ b/pkg/agent/delivery/factory_test.go
@@ -156,3 +156,15 @@ func (m *mockDelivery) DeliverPostStart(_ context.Context, _ PostStartOptions) e
}
func (m *mockDelivery) Cleanup(_ context.Context, _ string) error { return nil }
+
+func TestNewAgentDelivery_AppleUsesShellDelivery(t *testing.T) {
+ opts := FactoryOptions{
+ WorkspaceConfig: &provider.AgentWorkspaceInfo{
+ Agent: provider.ProviderAgentConfig{Driver: provider.AppleDriver},
+ },
+ }
+ d := NewAgentDelivery(opts)
+ if _, ok := d.(*LegacyShellDelivery); !ok {
+ t.Fatalf("apple driver must use shell delivery, got %T", d)
+ }
+}
diff --git a/pkg/apple/helper.go b/pkg/apple/helper.go
new file mode 100644
index 000000000..99caed353
--- /dev/null
+++ b/pkg/apple/helper.go
@@ -0,0 +1,381 @@
+package apple
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/devsy-org/devsy/pkg/command"
+ "github.com/devsy-org/devsy/pkg/devcontainer/config"
+ "github.com/devsy-org/devsy/pkg/image"
+ "k8s.io/apimachinery/pkg/util/wait"
+)
+
+const (
+ containerRunningPollInterval = 500 * time.Millisecond
+ containerRunningTimeout = 60 * time.Second
+ systemStartTimeout = 30 * time.Second
+)
+
+// AppleHelper shells out to Apple's `container` CLI, translating operations to
+// its grouped subcommands and parsing its JSON output (it has no Go-template
+// `--format`).
+type AppleHelper struct {
+ Command string
+ Environment []string
+}
+
+// EnsureSystemRunning starts the container system service if it is not running;
+// it must be running before any container operation.
+func (h *AppleHelper) EnsureSystemRunning(ctx context.Context) error {
+ if h.systemRunning(ctx) {
+ return nil
+ }
+
+ cctx, cancel := context.WithTimeout(ctx, systemStartTimeout)
+ defer cancel()
+ out, err := h.buildCmd(cctx, "system", "start").CombinedOutput()
+ if err != nil {
+ return fmt.Errorf(
+ "start container system service (run `container system start` manually): %s: %w",
+ strings.TrimSpace(string(out)), err,
+ )
+ }
+ return nil
+}
+
+func (h *AppleHelper) ClientVersion(ctx context.Context) string {
+ cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ out, err := h.buildCmd(cctx, "system", "version", "--format", "json").Output()
+ if err != nil {
+ return ""
+ }
+ var versions []struct {
+ AppName string `json:"appName"`
+ Version string `json:"version"`
+ }
+ if json.Unmarshal(out, &versions) != nil {
+ return ""
+ }
+ for _, v := range versions {
+ if v.AppName == "container" {
+ return strings.TrimSpace(v.Version)
+ }
+ }
+ return ""
+}
+
+type PullOptions struct {
+ Image string
+ Platform string
+ Stdout io.Writer
+ Stderr io.Writer
+}
+
+func (h *AppleHelper) Pull(ctx context.Context, opts PullOptions) error {
+ args := []string{"image", "pull"}
+ if opts.Platform != "" {
+ args = append(args, "--platform", opts.Platform)
+ }
+ args = append(args, opts.Image)
+ cmd := h.buildCmd(ctx, args...)
+ cmd.Stdout = opts.Stdout
+ cmd.Stderr = opts.Stderr
+ return cmd.Run()
+}
+
+func (h *AppleHelper) Push(ctx context.Context, image string, stdout, stderr io.Writer) error {
+ cmd := h.buildCmd(ctx, "image", "push", image)
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+ return cmd.Run()
+}
+
+func (h *AppleHelper) Tag(ctx context.Context, image, tag string) error {
+ out, err := h.buildCmd(ctx, "image", "tag", image, tag).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("%s: %w", strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
+
+func (h *AppleHelper) Stop(ctx context.Context, id string) error {
+ out, err := h.buildCmd(ctx, "stop", id).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("%s: %w", strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
+
+func (h *AppleHelper) Remove(ctx context.Context, id string) error {
+ out, err := h.buildCmd(ctx, "delete", id).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("%s: %w", strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
+
+// Streams bundles the optional IO for a `container` invocation.
+type Streams struct {
+ Stdin io.Reader
+ Stdout io.Writer
+ Stderr io.Writer
+}
+
+func (h *AppleHelper) Run(ctx context.Context, args []string, s Streams) error {
+ return h.RunWithDir(ctx, "", args, s)
+}
+
+func (h *AppleHelper) RunWithDir(ctx context.Context, dir string, args []string, s Streams) error {
+ cmd := h.buildCmd(ctx, args...)
+ cmd.Dir = dir
+ cmd.Stdin = s.Stdin
+ cmd.Stdout = s.Stdout
+ cmd.Stderr = s.Stderr
+ return cmd.Run()
+}
+
+func (h *AppleHelper) StartContainer(ctx context.Context, id string) error {
+ out, err := h.buildCmd(ctx, "start", id).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("failed to start container: %s: %w", strings.TrimSpace(string(out)), err)
+ }
+ return nil
+}
+
+func (h *AppleHelper) WaitContainerRunning(ctx context.Context, id string) error {
+ // Inspect errors are treated as transient (the container may not be
+ // queryable immediately after start) and polling continues, but the last
+ // one is retained so a timeout surfaces the real cause instead of a bare
+ // deadline. A terminal (exited) state fails fast.
+ var lastErr error
+ pollErr := wait.PollUntilContextTimeout(
+ ctx, containerRunningPollInterval, containerRunningTimeout, true,
+ func(ctx context.Context) (bool, error) {
+ details, err := h.InspectContainers(ctx, []string{id})
+ if err != nil {
+ lastErr = err
+ return false, nil
+ }
+ if len(details) == 0 {
+ return false, nil
+ }
+ switch strings.ToLower(details[0].State.Status) {
+ case stateRunning:
+ return true, nil
+ case stateExited:
+ return false, fmt.Errorf("container %s exited before reaching running state", id)
+ default:
+ return false, nil
+ }
+ },
+ )
+ if pollErr != nil && lastErr != nil {
+ return fmt.Errorf("%w (last inspect error: %w)", pollErr, lastErr)
+ }
+ return pollErr
+}
+
+func (h *AppleHelper) GetContainerLogs(
+ ctx context.Context,
+ id string,
+ stdout, stderr io.Writer,
+) error {
+ cmd := h.buildCmd(ctx, "logs", id)
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+ return cmd.Run()
+}
+
+func (h *AppleHelper) InspectContainers(
+ ctx context.Context,
+ ids []string,
+) ([]config.ContainerDetails, error) {
+ if len(ids) == 0 {
+ return nil, nil
+ }
+ args := append([]string{"inspect"}, ids...)
+ out, err := h.buildCmd(ctx, args...).Output()
+ if err != nil {
+ return nil, fmt.Errorf("inspect container: %w", command.WrapCommandError(out, err))
+ }
+
+ var raw []containerInspect
+ if err := json.Unmarshal(out, &raw); err != nil {
+ return nil, fmt.Errorf("parse inspect output: %w", err)
+ }
+
+ details := make([]config.ContainerDetails, 0, len(raw))
+ for _, r := range raw {
+ details = append(details, r.toContainerDetails())
+ }
+ return details, nil
+}
+
+func (h *AppleHelper) FindDevContainer(
+ ctx context.Context,
+ labels []string,
+) (*config.ContainerDetails, error) {
+ all, err := h.listContainers(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, c := range all {
+ if strings.ToLower(c.Status.State) == "removing" {
+ continue
+ }
+ if matchesLabels(c.Configuration.Labels, labels) {
+ details := c.toContainerDetails()
+ return &details, nil
+ }
+ }
+ return nil, nil
+}
+
+func (h *AppleHelper) FindContainerByID(
+ ctx context.Context,
+ ids []string,
+) (*config.ContainerDetails, error) {
+ details, err := h.InspectContainers(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ for i := range details {
+ if strings.ToLower(details[i].State.Status) != "removing" {
+ return &details[i], nil
+ }
+ }
+ return nil, nil
+}
+
+func matchesLabels(containerLabels map[string]string, selectors []string) bool {
+ for _, sel := range selectors {
+ key, value, _ := strings.Cut(sel, "=")
+ if containerLabels[key] != value {
+ return false
+ }
+ }
+ return true
+}
+
+// InspectImage falls back to the remote registry config when tryRemote is set
+// and the image is not present locally.
+func (h *AppleHelper) InspectImage(
+ ctx context.Context,
+ imageName string,
+ tryRemote bool,
+) (*config.ImageDetails, error) {
+ out, err := h.buildCmd(ctx, "image", "inspect", imageName).Output()
+ if err != nil {
+ err = fmt.Errorf("inspect image %s: %w", imageName, command.WrapCommandError(out, err))
+ } else {
+ var raw []imageInspect
+ switch {
+ case json.Unmarshal(out, &raw) != nil:
+ err = fmt.Errorf("parse image inspect output for %s", imageName)
+ case len(raw) == 0:
+ err = fmt.Errorf("no image details found for %s", imageName)
+ default:
+ return raw[0].toImageDetails(runtime.GOARCH), nil
+ }
+ }
+
+ if !tryRemote {
+ return nil, err
+ }
+
+ imageConfig, _, rerr := image.GetImageConfig(ctx, imageName)
+ if rerr != nil {
+ return nil, fmt.Errorf("get image config remotely: %w", rerr)
+ }
+ return &config.ImageDetails{
+ ID: imageName,
+ Config: config.ImageDetailsConfig{
+ User: imageConfig.Config.User,
+ Env: imageConfig.Config.Env,
+ Labels: imageConfig.Config.Labels,
+ Entrypoint: imageConfig.Config.Entrypoint,
+ Cmd: imageConfig.Config.Cmd,
+ },
+ }, nil
+}
+
+func (h *AppleHelper) GetImageTag(ctx context.Context, imageID string) (string, error) {
+ out, err := h.buildCmd(ctx, "image", "inspect", imageID).Output()
+ if err != nil {
+ return "", fmt.Errorf("inspect image: %w", command.WrapCommandError(out, err))
+ }
+ var raw []imageInspect
+ if err := json.Unmarshal(out, &raw); err != nil {
+ return "", fmt.Errorf("parse image inspect output: %w", err)
+ }
+ if len(raw) == 0 {
+ return "", nil
+ }
+ return parseImageTag(raw[0].Configuration.Name), nil
+}
+
+// parseImageTag returns the tag from an image reference. It inspects the final
+// path segment (so a registry port like localhost:5000/alpine:3.20 is not
+// mistaken for the tag) and drops any @digest suffix (which is not a tag).
+func parseImageTag(ref string) string {
+ lastSegment := ref[strings.LastIndex(ref, "/")+1:]
+ lastSegment, _, _ = strings.Cut(lastSegment, "@")
+ _, tag, found := strings.Cut(lastSegment, ":")
+ if !found {
+ return ""
+ }
+ return strings.TrimSpace(tag)
+}
+
+// EnsureBuilderRunning starts the BuildKit builder; an already-running builder is
+// a no-op, other failures are returned so a build never runs against a dead builder.
+func (h *AppleHelper) EnsureBuilderRunning(ctx context.Context) error {
+ out, err := h.buildCmd(ctx, "builder", "start").CombinedOutput()
+ if err == nil {
+ return nil
+ }
+ if strings.Contains(strings.ToLower(string(out)), "already running") {
+ return nil
+ }
+ return fmt.Errorf("start container builder: %s: %w", strings.TrimSpace(string(out)), err)
+}
+
+func (h *AppleHelper) buildCmd(ctx context.Context, args ...string) *exec.Cmd {
+ //nolint:gosec // G204: operator-configured binary, internally-built args (as in pkg/docker)
+ cmd := exec.CommandContext(ctx, h.Command, args...)
+ if h.Environment != nil {
+ cmd.Env = append(os.Environ(), h.Environment...)
+ }
+ return cmd
+}
+
+func (h *AppleHelper) systemRunning(ctx context.Context) bool {
+ cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ out, err := h.buildCmd(cctx, "system", "status").CombinedOutput()
+ if err != nil {
+ return false
+ }
+ return strings.Contains(strings.ToLower(string(out)), stateRunning)
+}
+
+func (h *AppleHelper) listContainers(ctx context.Context) ([]containerInspect, error) {
+ out, err := h.buildCmd(ctx, "list", "--all", "--format", "json").Output()
+ if err != nil {
+ return nil, fmt.Errorf("list containers: %w", command.WrapCommandError(out, err))
+ }
+ var raw []containerInspect
+ if err := json.Unmarshal(out, &raw); err != nil {
+ return nil, fmt.Errorf("parse list output: %w", err)
+ }
+ return raw, nil
+}
diff --git a/pkg/apple/helper_test.go b/pkg/apple/helper_test.go
new file mode 100644
index 000000000..bfcec156f
--- /dev/null
+++ b/pkg/apple/helper_test.go
@@ -0,0 +1,163 @@
+//go:build !windows
+
+package apple
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+)
+
+func TestMatchesLabels(t *testing.T) {
+ labels := map[string]string{"dev.containers.id": "ws-1", "k": "v"}
+ cases := []struct {
+ name string
+ selectors []string
+ want bool
+ }{
+ {"empty selectors match", nil, true},
+ {"single match", []string{"dev.containers.id=ws-1"}, true},
+ {"all match", []string{"dev.containers.id=ws-1", "k=v"}, true},
+ {"value mismatch", []string{"dev.containers.id=other"}, false},
+ {"missing key", []string{"absent=1"}, false},
+ {"empty value required but present", []string{"k="}, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := matchesLabels(labels, c.selectors); got != c.want {
+ t.Errorf("matchesLabels(%v) = %v, want %v", c.selectors, got, c.want)
+ }
+ })
+ }
+}
+
+// stubContainer writes a fake `container` executable that echoes a canned stdout
+// and exits with the given code, so helper command construction/parsing can be
+// tested without the real CLI.
+func stubContainer(t *testing.T, stdout string, exitCode int) string {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "container")
+ script := "#!/bin/sh\ncat <<'EOF'\n" + stdout + "\nEOF\nexit " + strconv.Itoa(exitCode) + "\n"
+ //nolint:gosec // G306: a test stub must be executable
+ if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
+ t.Fatalf("write stub: %v", err)
+ }
+ return path
+}
+
+func TestGetImageTag(t *testing.T) {
+ cases := []struct {
+ name string
+ stdout string
+ wantTag string
+ wantErr bool
+ }{
+ {
+ name: "tag parsed from reference",
+ stdout: `[{"id":"abc","configuration":{"name":"docker.io/library/alpine:3.20"}}]`,
+ wantTag: "3.20",
+ },
+ {
+ name: "no colon yields empty tag",
+ stdout: `[{"id":"abc","configuration":{"name":"alpine"}}]`,
+ wantTag: "",
+ },
+ {
+ name: "registry port is not mistaken for the tag",
+ stdout: `[{"id":"abc","configuration":{"name":"localhost:5000/library/alpine:3.20"}}]`,
+ wantTag: "3.20",
+ },
+ {
+ name: "registry port with no tag yields empty",
+ stdout: `[{"id":"abc","configuration":{"name":"localhost:5000/library/alpine"}}]`,
+ wantTag: "",
+ },
+ {
+ name: "empty array yields empty tag",
+ stdout: `[]`,
+ wantTag: "",
+ },
+ {
+ name: "invalid json is an error, not a false empty tag",
+ stdout: `not json`,
+ wantErr: true,
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ h := &AppleHelper{Command: stubContainer(t, c.stdout, 0)}
+ got, err := h.GetImageTag(context.Background(), "img")
+ if c.wantErr {
+ if err == nil {
+ t.Fatalf("expected error, got tag %q", got)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != c.wantTag {
+ t.Errorf("GetImageTag = %q, want %q", got, c.wantTag)
+ }
+ })
+ }
+}
+
+func TestParseImageTag(t *testing.T) {
+ cases := []struct{ ref, want string }{
+ {"docker.io/library/alpine:1.2", "1.2"},
+ {"alpine", ""},
+ {"localhost:5000/library/alpine:v1", "v1"},
+ {"localhost:5000/library/alpine", ""},
+ {"alpine@sha256:deadbeef", ""},
+ {"alpine:2.1@sha256:deadbeef", "2.1"},
+ }
+ for _, c := range cases {
+ if got := parseImageTag(c.ref); got != c.want {
+ t.Errorf("parseImageTag(%q) = %q, want %q", c.ref, got, c.want)
+ }
+ }
+}
+
+func TestWaitContainerRunningFailsFastOnExit(t *testing.T) {
+ // A container reporting a terminal (stopped→exited) state must error
+ // immediately rather than block for the full poll timeout.
+ stdout := `[{"id":"c1","configuration":{"id":"c1"},"status":{"state":"stopped"}}]`
+ h := &AppleHelper{Command: stubContainer(t, stdout, 0)}
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ start := time.Now()
+ err := h.WaitContainerRunning(ctx, "c1")
+ if err == nil {
+ t.Fatal("expected an error for an exited container")
+ }
+ if elapsed := time.Since(start); elapsed > 5*time.Second {
+ t.Errorf("WaitContainerRunning did not fail fast: took %s", elapsed)
+ }
+}
+
+func TestEnsureBuilderRunning(t *testing.T) {
+ // Exit 0 (the real CLI's behavior even when already running) → no error.
+ okHelper := &AppleHelper{Command: stubContainer(t, "", 0)}
+ if err := okHelper.EnsureBuilderRunning(context.Background()); err != nil {
+ t.Errorf("exit 0 must succeed, got %v", err)
+ }
+
+ // A genuine non-zero failure must propagate (no longer swallowed).
+ failHelper := &AppleHelper{Command: stubContainer(t, "boom: cannot start", 1)}
+ if err := failHelper.EnsureBuilderRunning(context.Background()); err == nil {
+ t.Error("non-zero builder start must return an error")
+ }
+
+ // A non-zero exit that reports "already running" is tolerated.
+ alreadyHelper := &AppleHelper{Command: stubContainer(t, "builder already running", 1)}
+ if err := alreadyHelper.EnsureBuilderRunning(context.Background()); err != nil {
+ t.Errorf("already-running must be tolerated, got %v", err)
+ }
+}
diff --git a/pkg/apple/integration_test.go b/pkg/apple/integration_test.go
new file mode 100644
index 000000000..c8c6ba77b
--- /dev/null
+++ b/pkg/apple/integration_test.go
@@ -0,0 +1,120 @@
+package apple
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/devsy-org/devsy/pkg/devcontainer/config"
+)
+
+// probe bundles the state shared across the integration steps so each helper
+// stays within the project's argument limit.
+type probe struct {
+ t *testing.T
+ h *AppleHelper
+ ctx context.Context
+}
+
+// TestAppleHelperIntegration exercises AppleHelper against a real `container`
+// CLI. It is gated behind DEVSY_APPLE_E2E=1 so it never runs in normal CI; it
+// requires an Apple silicon Mac (macOS 26+) with `container` installed.
+func TestAppleHelperIntegration(t *testing.T) {
+ if os.Getenv("DEVSY_APPLE_E2E") != "1" {
+ t.Skip("set DEVSY_APPLE_E2E=1 to run against a real container CLI")
+ }
+
+ const (
+ image = "alpine:latest"
+ name = "devsy-e2e-probe"
+ label = "devsy.e2e=1"
+ )
+
+ p := &probe{t: t, h: &AppleHelper{Command: "container"}, ctx: context.Background()}
+
+ if err := p.h.EnsureSystemRunning(p.ctx); err != nil {
+ t.Fatalf("EnsureSystemRunning: %v", err)
+ }
+
+ p.pullImage(image)
+ _ = p.h.Remove(p.ctx, name) // best-effort cleanup of a stale run
+ p.runProbe(name, label, image)
+ t.Cleanup(func() {
+ _ = p.h.Stop(context.Background(), name)
+ _ = p.h.Remove(context.Background(), name)
+ })
+
+ found := p.findRunning(label)
+ p.execEcho(found)
+ p.teardown(found)
+}
+
+func (p *probe) pullImage(image string) {
+ p.t.Helper()
+ var out bytes.Buffer
+ if err := p.h.Pull(p.ctx, PullOptions{Image: image, Stdout: &out, Stderr: &out}); err != nil {
+ p.t.Fatalf("Pull: %v\n%s", err, out.String())
+ }
+ if _, err := p.h.InspectImage(p.ctx, image, false); err != nil {
+ p.t.Fatalf("InspectImage: %v", err)
+ }
+}
+
+func (p *probe) runProbe(name, label, image string) {
+ p.t.Helper()
+ var out bytes.Buffer
+ args := []string{"run", "-d", "--name", name, "-l", label, image, "sleep", "120"}
+ if err := p.h.Run(p.ctx, args, Streams{Stdout: &out, Stderr: &out}); err != nil {
+ p.t.Fatalf("run: %v\n%s", err, out.String())
+ }
+ if err := p.h.WaitContainerRunning(p.ctx, name); err != nil {
+ p.t.Fatalf("WaitContainerRunning: %v", err)
+ }
+}
+
+func (p *probe) findRunning(label string) *config.ContainerDetails {
+ p.t.Helper()
+ found, err := p.h.FindDevContainer(p.ctx, []string{label})
+ if err != nil {
+ p.t.Fatalf("FindDevContainer: %v", err)
+ }
+ if found == nil {
+ p.t.Fatal("FindDevContainer returned nil for a running labelled container")
+ }
+ if found.State.Status != stateRunning {
+ p.t.Errorf("found.State.Status = %q, want %q", found.State.Status, stateRunning)
+ }
+ if found.Config.Labels["devsy.e2e"] != "1" {
+ p.t.Errorf("label devsy.e2e = %q, want 1", found.Config.Labels["devsy.e2e"])
+ }
+ return found
+}
+
+func (p *probe) execEcho(c *config.ContainerDetails) {
+ p.t.Helper()
+ var out bytes.Buffer
+ if err := p.h.Run(
+ p.ctx,
+ []string{"exec", c.ID, "echo", "ok"},
+ Streams{Stdout: &out, Stderr: &out},
+ ); err != nil {
+ p.t.Fatalf("exec: %v\n%s", err, out.String())
+ }
+ if out.String() == "" {
+ p.t.Error("exec produced no output")
+ }
+}
+
+func (p *probe) teardown(c *config.ContainerDetails) {
+ p.t.Helper()
+ if err := p.h.Stop(p.ctx, c.ID); err != nil {
+ p.t.Fatalf("Stop: %v", err)
+ }
+ // Give the state a moment to settle before delete.
+ time.Sleep(time.Second)
+ if err := p.h.Remove(p.ctx, c.ID); err != nil {
+ p.t.Fatalf("Remove: %v", err)
+ }
+}
diff --git a/pkg/apple/types.go b/pkg/apple/types.go
new file mode 100644
index 000000000..367eeff5f
--- /dev/null
+++ b/pkg/apple/types.go
@@ -0,0 +1,207 @@
+package apple
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/devsy-org/devsy/pkg/devcontainer/config"
+)
+
+const (
+ stateRunning = "running"
+ stateExited = "exited"
+ mountTypeBind = "bind"
+ archUnknown = "unknown" // placeholder arch in Apple's multi-arch image index
+)
+
+// Apple's inspect JSON is unrelated to Docker's; these structs mirror the
+// relevant subset and map onto the Docker-shaped config types, keeping all
+// schema coupling in this file.
+type containerInspect struct {
+ ID string `json:"id"`
+ Configuration containerConfig `json:"configuration"`
+ Status containerStatusRaw `json:"status"`
+}
+
+type containerConfig struct {
+ ID string `json:"id"`
+ CreationDate string `json:"creationDate"`
+ Image containerImageRef `json:"image"`
+ InitProcess initProcess `json:"initProcess"`
+ Labels map[string]string `json:"labels"`
+ Mounts []containerMount `json:"mounts"`
+ Platform platform `json:"platform"`
+}
+
+type containerImageRef struct {
+ Reference string `json:"reference"`
+}
+
+type initProcess struct {
+ WorkingDirectory string `json:"workingDirectory"`
+ User userRef `json:"user"`
+}
+
+type userRef struct {
+ ID userID `json:"id"`
+}
+
+type userID struct {
+ UID int `json:"uid"`
+ GID int `json:"gid"`
+}
+
+type containerMount struct {
+ Type mountType `json:"type"`
+ Source string `json:"source"`
+ Destination string `json:"destination"`
+}
+
+// mountType decodes Apple's mount type, an object keyed by the type name
+// (e.g. {"virtiofs": {}}) rather than a plain string.
+type mountType string
+
+func (m *mountType) UnmarshalJSON(data []byte) error {
+ // Tolerate a plain string form as well as the object form.
+ var s string
+ if err := json.Unmarshal(data, &s); err == nil {
+ *m = mountType(s)
+ return nil
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return err
+ }
+ for k := range obj {
+ *m = mountType(k)
+ return nil
+ }
+ return nil
+}
+
+// dockerType maps a virtiofs host share onto the Docker "bind" vocabulary.
+func (m mountType) dockerType() string {
+ if m == "virtiofs" {
+ return mountTypeBind
+ }
+ return string(m)
+}
+
+type platform struct {
+ Architecture string `json:"architecture"`
+ OS string `json:"os"`
+}
+
+type containerStatusRaw struct {
+ State string `json:"state"`
+ StartedDate string `json:"startedDate"`
+}
+
+func (c containerInspect) toContainerDetails() config.ContainerDetails {
+ id := c.ID
+ if id == "" {
+ id = c.Configuration.ID
+ }
+
+ mounts := make([]config.ContainerMount, 0, len(c.Configuration.Mounts))
+ for _, m := range c.Configuration.Mounts {
+ mounts = append(mounts, config.ContainerMount{
+ Type: m.Type.dockerType(),
+ Source: m.Source,
+ Destination: m.Destination,
+ })
+ }
+
+ return config.ContainerDetails{
+ ID: id,
+ Created: c.Configuration.CreationDate,
+ State: config.ContainerDetailsState{
+ Status: normalizeState(c.Status.State),
+ StartedAt: c.Status.StartedDate,
+ },
+ Config: config.ContainerDetailsConfig{
+ Labels: c.Configuration.Labels,
+ WorkingDir: c.Configuration.InitProcess.WorkingDirectory,
+ User: fmt.Sprintf("%d", c.Configuration.InitProcess.User.ID.UID),
+ LegacyImage: c.Configuration.Image.Reference,
+ },
+ Mounts: mounts,
+ }
+}
+
+func normalizeState(state string) string {
+ s := strings.ToLower(strings.TrimSpace(state))
+ if s == "stopped" {
+ // Docker uses "exited" for a container that ran and stopped; the
+ // runner's terminal-state checks key off that vocabulary.
+ return stateExited
+ }
+ return s
+}
+
+type imageInspect struct {
+ ID string `json:"id"`
+ Configuration imageConfigOuter `json:"configuration"`
+ Variants []imageVariant `json:"variants"`
+}
+
+type imageConfigOuter struct {
+ Name string `json:"name"`
+}
+
+type imageVariant struct {
+ Config imageVariantConfig `json:"config"`
+}
+
+type imageVariantConfig struct {
+ Architecture string `json:"architecture"`
+ OS string `json:"os"`
+ Config ociImageCfg `json:"config"`
+}
+
+type ociImageCfg struct {
+ User string `json:"User"`
+ Env []string `json:"Env"`
+ Cmd []string `json:"Cmd"`
+ Entrypoint []string `json:"Entrypoint"`
+ Labels map[string]string `json:"Labels"`
+ WorkingDir string `json:"WorkingDir"`
+}
+
+// toImageDetails prefers the variant matching preferArch, else the first known-arch variant.
+func (i imageInspect) toImageDetails(preferArch string) *config.ImageDetails {
+ variant := i.selectVariant(preferArch)
+ if variant == nil {
+ return &config.ImageDetails{ID: i.ID}
+ }
+
+ return &config.ImageDetails{
+ ID: i.ID,
+ Config: config.ImageDetailsConfig{
+ User: variant.Config.Config.User,
+ Env: variant.Config.Config.Env,
+ Labels: variant.Config.Config.Labels,
+ Entrypoint: variant.Config.Config.Entrypoint,
+ Cmd: variant.Config.Config.Cmd,
+ },
+ }
+}
+
+func (i imageInspect) selectVariant(preferArch string) *imageVariant {
+ var fallback *imageVariant
+ for idx := range i.Variants {
+ v := &i.Variants[idx]
+ arch := strings.ToLower(v.Config.Architecture)
+ if arch == "" || arch == archUnknown {
+ continue
+ }
+ if preferArch != "" && arch == strings.ToLower(preferArch) {
+ return v
+ }
+ if fallback == nil {
+ fallback = v
+ }
+ }
+ return fallback
+}
diff --git a/pkg/apple/types_test.go b/pkg/apple/types_test.go
new file mode 100644
index 000000000..57ac6ed99
--- /dev/null
+++ b/pkg/apple/types_test.go
@@ -0,0 +1,189 @@
+package apple
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+// containerInspectFixture is verbatim output from `container inspect` on
+// macOS 26.5 / container 1.1.0.
+const containerInspectFixture = `[
+ {
+ "configuration" : {
+ "creationDate" : "2026-07-25T01:57:23Z",
+ "id" : "devsy-probe",
+ "image" : { "reference" : "docker.io/library/alpine:latest" },
+ "initProcess" : {
+ "arguments" : ["300"],
+ "environment" : ["PATH=/usr/local/sbin:/usr/local/bin"],
+ "executable" : "sleep",
+ "user" : { "id" : { "gid" : 0, "uid" : 0 } },
+ "workingDirectory" : "/"
+ },
+ "labels" : { "devsy.test" : "1", "dev.containers.id" : "ws-42" },
+ "mounts" : [
+ { "type" : { "virtiofs" : {} }, "options" : [], "source" : "/host/work", "destination" : "/workspaces/work" }
+ ],
+ "platform" : { "architecture" : "arm64", "os" : "linux" }
+ },
+ "id" : "devsy-probe",
+ "status" : { "startedDate" : "2026-07-25T01:57:25Z", "state" : "running" }
+ }
+]`
+
+func TestContainerInspectMapping(t *testing.T) {
+ var raw []containerInspect
+ if err := json.Unmarshal([]byte(containerInspectFixture), &raw); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(raw) != 1 {
+ t.Fatalf("expected 1 container, got %d", len(raw))
+ }
+
+ got := raw[0].toContainerDetails()
+
+ fields := []struct {
+ name, got, want string
+ }{
+ {"ID", got.ID, "devsy-probe"},
+ {"State.Status", got.State.Status, stateRunning},
+ {"StartedAt", got.State.StartedAt, "2026-07-25T01:57:25Z"},
+ {"WorkingDir", got.Config.WorkingDir, "/"},
+ {"User", got.Config.User, "0"},
+ {"LegacyImage", got.Config.LegacyImage, "docker.io/library/alpine:latest"},
+ {"label", got.Config.Labels["dev.containers.id"], "ws-42"},
+ }
+ for _, f := range fields {
+ if f.got != f.want {
+ t.Errorf("%s = %q, want %q", f.name, f.got, f.want)
+ }
+ }
+
+ if len(got.Mounts) != 1 || got.Mounts[0].Destination != "/workspaces/work" {
+ t.Errorf("mounts = %+v", got.Mounts)
+ }
+ // virtiofs host share must normalize to the Docker "bind" vocabulary.
+ if got.Mounts[0].Type != "bind" {
+ t.Errorf("mount type = %q, want bind", got.Mounts[0].Type)
+ }
+}
+
+func TestNormalizeState(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"running", stateRunning},
+ {"Running", stateRunning},
+ {"stopped", stateExited},
+ {"Stopped", stateExited},
+ }
+ for _, c := range cases {
+ if got := normalizeState(c.in); got != c.want {
+ t.Errorf("normalizeState(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+// imageInspectFixture is verbatim output from `container image inspect` for a
+// multi-arch image; only the arm64 variant should be selected on Apple silicon.
+const imageInspectFixture = `[
+ {
+ "configuration" : { "name" : "docker.io/library/alpine:latest" },
+ "id" : "28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b",
+ "variants" : [
+ {
+ "config" : {
+ "architecture" : "amd64",
+ "os" : "linux",
+ "config" : { "Cmd" : ["/bin/sh"], "Env" : ["PATH=/amd64"], "WorkingDir" : "/" }
+ }
+ },
+ {
+ "config" : {
+ "architecture" : "unknown",
+ "os" : "unknown",
+ "config" : {}
+ }
+ },
+ {
+ "config" : {
+ "architecture" : "arm64",
+ "os" : "linux",
+ "config" : { "Cmd" : ["/bin/sh"], "Env" : ["PATH=/arm64"], "User" : "root", "WorkingDir" : "/" }
+ }
+ }
+ ]
+ }
+]`
+
+func TestImageInspectMapping(t *testing.T) {
+ var raw []imageInspect
+ if err := json.Unmarshal([]byte(imageInspectFixture), &raw); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ got := raw[0].toImageDetails("arm64")
+
+ if got.ID != "28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" {
+ t.Errorf("ID = %q", got.ID)
+ }
+ // Must pick the arm64 variant, not amd64 (first) or unknown.
+ if len(got.Config.Env) != 1 || got.Config.Env[0] != "PATH=/arm64" {
+ t.Errorf("Env = %v, want [PATH=/arm64] (arm64 variant)", got.Config.Env)
+ }
+ if got.Config.User != "root" {
+ t.Errorf("User = %q, want root", got.Config.User)
+ }
+}
+
+func TestMountTypeUnmarshal(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"object form", `{"virtiofs":{}}`, "bind"},
+ {"plain string form", `"tmpfs"`, "tmpfs"},
+ {"non-virtiofs object passthrough", `{"tmpfs":{}}`, "tmpfs"},
+ {"empty object yields empty", `{}`, ""},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ var m mountType
+ if err := json.Unmarshal([]byte(c.in), &m); err != nil {
+ t.Fatalf("unmarshal %s: %v", c.in, err)
+ }
+ if got := m.dockerType(); got != c.want {
+ t.Errorf("dockerType() = %q, want %q", got, c.want)
+ }
+ })
+ }
+}
+
+func TestImageInspectNoKnownVariant(t *testing.T) {
+ raw := []imageInspect{{
+ ID: "id1",
+ Variants: []imageVariant{
+ {Config: imageVariantConfig{Architecture: "unknown", OS: "unknown"}},
+ {Config: imageVariantConfig{Architecture: ""}},
+ },
+ }}
+ got := raw[0].toImageDetails("arm64")
+ if got.ID != "id1" {
+ t.Errorf("ID = %q, want id1", got.ID)
+ }
+ if got.Config.User != "" || len(got.Config.Env) != 0 {
+ t.Errorf("expected empty config when no known-arch variant, got %+v", got.Config)
+ }
+}
+
+func TestImageInspectSkipsUnknownArch(t *testing.T) {
+ var raw []imageInspect
+ if err := json.Unmarshal([]byte(imageInspectFixture), &raw); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ // A platform with no matching variant falls back to the first known arch (amd64),
+ // never the "unknown" variant.
+ got := raw[0].toImageDetails("s390x")
+ if len(got.Config.Env) == 0 || got.Config.Env[0] != "PATH=/amd64" {
+ t.Errorf("fallback Env = %v, want amd64 variant", got.Config.Env)
+ }
+}
diff --git a/pkg/devcontainer/build.go b/pkg/devcontainer/build.go
index fcf1e636c..76989151e 100644
--- a/pkg/devcontainer/build.go
+++ b/pkg/devcontainer/build.go
@@ -493,7 +493,7 @@ func (r *runner) executeBuild(
// check if we should fallback to dockerless.
// This should only be OSS kubernetes as of March 06, 2025.
- dockerDriver, ok := r.driver.(driver.DockerDriver)
+ dockerDriver, ok := r.driver.(driver.ImageDriver)
if options.ForceDockerless || !ok {
if r.workspaceConfig.Agent.Dockerless.Disabled == pkgconfig.BoolTrue {
return nil, fmt.Errorf(
diff --git a/pkg/devcontainer/compose.go b/pkg/devcontainer/compose.go
index b7e89f1f1..1be2f9f15 100644
--- a/pkg/devcontainer/compose.go
+++ b/pkg/devcontainer/compose.go
@@ -95,14 +95,14 @@ type composeUpParams struct {
}
func (r *runner) composeHelper() (*compose.ComposeHelper, error) {
- dockerDriver, ok := r.driver.(driver.DockerDriver)
+ composeDriver, ok := r.driver.(driver.ComposeDriver)
if !ok {
return nil, fmt.Errorf(
"docker compose is not supported by this provider, choose a different one",
)
}
- return dockerDriver.ComposeHelper()
+ return composeDriver.ComposeHelper()
}
func (r *runner) stopDockerCompose(ctx context.Context, projectName string) error {
@@ -374,7 +374,7 @@ func (r *runner) updateContainerUserUID(
ctx context.Context,
parsedConfig *config.DevContainerConfig,
) error {
- dockerDriver, ok := r.driver.(driver.DockerDriver)
+ dockerDriver, ok := r.driver.(driver.ImageDriver)
if !ok {
return nil
}
diff --git a/pkg/devcontainer/inspect.go b/pkg/devcontainer/inspect.go
index 064582acb..175df803d 100644
--- a/pkg/devcontainer/inspect.go
+++ b/pkg/devcontainer/inspect.go
@@ -10,7 +10,7 @@ import (
)
func (r *runner) inspectImage(ctx context.Context, imageName string) (*config.ImageDetails, error) {
- dockerDriver, ok := r.driver.(driver.DockerDriver)
+ dockerDriver, ok := r.driver.(driver.ImageDriver)
if ok {
return dockerDriver.InspectImage(ctx, imageName)
}
@@ -44,7 +44,7 @@ func (r *runner) inspectImage(ctx context.Context, imageName string) (*config.Im
}
func (r *runner) getImageTag(ctx context.Context, imageID string) (string, error) {
- dockerDriver, ok := r.driver.(driver.DockerDriver)
+ dockerDriver, ok := r.driver.(driver.ImageDriver)
if ok {
return dockerDriver.GetImageTag(ctx, imageID)
}
diff --git a/pkg/devcontainer/prebuild.go b/pkg/devcontainer/prebuild.go
index cca41a3e1..2dce58fc3 100644
--- a/pkg/devcontainer/prebuild.go
+++ b/pkg/devcontainer/prebuild.go
@@ -13,7 +13,7 @@ import (
)
func (r *runner) Build(ctx context.Context, options provider.BuildOptions) (string, error) {
- dockerDriver, ok := r.driver.(driver.DockerDriver)
+ dockerDriver, ok := r.driver.(driver.ImageDriver)
if !ok {
return "", fmt.Errorf("building only supported with docker driver")
}
diff --git a/pkg/devcontainer/run.go b/pkg/devcontainer/run.go
index ec00a3152..311100f25 100644
--- a/pkg/devcontainer/run.go
+++ b/pkg/devcontainer/run.go
@@ -83,10 +83,11 @@ type runner struct {
}
func NewRunner(
+ ctx context.Context,
agentPath, agentDownloadURL string,
workspaceConfig *provider.AgentWorkspaceInfo,
) (Runner, error) {
- drv, err := drivercreate.NewDriver(workspaceConfig)
+ drv, err := drivercreate.NewDriver(ctx, workspaceConfig)
if err != nil {
return nil, err
}
diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go
index e8d868035..0b3c8c850 100644
--- a/pkg/devcontainer/setup.go
+++ b/pkg/devcontainer/setup.go
@@ -344,7 +344,7 @@ func (r *runner) buildSetupCommand(compressed, workspaceConfigCompressed string)
}
func (r *runner) addSetupFlags(args *[]string) {
- _, isDockerDriver := r.driver.(driver.DockerDriver)
+ _, isDockerDriver := r.driver.(driver.ImageDriver)
r.addChownFlag(args, isDockerDriver)
r.addDriverFlags(args, isDockerDriver)
diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go
index e8dfe5ef5..84a449f05 100644
--- a/pkg/devcontainer/single.go
+++ b/pkg/devcontainer/single.go
@@ -250,7 +250,8 @@ func (r *runner) reprovisionIfNeeded(
return containerDetails, nil
}
- if err := r.driver.RunDevContainer(ctx, r.id, nil); err != nil {
+ // ReprovisioningDriver embeds RunOptionsDriver, so d can re-run the container.
+ if err := d.RunDevContainer(ctx, r.id, nil); err != nil {
return nil, fmt.Errorf("runner driver run dev container: %w", err)
}
return r.findRunningContainerOrFail(ctx, "reprovision")
@@ -308,11 +309,11 @@ func (r *runner) resolveNewContainer(
}
func (r *runner) lingerWarning(ctx context.Context) string {
- dockerDriver, ok := r.driver.(driver.DockerDriver)
+ helperProvider, ok := r.driver.(driver.DockerHelperProvider)
if !ok {
return ""
}
- helper, err := dockerDriver.DockerHelper()
+ helper, err := helperProvider.DockerHelper()
if err != nil {
return ""
}
@@ -382,7 +383,7 @@ func (r *runner) newContainerHostWarnings(p *resolveParams) ([]string, error) {
// deleteForRecreate removes the existing container before recreating it.
// Docker containers are fully deleted; other drivers stop the container.
func (r *runner) deleteForRecreate(ctx context.Context) error {
- if _, ok := r.driver.(driver.DockerDriver); ok {
+ if _, ok := r.driver.(driver.ImageDriver); ok {
if err := r.Delete(ctx, DeleteOptions{}); err != nil {
return fmt.Errorf("delete devcontainer: %w", err)
}
@@ -546,10 +547,9 @@ func (r *runner) runContainer(
runOptions.Env = r.addExtraEnvVars(runOptions.Env)
- // check if docker
- dockerDriver, ok := r.driver.(driver.DockerDriver)
- if ok {
- return dockerDriver.RunDockerDevContainer(ctx, &driver.RunDockerDevContainerParams{
+ // Image drivers (Docker, Apple) build and run a local OCI image.
+ if imageDriver, ok := r.driver.(driver.ImageDriver); ok {
+ return imageDriver.RunImageDevContainer(ctx, &driver.RunImageDevContainerParams{
WorkspaceID: r.id,
Options: runOptions,
ParsedConfig: withResolvedUser(p.parsedConfig.Config, mergedConfig),
@@ -560,8 +560,12 @@ func (r *runner) runContainer(
})
}
- // build run options for regular driver
- return r.driver.RunDevContainer(ctx, r.id, runOptions)
+ // Other drivers (Kubernetes, custom) run the devcontainer from RunOptions.
+ if runDriver, ok := r.driver.(driver.RunOptionsDriver); ok {
+ return runDriver.RunDevContainer(ctx, r.id, runOptions)
+ }
+
+ return fmt.Errorf("driver does not support running a devcontainer")
}
// withResolvedUser returns a copy of parsedConfig carrying the effective user
diff --git a/pkg/driver/apple/build.go b/pkg/driver/apple/build.go
new file mode 100644
index 000000000..417ae6c83
--- /dev/null
+++ b/pkg/driver/apple/build.go
@@ -0,0 +1,162 @@
+package apple
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "sort"
+ "strings"
+
+ "github.com/devsy-org/devsy/pkg/apple"
+ "github.com/devsy-org/devsy/pkg/devcontainer/build"
+ "github.com/devsy-org/devsy/pkg/devcontainer/config"
+ "github.com/devsy-org/devsy/pkg/driver"
+ "github.com/devsy-org/devsy/pkg/log"
+)
+
+func (d *appleDriver) BuildDevContainer(
+ ctx context.Context,
+ req driver.BuildRequest,
+) (*config.BuildInfo, error) {
+ imageName := build.GetImageName(req.LocalWorkspaceFolder, req.PrebuildHash)
+ if req.Options.ImageName != "" {
+ imageName = req.Options.ImageName
+ }
+
+ if info, found := d.resolveExistingImage(ctx, imageName, req); found {
+ return info, nil
+ }
+
+ if req.Options.NoBuild {
+ return nil, fmt.Errorf("cannot build in no-build mode when the image does not exist")
+ }
+
+ buildOptions, err := build.NewOptions(build.NewOptionsParams{
+ DockerfilePath: req.DockerfilePath,
+ DockerfileContent: req.DockerfileContent,
+ ParsedConfig: req.ParsedConfig,
+ ExtendedBuildInfo: req.ExtendedBuildInfo,
+ ImageName: imageName,
+ Options: req.Options,
+ PrebuildHash: req.PrebuildHash,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if err := d.Apple.EnsureBuilderRunning(ctx); err != nil {
+ return nil, err
+ }
+
+ if err := d.executeBuild(ctx, buildOptions, req.Options.Platform); err != nil {
+ return nil, err
+ }
+
+ imageDetails, err := d.Apple.InspectImage(ctx, imageName, false)
+ if err != nil {
+ return nil, fmt.Errorf("get image details: %w", err)
+ }
+
+ return &config.BuildInfo{
+ ImageDetails: imageDetails,
+ ImageMetadata: req.ExtendedBuildInfo.MetadataConfig,
+ ImageName: imageName,
+ PrebuildHash: req.PrebuildHash,
+ RegistryCache: req.Options.RegistryCache,
+ Tags: req.Options.Tag,
+ }, nil
+}
+
+func (d *appleDriver) resolveExistingImage(
+ ctx context.Context,
+ imageName string,
+ req driver.BuildRequest,
+) (*config.BuildInfo, bool) {
+ if req.Options.Repository != "" || req.Options.ForceBuild {
+ return nil, false
+ }
+
+ imageDetails, err := d.Apple.InspectImage(ctx, imageName, false)
+ if err != nil || imageDetails == nil {
+ return nil, false
+ }
+
+ log.Infof("found existing local image %s", imageName)
+ return &config.BuildInfo{
+ ImageDetails: imageDetails,
+ ImageMetadata: req.ExtendedBuildInfo.MetadataConfig,
+ ImageName: imageName,
+ PrebuildHash: req.PrebuildHash,
+ RegistryCache: req.Options.RegistryCache,
+ Tags: req.Options.Tag,
+ }, true
+}
+
+func (d *appleDriver) executeBuild(
+ ctx context.Context,
+ options *build.BuildOptions,
+ platform string,
+) error {
+ args := buildArgs(options, platform)
+ log.Infof("building image with: container %s", redactArgs(args))
+
+ writer := log.Writer(log.LevelInfo)
+ defer func() { _ = writer.Close() }()
+ stderrBuf := &bytes.Buffer{}
+
+ streams := apple.Streams{Stdout: writer, Stderr: io.MultiWriter(writer, stderrBuf)}
+ if err := d.Apple.Run(ctx, args, streams); err != nil {
+ if stderrBuf.Len() > 0 {
+ return fmt.Errorf(
+ "failed to build image: %w: %s",
+ err, strings.TrimSpace(stderrBuf.String()),
+ )
+ }
+ return fmt.Errorf("failed to build image: %w", err)
+ }
+ return nil
+}
+
+// buildArgs assembles `container build` args; Apple's BuildKit builder accepts a
+// Docker-like flag set but not buildx/registry-cache flags.
+func buildArgs(options *build.BuildOptions, platform string) []string {
+ args := []string{"build", "-f", options.Dockerfile}
+
+ if options.NoCache {
+ args = append(args, "--no-cache")
+ }
+ for _, img := range options.Images {
+ args = append(args, "-t", img)
+ }
+
+ buildArgKeys := sortedKeys(options.BuildArgs)
+ for _, k := range buildArgKeys {
+ args = append(args, "--build-arg", k+"="+options.BuildArgs[k])
+ }
+
+ labelKeys := sortedKeys(options.Labels)
+ for _, k := range labelKeys {
+ args = append(args, "--label", k+"="+options.Labels[k])
+ }
+
+ if options.Target != "" {
+ args = append(args, "--target", options.Target)
+ }
+ if platform != "" {
+ args = append(args, "--platform", platform)
+ }
+
+ args = append(args, options.CliOpts...)
+ args = append(args, options.Context)
+ return args
+}
+
+func sortedKeys(m map[string]string) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
diff --git a/pkg/driver/apple/build_test.go b/pkg/driver/apple/build_test.go
new file mode 100644
index 000000000..5ea88bf9f
--- /dev/null
+++ b/pkg/driver/apple/build_test.go
@@ -0,0 +1,48 @@
+package apple
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/devsy-org/devsy/pkg/devcontainer/build"
+)
+
+func TestBuildArgs(t *testing.T) {
+ opts := &build.BuildOptions{
+ Dockerfile: "/tmp/Dockerfile",
+ Context: "/tmp/ctx",
+ Images: []string{"img:a", "img:b"},
+ BuildArgs: map[string]string{"B": "2", "A": "1"},
+ Labels: map[string]string{"z": "9", "a": "0"},
+ Target: "dev",
+ NoCache: true,
+ CliOpts: []string{"--extra"},
+ }
+
+ args := buildArgs(opts, "linux/arm64")
+ joined := strings.Join(args, " ")
+
+ for _, want := range []string{
+ "build -f /tmp/Dockerfile",
+ "--no-cache",
+ "-t img:a", "-t img:b",
+ "--build-arg A=1", "--build-arg B=2",
+ "--label a=0", "--label z=9",
+ "--target dev",
+ "--platform linux/arm64",
+ "--extra",
+ } {
+ if !strings.Contains(joined, want) {
+ t.Errorf("buildArgs missing %q\n got: %s", want, joined)
+ }
+ }
+
+ // build-arg keys must be sorted deterministically (A before B).
+ if strings.Index(joined, "A=1") > strings.Index(joined, "B=2") {
+ t.Errorf("build-arg keys not sorted: %s", joined)
+ }
+ // Context must be the final argument.
+ if args[len(args)-1] != "/tmp/ctx" {
+ t.Errorf("context must be last, got %q", args[len(args)-1])
+ }
+}
diff --git a/pkg/driver/apple/capability_test.go b/pkg/driver/apple/capability_test.go
new file mode 100644
index 000000000..f2919469a
--- /dev/null
+++ b/pkg/driver/apple/capability_test.go
@@ -0,0 +1,30 @@
+package apple
+
+import (
+ "testing"
+
+ "github.com/devsy-org/devsy/pkg/driver"
+)
+
+// TestDriverCapabilities locks in the capability design: the Apple driver
+// supports image operations (ImageDriver) but deliberately does NOT advertise
+// compose or the docker-helper capability, so callers gate on those via type
+// assertion rather than a runtime error.
+func TestDriverCapabilities(t *testing.T) {
+ var d driver.Driver = &appleDriver{}
+
+ if _, ok := d.(driver.ImageDriver); !ok {
+ t.Error("apple driver must implement ImageDriver (image operations)")
+ }
+ if _, ok := d.(driver.ComposeDriver); ok {
+ t.Error("apple driver must NOT implement ComposeDriver: container has no compose engine")
+ }
+ if _, ok := d.(driver.DockerHelperProvider); ok {
+ t.Error("apple driver must NOT implement DockerHelperProvider: no docker helper")
+ }
+ // Image drivers run via RunImageDevContainer, not the RunOptionsDriver path
+ // used by orchestrated drivers (Kubernetes/custom).
+ if _, ok := d.(driver.RunOptionsDriver); ok {
+ t.Error("apple driver must NOT implement RunOptionsDriver: it is an ImageDriver")
+ }
+}
diff --git a/pkg/driver/apple/client.go b/pkg/driver/apple/client.go
new file mode 100644
index 000000000..05f6be599
--- /dev/null
+++ b/pkg/driver/apple/client.go
@@ -0,0 +1,35 @@
+package apple
+
+import (
+ "context"
+ "io"
+
+ "github.com/devsy-org/devsy/pkg/apple"
+ "github.com/devsy-org/devsy/pkg/devcontainer/config"
+)
+
+// appleClient is the subset of *apple.AppleHelper the driver depends on, so its
+// orchestration can be unit-tested with a mock instead of the real CLI.
+type appleClient interface {
+ EnsureBuilderRunning(ctx context.Context) error
+ FindDevContainer(ctx context.Context, labels []string) (*config.ContainerDetails, error)
+ FindContainerByID(ctx context.Context, ids []string) (*config.ContainerDetails, error)
+ InspectImage(
+ ctx context.Context,
+ imageName string,
+ tryRemote bool,
+ ) (*config.ImageDetails, error)
+ GetImageTag(ctx context.Context, imageID string) (string, error)
+ Pull(ctx context.Context, opts apple.PullOptions) error
+ Push(ctx context.Context, image string, stdout, stderr io.Writer) error
+ Tag(ctx context.Context, image, tag string) error
+ Run(ctx context.Context, args []string, s apple.Streams) error
+ RunWithDir(ctx context.Context, dir string, args []string, s apple.Streams) error
+ StartContainer(ctx context.Context, id string) error
+ WaitContainerRunning(ctx context.Context, id string) error
+ Stop(ctx context.Context, id string) error
+ Remove(ctx context.Context, id string) error
+ GetContainerLogs(ctx context.Context, id string, stdout, stderr io.Writer) error
+}
+
+var _ appleClient = (*apple.AppleHelper)(nil)
diff --git a/pkg/driver/apple/driver.go b/pkg/driver/apple/driver.go
new file mode 100644
index 000000000..0e7494806
--- /dev/null
+++ b/pkg/driver/apple/driver.go
@@ -0,0 +1,120 @@
+// Package apple implements a Devsy driver for Apple's `container` CLI, which runs
+// Linux containers as lightweight VMs on Apple silicon (macOS 26+). It implements
+// driver.ImageDriver; compose and docker-helper capabilities are intentionally
+// not implemented (callers detect their absence by type assertion).
+package apple
+
+import (
+ "context"
+ "fmt"
+ "runtime"
+
+ "github.com/devsy-org/devsy/pkg/apple"
+ "github.com/devsy-org/devsy/pkg/devcontainer/config"
+ "github.com/devsy-org/devsy/pkg/driver"
+ "github.com/devsy-org/devsy/pkg/log"
+ "github.com/devsy-org/devsy/pkg/provider"
+)
+
+const (
+ appleExec = "exec"
+ defaultCommand = "container"
+ statusRunning = "running"
+)
+
+type appleDriver struct {
+ Apple appleClient
+ IDLabels []string
+ Rosetta bool
+ command string // binary path, retained for logging
+ containerID string // set when the workspace source is an existing container
+}
+
+var _ driver.ImageDriver = (*appleDriver)(nil)
+
+// NewAppleDriver verifies the host is supported and the container system service
+// is running (ctx bounds the potentially-slow `container system start`).
+func NewAppleDriver(
+ ctx context.Context,
+ workspaceInfo *provider.AgentWorkspaceInfo,
+) (driver.ImageDriver, error) {
+ if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" {
+ return nil, fmt.Errorf(
+ "the apple provider requires an Apple silicon Mac (found %s/%s)",
+ runtime.GOOS, runtime.GOARCH,
+ )
+ }
+
+ command := defaultCommand
+ if workspaceInfo.Agent.Apple.Path != "" {
+ command = workspaceInfo.Agent.Apple.Path
+ }
+
+ helper := &apple.AppleHelper{
+ Command: command,
+ Environment: makeEnvironment(workspaceInfo.Agent.Apple.Env),
+ }
+
+ if err := helper.EnsureSystemRunning(ctx); err != nil {
+ return nil, err
+ }
+
+ rosetta, err := workspaceInfo.Agent.Apple.Rosetta.Bool()
+ if err != nil {
+ log.Warnf(
+ "invalid rosetta value %q, defaulting to false: %v",
+ workspaceInfo.Agent.Apple.Rosetta, err,
+ )
+ rosetta = false
+ }
+
+ log.Debugf("using apple container command: command=%s, rosetta=%t", command, rosetta)
+ return &appleDriver{
+ Apple: helper,
+ IDLabels: workspaceInfo.CLIOptions.IDLabels,
+ Rosetta: rosetta,
+ command: command,
+ containerID: workspaceInfo.Workspace.Source.Container,
+ }, nil
+}
+
+func makeEnvironment(env map[string]string) []string {
+ if len(env) == 0 {
+ return nil
+ }
+ return config.ObjectToList(env)
+}
+
+func (d *appleDriver) TargetArchitecture(ctx context.Context, workspaceID string) (string, error) {
+ return runtime.GOARCH, nil
+}
+
+func (d *appleDriver) FindDevContainer(
+ ctx context.Context,
+ workspaceID string,
+) (*config.ContainerDetails, error) {
+ var containerDetails *config.ContainerDetails
+ var err error
+ if d.containerID != "" {
+ containerDetails, err = d.Apple.FindContainerByID(ctx, []string{d.containerID})
+ } else {
+ containerDetails, err = d.Apple.FindDevContainer(
+ ctx,
+ config.GetIDLabels(workspaceID, d.IDLabels),
+ )
+ }
+ if err != nil || containerDetails == nil {
+ return nil, err
+ }
+
+ if containerDetails.Config.User != "" {
+ if containerDetails.Config.Labels == nil {
+ containerDetails.Config.Labels = map[string]string{}
+ }
+ if containerDetails.Config.Labels[config.UserLabel] == "" {
+ containerDetails.Config.Labels[config.UserLabel] = containerDetails.Config.User
+ }
+ }
+
+ return containerDetails, nil
+}
diff --git a/pkg/driver/apple/lifecycle.go b/pkg/driver/apple/lifecycle.go
new file mode 100644
index 000000000..216f764da
--- /dev/null
+++ b/pkg/driver/apple/lifecycle.go
@@ -0,0 +1,193 @@
+package apple
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/devsy-org/devsy/pkg/apple"
+ "github.com/devsy-org/devsy/pkg/devcontainer/config"
+ "github.com/devsy-org/devsy/pkg/driver"
+ "github.com/devsy-org/devsy/pkg/log"
+)
+
+func (d *appleDriver) CommandDevContainer(
+ ctx context.Context,
+ params *driver.CommandParams,
+) error {
+ container, err := d.FindDevContainer(ctx, params.WorkspaceID)
+ if err != nil {
+ return err
+ } else if container == nil {
+ return fmt.Errorf("container not found")
+ }
+
+ if err := d.ensureContainerRunning(ctx, container); err != nil {
+ return err
+ }
+
+ args := []string{appleExec}
+ if params.Stdin != nil {
+ args = append(args, "-i")
+ }
+ if params.User != "" {
+ args = append(args, "-u", params.User)
+ }
+ args = append(args, container.ID, "sh", "-c", params.Command)
+ return d.Apple.Run(ctx, args, apple.Streams{
+ Stdin: params.Stdin, Stdout: params.Stdout, Stderr: params.Stderr,
+ })
+}
+
+func (d *appleDriver) ensureContainerRunning(
+ ctx context.Context,
+ container *config.ContainerDetails,
+) error {
+ if strings.ToLower(container.State.Status) == statusRunning {
+ return nil
+ }
+ if err := d.Apple.StartContainer(ctx, container.ID); err != nil {
+ return err
+ }
+ return d.Apple.WaitContainerRunning(ctx, container.ID)
+}
+
+func (d *appleDriver) StartDevContainer(ctx context.Context, workspaceID string) error {
+ container, err := d.FindDevContainer(ctx, workspaceID)
+ if err != nil {
+ return err
+ } else if container == nil {
+ return fmt.Errorf("container not found")
+ }
+ return d.Apple.StartContainer(ctx, container.ID)
+}
+
+func (d *appleDriver) StopDevContainer(ctx context.Context, workspaceID string) error {
+ container, err := d.FindDevContainer(ctx, workspaceID)
+ if err != nil {
+ return err
+ } else if container == nil {
+ return fmt.Errorf("container not found")
+ }
+ return d.Apple.Stop(ctx, container.ID)
+}
+
+func (d *appleDriver) DeleteDevContainer(ctx context.Context, workspaceID string) error {
+ container, err := d.FindDevContainer(ctx, workspaceID)
+ if err != nil {
+ return err
+ } else if container == nil {
+ return nil
+ }
+ // Apple's `delete` requires a stopped container. `container stop` is synchronous;
+ // a stop failure is logged but delete is still attempted (it fails loudly if the
+ // container is genuinely still running).
+ if strings.ToLower(container.State.Status) == statusRunning {
+ if err := d.Apple.Stop(ctx, container.ID); err != nil {
+ log.Warnf("stop before delete failed for %s: %v", container.ID, err)
+ }
+ }
+ return d.Apple.Remove(ctx, container.ID)
+}
+
+func (d *appleDriver) GetDevContainerLogs(
+ ctx context.Context,
+ workspaceID string,
+ stdout io.Writer,
+ stderr io.Writer,
+) error {
+ container, err := d.FindDevContainer(ctx, workspaceID)
+ if err != nil {
+ return err
+ } else if container == nil {
+ return fmt.Errorf("container not found")
+ }
+ return d.Apple.GetContainerLogs(ctx, container.ID, stdout, stderr)
+}
+
+func (d *appleDriver) InspectImage(
+ ctx context.Context,
+ imageName string,
+) (*config.ImageDetails, error) {
+ return d.Apple.InspectImage(ctx, imageName, true)
+}
+
+func (d *appleDriver) GetImageTag(ctx context.Context, imageID string) (string, error) {
+ return d.Apple.GetImageTag(ctx, imageID)
+}
+
+func (d *appleDriver) PushDevContainer(ctx context.Context, image string) error {
+ writer := log.Writer(log.LevelInfo)
+ defer func() { _ = writer.Close() }()
+ if err := d.Apple.Push(ctx, image, writer, writer); err != nil {
+ return fmt.Errorf("push image: %w", err)
+ }
+ return nil
+}
+
+func (d *appleDriver) TagDevContainer(ctx context.Context, image, tag string) error {
+ if err := d.Apple.Tag(ctx, image, tag); err != nil {
+ return fmt.Errorf("tag image: %w", err)
+ }
+ return nil
+}
+
+func (d *appleDriver) RunImageDevContainer(
+ ctx context.Context,
+ params *driver.RunImageDevContainerParams,
+) error {
+ if err := d.EnsureImage(ctx, params.Options); err != nil {
+ return err
+ }
+
+ args := d.buildRunArgs(params)
+
+ writer := log.Writer(log.LevelInfo)
+ defer func() { _ = writer.Close() }()
+
+ log.Infof(
+ "running apple container command: command=%s, args=%s",
+ d.command, redactArgs(args),
+ )
+ err := d.Apple.RunWithDir(ctx, params.LocalWorkspaceFolder, args,
+ apple.Streams{Stdout: writer, Stderr: writer})
+ if err != nil {
+ return fmt.Errorf("failed to start dev container: %w", err)
+ }
+
+ return d.UpdateContainerUserUID(ctx, params.WorkspaceID, params.ParsedConfig, writer)
+}
+
+func (d *appleDriver) EnsureImage(ctx context.Context, options *driver.RunOptions) error {
+ log.Infof("inspecting image: image=%s", options.Image)
+ if details, err := d.Apple.InspectImage(
+ ctx,
+ options.Image,
+ false,
+ ); err == nil &&
+ details != nil {
+ return nil
+ }
+
+ log.Infof("image not found, pulling image: image=%s", options.Image)
+ writer := log.Writer(log.LevelDebug)
+ defer func() { _ = writer.Close() }()
+ return d.Apple.Pull(ctx, apple.PullOptions{
+ Image: options.Image,
+ Platform: options.Platform,
+ Stdout: writer,
+ Stderr: writer,
+ })
+}
+
+// UpdateContainerUserUID is a no-op: each Apple container is its own VM and
+// virtio-fs handles host-mount ownership, so UID/GID remapping does not apply.
+func (d *appleDriver) UpdateContainerUserUID(
+ ctx context.Context,
+ workspaceID string,
+ parsedConfig *config.DevContainerConfig,
+ writer io.Writer,
+) error {
+ return nil
+}
diff --git a/pkg/driver/apple/lifecycle_test.go b/pkg/driver/apple/lifecycle_test.go
new file mode 100644
index 000000000..d7ce67880
--- /dev/null
+++ b/pkg/driver/apple/lifecycle_test.go
@@ -0,0 +1,257 @@
+package apple
+
+import (
+ "context"
+ "errors"
+ "io"
+ "slices"
+ "testing"
+
+ "github.com/devsy-org/devsy/pkg/apple"
+ "github.com/devsy-org/devsy/pkg/devcontainer/config"
+ "github.com/devsy-org/devsy/pkg/driver"
+)
+
+// Test image references shared across driver tests.
+const (
+ testImage = "img"
+ testImageRef = "alpine:latest"
+)
+
+// mockClient is a test double for appleClient. It records every mutating call
+// into a single ordered log (calls) so tests can assert relative ordering, not
+// just that calls happened.
+type mockClient struct {
+ found *config.ContainerDetails
+ foundErr error
+ inspectErr error
+ pulled bool
+ waitErr error
+ startErr error
+ stopErr error
+ calls []string // ordered event log, e.g. "stop:c1", "remove:c1"
+ ranArgs [][]string // exec/run argument lists, in call order
+ runWithDir string // dir passed to the last RunWithDir call
+}
+
+func (m *mockClient) EnsureBuilderRunning(context.Context) error { return nil }
+func (m *mockClient) FindDevContainer(context.Context, []string) (*config.ContainerDetails, error) {
+ return m.found, m.foundErr
+}
+
+func (m *mockClient) FindContainerByID(
+ context.Context, []string,
+) (*config.ContainerDetails, error) {
+ return m.found, m.foundErr
+}
+
+func (m *mockClient) InspectImage(context.Context, string, bool) (*config.ImageDetails, error) {
+ if m.inspectErr != nil {
+ return nil, m.inspectErr
+ }
+ return &config.ImageDetails{ID: testImage}, nil
+}
+func (m *mockClient) GetImageTag(context.Context, string) (string, error) { return "", nil }
+func (m *mockClient) Pull(context.Context, apple.PullOptions) error {
+ m.pulled = true
+ m.calls = append(m.calls, "pull")
+ return nil
+}
+func (m *mockClient) Push(context.Context, string, io.Writer, io.Writer) error { return nil }
+func (m *mockClient) Tag(context.Context, string, string) error { return nil }
+func (m *mockClient) Run(_ context.Context, args []string, _ apple.Streams) error {
+ m.calls = append(m.calls, "exec")
+ m.ranArgs = append(m.ranArgs, args)
+ return nil
+}
+
+func (m *mockClient) RunWithDir(
+ _ context.Context,
+ dir string,
+ args []string,
+ _ apple.Streams,
+) error {
+ m.calls = append(m.calls, "run")
+ m.runWithDir = dir
+ m.ranArgs = append(m.ranArgs, args)
+ return nil
+}
+
+func (m *mockClient) StartContainer(_ context.Context, id string) error {
+ m.calls = append(m.calls, "start:"+id)
+ return m.startErr
+}
+func (m *mockClient) WaitContainerRunning(context.Context, string) error { return m.waitErr }
+func (m *mockClient) Stop(_ context.Context, id string) error {
+ m.calls = append(m.calls, "stop:"+id)
+ return m.stopErr
+}
+
+func (m *mockClient) Remove(_ context.Context, id string) error {
+ m.calls = append(m.calls, "remove:"+id)
+ return nil
+}
+
+func (m *mockClient) GetContainerLogs(context.Context, string, io.Writer, io.Writer) error {
+ return nil
+}
+
+func running(id string) *config.ContainerDetails {
+ return &config.ContainerDetails{
+ ID: id,
+ State: config.ContainerDetailsState{Status: statusRunning},
+ }
+}
+
+func TestFindDevContainer_InjectsUserLabel(t *testing.T) {
+ c := &config.ContainerDetails{ID: "x", Config: config.ContainerDetailsConfig{User: "1000"}}
+ d := &appleDriver{Apple: &mockClient{found: c}}
+
+ got, err := d.FindDevContainer(context.Background(), "ws")
+ if err != nil {
+ t.Fatalf("FindDevContainer: %v", err)
+ }
+ if got.Config.Labels[config.UserLabel] != "1000" {
+ t.Errorf("UserLabel = %q, want 1000", got.Config.Labels[config.UserLabel])
+ }
+}
+
+func TestFindDevContainer_UsesContainerIDWhenPinned(t *testing.T) {
+ m := &mockClient{found: running("pinned")}
+ d := &appleDriver{Apple: m, containerID: "pinned"}
+ got, err := d.FindDevContainer(context.Background(), "ws")
+ if err != nil || got == nil || got.ID != "pinned" {
+ t.Fatalf("expected pinned container, got %+v err=%v", got, err)
+ }
+}
+
+func TestDeleteDevContainer_StopsThenRemovesInOrder(t *testing.T) {
+ m := &mockClient{found: running("c1")}
+ d := &appleDriver{Apple: m}
+ if err := d.DeleteDevContainer(context.Background(), "ws"); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ // Order matters: Apple's delete requires the container be stopped first.
+ if want := []string{"stop:c1", "remove:c1"}; !slices.Equal(m.calls, want) {
+ t.Errorf("call order = %v, want %v", m.calls, want)
+ }
+}
+
+func TestDeleteDevContainer_StopFailureStillRemoves(t *testing.T) {
+ m := &mockClient{found: running("c1"), stopErr: errors.New("stop boom")}
+ d := &appleDriver{Apple: m}
+ if err := d.DeleteDevContainer(context.Background(), "ws"); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ // A stop failure is logged but delete is still attempted.
+ if want := []string{"stop:c1", "remove:c1"}; !slices.Equal(m.calls, want) {
+ t.Errorf("call order = %v, want %v", m.calls, want)
+ }
+}
+
+func TestDeleteDevContainer_StoppedContainerSkipsStop(t *testing.T) {
+ stopped := &config.ContainerDetails{
+ ID: "c3",
+ State: config.ContainerDetailsState{Status: "exited"},
+ }
+ m := &mockClient{found: stopped}
+ d := &appleDriver{Apple: m}
+ if err := d.DeleteDevContainer(context.Background(), "ws"); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if want := []string{"remove:c3"}; !slices.Equal(m.calls, want) {
+ t.Errorf("stopped container should skip stop; calls = %v, want %v", m.calls, want)
+ }
+}
+
+func TestDeleteDevContainer_NoContainerIsNoop(t *testing.T) {
+ m := &mockClient{found: nil}
+ d := &appleDriver{Apple: m}
+ if err := d.DeleteDevContainer(context.Background(), "ws"); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if len(m.calls) != 0 {
+ t.Errorf("no container: expected no calls, got %v", m.calls)
+ }
+}
+
+func TestRunImageDevContainer_PullsRunsThenUID(t *testing.T) {
+ m := &mockClient{inspectErr: errors.New("absent")} // force a pull
+ d := &appleDriver{Apple: m}
+ err := d.RunImageDevContainer(context.Background(), &driver.RunImageDevContainerParams{
+ WorkspaceID: "ws",
+ LocalWorkspaceFolder: "/local/ws",
+ ParsedConfig: &config.DevContainerConfig{},
+ Options: &driver.RunOptions{Image: testImageRef},
+ })
+ if err != nil {
+ t.Fatalf("RunImageDevContainer: %v", err)
+ }
+ // Image must be ensured (pulled) before the container is run.
+ if want := []string{"pull", "run"}; !slices.Equal(m.calls, want) {
+ t.Errorf("call order = %v, want %v (ensure image before run)", m.calls, want)
+ }
+ if m.runWithDir != "/local/ws" {
+ t.Errorf("run cwd = %q, want /local/ws", m.runWithDir)
+ }
+}
+
+func TestEnsureImage_PullsWhenMissing(t *testing.T) {
+ m := &mockClient{inspectErr: errors.New("not found")}
+ d := &appleDriver{Apple: m}
+ if err := d.EnsureImage(
+ context.Background(),
+ &driver.RunOptions{Image: testImage},
+ ); err != nil {
+ t.Fatalf("EnsureImage: %v", err)
+ }
+ if !m.pulled {
+ t.Error("expected pull when image is missing locally")
+ }
+}
+
+func TestEnsureImage_SkipsPullWhenPresent(t *testing.T) {
+ m := &mockClient{} // InspectImage succeeds
+ d := &appleDriver{Apple: m}
+ if err := d.EnsureImage(
+ context.Background(),
+ &driver.RunOptions{Image: testImage},
+ ); err != nil {
+ t.Fatalf("EnsureImage: %v", err)
+ }
+ if m.pulled {
+ t.Error("must not pull when image already present")
+ }
+}
+
+func TestCommandDevContainer_StartsStoppedContainer(t *testing.T) {
+ stopped := &config.ContainerDetails{
+ ID: "c2",
+ State: config.ContainerDetailsState{Status: "exited"},
+ }
+ m := &mockClient{found: stopped}
+ d := &appleDriver{Apple: m}
+
+ err := d.CommandDevContainer(context.Background(), &driver.CommandParams{
+ WorkspaceID: "ws", User: "root", Command: "echo hi",
+ })
+ if err != nil {
+ t.Fatalf("CommandDevContainer: %v", err)
+ }
+ // A stopped container must be started before the exec runs.
+ if want := []string{"start:c2", "exec"}; !slices.Equal(m.calls, want) {
+ t.Errorf("call order = %v, want %v", m.calls, want)
+ }
+ last := m.ranArgs[len(m.ranArgs)-1]
+ if last[0] != appleExec {
+ t.Errorf("expected exec, got %v", last)
+ }
+}
+
+func TestCommandDevContainer_NotFound(t *testing.T) {
+ d := &appleDriver{Apple: &mockClient{found: nil}}
+ err := d.CommandDevContainer(context.Background(), &driver.CommandParams{WorkspaceID: "ws"})
+ if err == nil {
+ t.Error("expected error when container not found")
+ }
+}
diff --git a/pkg/driver/apple/runargs.go b/pkg/driver/apple/runargs.go
new file mode 100644
index 000000000..8213e1fe2
--- /dev/null
+++ b/pkg/driver/apple/runargs.go
@@ -0,0 +1,176 @@
+package apple
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/devsy-org/devsy/pkg/devcontainer/config"
+ "github.com/devsy-org/devsy/pkg/driver"
+ "github.com/devsy-org/devsy/pkg/log"
+)
+
+// buildRunArgs assembles `container run` args, emitting only flags Apple supports
+// (Docker/Podman-only flags like --security-opt/--gpus/--userns are omitted).
+func (d *appleDriver) buildRunArgs(params *driver.RunImageDevContainerParams) []string {
+ args := []string{"run", "-d"}
+
+ if d.Rosetta {
+ args = append(args, "--rosetta")
+ }
+
+ warnUnsupportedGPU(params.ParsedConfig)
+ args = d.addPorts(args, params.ParsedConfig)
+ args = d.addWorkspaceMount(args, params.Options)
+ args = addUser(args, params.Options)
+ args = addEnv(args, params.Options)
+ args = addInit(args, params.Options)
+ args = addCapabilities(args, params.Options)
+ args = addMounts(args, params.Options)
+ args = d.addLabels(args, params.WorkspaceID, params.Options)
+ args = append(args, params.ParsedConfig.RunArgs...)
+ args = addPlatform(args, params.ParsedConfig, params.Options)
+ args = addEntrypoint(args, params.Options)
+
+ args = append(args, params.Options.Image)
+ args = append(args, params.Options.Cmd...)
+ return args
+}
+
+func (d *appleDriver) addPorts(args []string, parsedConfig *config.DevContainerConfig) []string {
+ for _, appPort := range parsedConfig.AppPort {
+ if intPort, err := strconv.Atoi(appPort); err == nil {
+ args = append(args, "-p", fmt.Sprintf("%d:%d", intPort, intPort))
+ } else {
+ args = append(args, "-p", appPort)
+ }
+ }
+ return args
+}
+
+func (d *appleDriver) addWorkspaceMount(args []string, options *driver.RunOptions) []string {
+ if options.WorkspaceMount != nil {
+ args = append(args, "--mount", cleanMount(options.WorkspaceMount.String()))
+ }
+ return args
+}
+
+func addUser(args []string, options *driver.RunOptions) []string {
+ if options.User != "" {
+ args = append(args, "-u", options.User)
+ }
+ return args
+}
+
+func addEnv(args []string, options *driver.RunOptions) []string {
+ for k, v := range options.Env {
+ args = append(args, "-e", k+"="+v)
+ }
+ return args
+}
+
+func addInit(args []string, options *driver.RunOptions) []string {
+ if options.Init != nil && *options.Init {
+ args = append(args, "--init")
+ }
+ return args
+}
+
+func addCapabilities(args []string, options *driver.RunOptions) []string {
+ for _, capAdd := range options.CapAdd {
+ args = append(args, "--cap-add", capAdd)
+ }
+ return args
+}
+
+func addMounts(args []string, options *driver.RunOptions) []string {
+ for _, mount := range options.Mounts {
+ if mount.Type == "bind" && mount.Source != "" {
+ if _, err := os.Stat(mount.Source); os.IsNotExist(err) {
+ log.Warnf("bind mount source path does not exist, skipping: %s", mount.Source)
+ continue
+ }
+ }
+ args = append(args, "--mount", cleanMount(mount.String()))
+ }
+ return args
+}
+
+func (d *appleDriver) addLabels(
+ args []string,
+ workspaceID string,
+ options *driver.RunOptions,
+) []string {
+ labels := append(config.GetIDLabels(workspaceID, d.IDLabels), options.Labels...)
+ for _, label := range labels {
+ args = append(args, "-l", label)
+ }
+ return args
+}
+
+func addEntrypoint(args []string, options *driver.RunOptions) []string {
+ if options.Entrypoint != "" {
+ args = append(args, "--entrypoint", options.Entrypoint)
+ }
+ return args
+}
+
+// warnUnsupportedGPU warns when a GPU is required, since Apple's `container` has
+// no GPU passthrough (matching how the docker driver warns on a missing GPU).
+func warnUnsupportedGPU(parsedConfig *config.DevContainerConfig) {
+ if parsedConfig == nil {
+ return
+ }
+ if _, warnIfMissing := parsedConfig.HostRequirements.ShouldEnableGPU(false); warnIfMissing {
+ log.Warn(
+ "devcontainer requires a GPU, but the apple provider does not support GPU passthrough",
+ )
+ }
+}
+
+func addPlatform(
+ args []string,
+ parsedConfig *config.DevContainerConfig,
+ options *driver.RunOptions,
+) []string {
+ if options.Platform == "" {
+ return args
+ }
+ for _, a := range parsedConfig.RunArgs {
+ if a == "--platform" || strings.HasPrefix(a, "--platform=") {
+ return args // explicit config wins
+ }
+ }
+ return append(args, "--platform="+options.Platform)
+}
+
+// redactArgs masks the values of secret-bearing flags (-e, --build-arg) for
+// logging, so env vars and build args carrying tokens are not written in plaintext.
+func redactArgs(args []string) string {
+ redacted := make([]string, len(args))
+ copy(redacted, args)
+ for i := 0; i < len(redacted)-1; i++ {
+ if redacted[i] != "-e" && redacted[i] != "--build-arg" {
+ continue
+ }
+ if key, _, found := strings.Cut(redacted[i+1], "="); found {
+ redacted[i+1] = key + "=****"
+ }
+ }
+ return strings.Join(redacted, " ")
+}
+
+// cleanMount strips Docker Desktop-only mount options (consistency=, bind-create-src=)
+// that Apple's `container` rejects.
+func cleanMount(mount string) string {
+ parts := strings.Split(mount, ",")
+ kept := parts[:0]
+ for _, part := range parts {
+ if strings.HasPrefix(part, "consistency=") || strings.HasPrefix(part, "bind-create-src=") {
+ continue
+ }
+ kept = append(kept, part)
+ }
+ return strings.Join(kept, ",")
+}
diff --git a/pkg/driver/apple/runargs_test.go b/pkg/driver/apple/runargs_test.go
new file mode 100644
index 000000000..d02837b11
--- /dev/null
+++ b/pkg/driver/apple/runargs_test.go
@@ -0,0 +1,107 @@
+package apple
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/devsy-org/devsy/pkg/devcontainer/config"
+ "github.com/devsy-org/devsy/pkg/driver"
+ "github.com/devsy-org/devsy/pkg/types"
+)
+
+func TestBuildRunArgs(t *testing.T) {
+ d := &appleDriver{IDLabels: []string{"dev.containers.id"}}
+ init := true
+ params := &driver.RunImageDevContainerParams{
+ WorkspaceID: "ws",
+ ParsedConfig: &config.DevContainerConfig{
+ NonComposeBase: config.NonComposeBase{
+ AppPort: types.StrIntArray{"8080"},
+ },
+ },
+ Options: &driver.RunOptions{
+ Image: testImageRef,
+ User: "vscode",
+ Env: map[string]string{"FOO": "bar"},
+ Init: &init,
+ CapAdd: []string{"SYS_PTRACE"},
+ Entrypoint: "/bin/sh",
+ Cmd: []string{"-c", "sleep infinity"},
+ },
+ }
+
+ args := d.buildRunArgs(params)
+ joined := strings.Join(args, " ")
+
+ for _, want := range []string{
+ "run -d",
+ "-p 8080:8080",
+ "-u vscode",
+ "-e FOO=bar",
+ "--init",
+ "--cap-add SYS_PTRACE",
+ "--entrypoint /bin/sh",
+ } {
+ if !strings.Contains(joined, want) {
+ t.Errorf("args missing %q\n got: %s", want, joined)
+ }
+ }
+
+ // Image must precede the command, and both must be last.
+ if args[len(args)-3] != testImageRef {
+ t.Errorf("expected image before cmd, got tail: %v", args[len(args)-3:])
+ }
+
+ // Apple does not support these Docker/Podman flags.
+ for _, forbidden := range []string{"--security-opt", "--gpus", "--userns", "--sig-proxy"} {
+ if strings.Contains(joined, forbidden) {
+ t.Errorf("args contains unsupported flag %q: %s", forbidden, joined)
+ }
+ }
+}
+
+func TestBuildRunArgsRosetta(t *testing.T) {
+ params := &driver.RunImageDevContainerParams{
+ ParsedConfig: &config.DevContainerConfig{},
+ Options: &driver.RunOptions{Image: testImageRef},
+ }
+
+ off := (&appleDriver{}).buildRunArgs(params)
+ if strings.Contains(strings.Join(off, " "), "--rosetta") {
+ t.Errorf("--rosetta must not be present when Rosetta is disabled: %v", off)
+ }
+
+ on := (&appleDriver{Rosetta: true}).buildRunArgs(params)
+ if !strings.Contains(strings.Join(on, " "), "--rosetta") {
+ t.Errorf("--rosetta must be present when Rosetta is enabled: %v", on)
+ }
+}
+
+func TestCleanMount(t *testing.T) {
+ in := "type=bind,source=/a,target=/b,consistency=cached,bind-create-src=true"
+ got := cleanMount(in)
+ if strings.Contains(got, "consistency=") || strings.Contains(got, "bind-create-src=") {
+ t.Errorf("cleanMount left docker-desktop options: %q", got)
+ }
+ for _, want := range []string{"type=bind", "source=/a", "target=/b"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("cleanMount dropped %q: %q", want, got)
+ }
+ }
+}
+
+func TestRedactArgs(t *testing.T) {
+ args := []string{
+ "-e", "TOKEN=supersecret", "--build-arg", "NPM_AUTH=abc123",
+ "-l", "keep=visible", "alpine",
+ }
+ got := redactArgs(args)
+ if strings.Contains(got, "supersecret") || strings.Contains(got, "abc123") {
+ t.Errorf("redactArgs leaked a secret: %s", got)
+ }
+ for _, want := range []string{"TOKEN=****", "NPM_AUTH=****", "keep=visible"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("redactArgs = %q, missing %q", got, want)
+ }
+ }
+}
diff --git a/pkg/driver/docker.go b/pkg/driver/docker.go
index 100a06cba..4705cb502 100644
--- a/pkg/driver/docker.go
+++ b/pkg/driver/docker.go
@@ -12,7 +12,7 @@ import (
"github.com/devsy-org/devsy/pkg/provider"
)
-type RunDockerDevContainerParams struct {
+type RunImageDevContainerParams struct {
WorkspaceID string
Options *RunOptions
ParsedConfig *config.DevContainerConfig
@@ -32,7 +32,10 @@ type BuildRequest struct {
Options provider.BuildOptions
}
-type DockerDriver interface {
+// ImageDriver is a capability interface for drivers that build and run a local
+// OCI image directly (e.g. Docker/Podman and Apple's `container`). It is named
+// for the behavior, not a concrete runtime, since multiple runtimes implement it.
+type ImageDriver interface {
Driver
// InspectImage inspects the given image name
@@ -41,8 +44,8 @@ type DockerDriver interface {
// GetImageTag returns latest tag for input image id
GetImageTag(ctx context.Context, imageName string) (string, error)
- // RunDockerDevContainer runs a docker devcontainer
- RunDockerDevContainer(ctx context.Context, params *RunDockerDevContainerParams) error
+ // RunImageDevContainer runs an image-based devcontainer
+ RunImageDevContainer(ctx context.Context, params *RunImageDevContainerParams) error
// BuildDevContainer builds a devcontainer
BuildDevContainer(ctx context.Context, req BuildRequest) (*config.BuildInfo, error)
@@ -60,10 +63,25 @@ type DockerDriver interface {
parsedConfig *config.DevContainerConfig,
writer io.Writer,
) error
+}
+
+// ComposeDriver is a capability interface implemented by drivers that can run
+// docker-compose based devcontainers. Not every container runtime has a compose
+// engine (e.g. Apple's `container`), so callers detect support via a type
+// assertion rather than forcing every driver to stub the method.
+type ComposeDriver interface {
+ Driver
// ComposeHelper returns the compose helper
ComposeHelper() (*compose.ComposeHelper, error)
+}
+
+// DockerHelperProvider is a capability interface implemented by drivers backed
+// by a Docker-compatible CLI that can expose the low-level *docker.DockerHelper.
+// Runtimes without one (e.g. the Apple driver) simply do not implement it.
+type DockerHelperProvider interface {
+ Driver
- // DockerHellper returns the docker helper
+ // DockerHelper returns the docker helper
DockerHelper() (*docker.DockerHelper, error)
}
diff --git a/pkg/driver/docker/docker.go b/pkg/driver/docker/docker.go
index 1e5c5b007..9fd4dc0f0 100644
--- a/pkg/driver/docker/docker.go
+++ b/pkg/driver/docker/docker.go
@@ -35,7 +35,7 @@ func makeEnvironment(env map[string]string) []string {
func NewDockerDriver(
workspaceInfo *provider.AgentWorkspaceInfo,
-) (driver.DockerDriver, error) {
+) (driver.ImageDriver, error) {
dockerCommand := "docker"
if workspaceInfo.Agent.Docker.Path != "" {
dockerCommand = workspaceInfo.Agent.Docker.Path
@@ -94,6 +94,14 @@ type dockerDriver struct {
UpdateRemoteUserUIDDefault string
}
+// The docker driver supports the full image, compose, and docker-helper
+// capabilities.
+var (
+ _ driver.ImageDriver = (*dockerDriver)(nil)
+ _ driver.ComposeDriver = (*dockerDriver)(nil)
+ _ driver.DockerHelperProvider = (*dockerDriver)(nil)
+)
+
func (d *dockerDriver) TargetArchitecture(ctx context.Context, workspaceId string) (string, error) {
return runtime.GOARCH, nil
}
diff --git a/pkg/driver/docker/lifecycle.go b/pkg/driver/docker/lifecycle.go
index 1dc846d49..4f45bc8f8 100644
--- a/pkg/driver/docker/lifecycle.go
+++ b/pkg/driver/docker/lifecycle.go
@@ -215,17 +215,9 @@ func (d *dockerDriver) GetDevContainerLogs(
return d.Docker.GetContainerLogs(ctx, container.ID, stdout, stderr)
}
-func (d *dockerDriver) RunDevContainer(
+func (d *dockerDriver) RunImageDevContainer(
ctx context.Context,
- workspaceId string,
- options *driver.RunOptions,
-) error {
- return fmt.Errorf("unsupported")
-}
-
-func (d *dockerDriver) RunDockerDevContainer(
- ctx context.Context,
- params *driver.RunDockerDevContainerParams,
+ params *driver.RunImageDevContainerParams,
) error {
if err := d.EnsureImage(ctx, params.Options); err != nil {
return err
diff --git a/pkg/driver/docker/runargs.go b/pkg/driver/docker/runargs.go
index a5b7f4d05..395a5839f 100644
--- a/pkg/driver/docker/runargs.go
+++ b/pkg/driver/docker/runargs.go
@@ -22,12 +22,12 @@ import (
type runArgsBuilder struct {
args []string
driver *dockerDriver
- params *driver.RunDockerDevContainerParams
+ params *driver.RunImageDevContainerParams
helper *docker.DockerHelper
}
func (d *dockerDriver) buildRunArgs(
- params *driver.RunDockerDevContainerParams,
+ params *driver.RunImageDevContainerParams,
helper *docker.DockerHelper,
) ([]string, error) {
b := &runArgsBuilder{
diff --git a/pkg/driver/docker/runargs_test.go b/pkg/driver/docker/runargs_test.go
index 17cc50290..67f99a7fc 100644
--- a/pkg/driver/docker/runargs_test.go
+++ b/pkg/driver/docker/runargs_test.go
@@ -96,7 +96,7 @@ func (s *DockerDriverTestSuite) TestAddRunPlatform_SetAppendsFlag() {
b := &runArgsBuilder{
args: []string{testRunArg},
driver: s.driver,
- params: &driver.RunDockerDevContainerParams{
+ params: &driver.RunImageDevContainerParams{
Options: &driver.RunOptions{Platform: "linux/amd64"},
ParsedConfig: &config.DevContainerConfig{},
},
@@ -109,7 +109,7 @@ func (s *DockerDriverTestSuite) TestAddRunPlatform_EmptyNoFlag() {
b := &runArgsBuilder{
args: []string{testRunArg},
driver: s.driver,
- params: &driver.RunDockerDevContainerParams{
+ params: &driver.RunImageDevContainerParams{
Options: &driver.RunOptions{Platform: ""},
ParsedConfig: &config.DevContainerConfig{},
},
@@ -124,7 +124,7 @@ func (s *DockerDriverTestSuite) TestAddRunPlatform_ExplicitInConfigNotDuplicated
b := &runArgsBuilder{
args: []string{testRunArg},
driver: s.driver,
- params: &driver.RunDockerDevContainerParams{
+ params: &driver.RunImageDevContainerParams{
Options: &driver.RunOptions{Platform: "linux/amd64"},
ParsedConfig: &config.DevContainerConfig{
NonComposeBase: config.NonComposeBase{
diff --git a/pkg/driver/drivercreate/create.go b/pkg/driver/drivercreate/create.go
index bc248ca1d..e690824db 100644
--- a/pkg/driver/drivercreate/create.go
+++ b/pkg/driver/drivercreate/create.go
@@ -1,16 +1,21 @@
package drivercreate
import (
+ "context"
"fmt"
"github.com/devsy-org/devsy/pkg/driver"
+ "github.com/devsy-org/devsy/pkg/driver/apple"
"github.com/devsy-org/devsy/pkg/driver/custom"
"github.com/devsy-org/devsy/pkg/driver/docker"
"github.com/devsy-org/devsy/pkg/driver/kubernetes"
provider2 "github.com/devsy-org/devsy/pkg/provider"
)
-func NewDriver(workspaceInfo *provider2.AgentWorkspaceInfo) (driver.Driver, error) {
+func NewDriver(
+ ctx context.Context,
+ workspaceInfo *provider2.AgentWorkspaceInfo,
+) (driver.Driver, error) {
driver := workspaceInfo.Agent.Driver
switch driver {
case "", provider2.DockerDriver:
@@ -19,8 +24,11 @@ func NewDriver(workspaceInfo *provider2.AgentWorkspaceInfo) (driver.Driver, erro
return custom.NewCustomDriver(workspaceInfo), nil
case provider2.KubernetesDriver:
return kubernetes.NewKubernetesDriver(workspaceInfo)
+ case provider2.AppleDriver:
+ return apple.NewAppleDriver(ctx, workspaceInfo)
}
- return nil, fmt.Errorf("unrecognized driver %q, possible values are %s, %s or %s",
- driver, provider2.DockerDriver, provider2.CustomDriver, provider2.KubernetesDriver)
+ return nil, fmt.Errorf("unrecognized driver %q, possible values are %s, %s, %s or %s",
+ driver, provider2.DockerDriver, provider2.CustomDriver, provider2.KubernetesDriver,
+ provider2.AppleDriver)
}
diff --git a/pkg/driver/kubernetes/driver.go b/pkg/driver/kubernetes/driver.go
index 3d6bc4b8c..aede8daca 100644
--- a/pkg/driver/kubernetes/driver.go
+++ b/pkg/driver/kubernetes/driver.go
@@ -45,6 +45,9 @@ func NewKubernetesDriver(
}, nil
}
+// The kubernetes driver runs devcontainers as pods and can reprovision them.
+var _ driver.ReprovisioningDriver = (*KubernetesDriver)(nil)
+
type KubernetesDriver struct {
namespace string
diff --git a/pkg/driver/types.go b/pkg/driver/types.go
index 78313ff5d..eb0ff0059 100644
--- a/pkg/driver/types.go
+++ b/pkg/driver/types.go
@@ -15,9 +15,6 @@ type Driver interface {
// CommandDevContainer runs the given command inside the devcontainer
CommandDevContainer(ctx context.Context, params *CommandParams) error
- // RunDevContainer runs a devcontainer
- RunDevContainer(ctx context.Context, workspaceID string, options *RunOptions) error
-
// TargetArchitecture returns the architecture of the container runtime. e.g. amd64 or arm64
TargetArchitecture(ctx context.Context, workspaceID string) (string, error)
@@ -39,9 +36,23 @@ type Driver interface {
) error
}
-type ReprovisioningDriver interface {
+// RunOptionsDriver is a capability interface for drivers that run a devcontainer
+// directly from RunOptions. These drivers delegate container management to an
+// external orchestrator (e.g. a Kubernetes pod or a custom command) rather than
+// building and running a local OCI image; image drivers use ImageDriver instead.
+type RunOptionsDriver interface {
Driver
+ // RunDevContainer runs a devcontainer
+ RunDevContainer(ctx context.Context, workspaceID string, options *RunOptions) error
+}
+
+// ReprovisioningDriver is a capability interface for drivers that can reprovision
+// an existing devcontainer in place. Reprovisioning re-runs the container, so it
+// embeds RunOptionsDriver.
+type ReprovisioningDriver interface {
+ RunOptionsDriver
+
// CanReprovision returns true if the driver can reprovision the devcontainer
CanReprovision() bool
}
diff --git a/pkg/options/resolve.go b/pkg/options/resolve.go
index d3d7a833d..c385812a0 100644
--- a/pkg/options/resolve.go
+++ b/pkg/options/resolve.go
@@ -246,6 +246,7 @@ func ResolveAgentConfig(
resolveAgentBaseConfig(&agentConfig, options, devConfig)
resolveAgentDockerConfig(&agentConfig, options)
resolveAgentKubernetesConfig(&agentConfig, options)
+ resolveAgentAppleConfig(&agentConfig, options)
resolveAgentPathAndURL(&agentConfig, options, devConfig)
resolveAgentCredentials(&agentConfig, options, devConfig)
@@ -323,6 +324,17 @@ func resolveAgentKubernetesConfig(
k8s.DiskSize = resolver.ResolveDefaultValue(k8s.DiskSize, options)
}
+func resolveAgentAppleConfig(
+ agentConfig *provider.ProviderAgentConfig,
+ options map[string]string,
+) {
+ agentConfig.Apple.Path = resolver.ResolveDefaultValue(agentConfig.Apple.Path, options)
+ agentConfig.Apple.Rosetta = types.StrBool(
+ resolver.ResolveDefaultValue(string(agentConfig.Apple.Rosetta), options),
+ )
+ agentConfig.Apple.Env = resolver.ResolveDefaultValues(agentConfig.Apple.Env, options)
+}
+
func resolveAgentPathAndURL(
agentConfig *provider.ProviderAgentConfig,
options map[string]string,
diff --git a/pkg/options/resolve_test.go b/pkg/options/resolve_test.go
index b9b7352bf..aabecdbad 100644
--- a/pkg/options/resolve_test.go
+++ b/pkg/options/resolve_test.go
@@ -709,3 +709,25 @@ func optionsToSubCommand(optionDefinitions config.OptionDefinitions) string {
})
return fmt.Sprintf("echo %q | base64 --decode", base64.StdEncoding.EncodeToString(out))
}
+
+// TestResolveAgentAppleConfig locks in the fix for ${CONTAINER_PATH}-style
+// option substitution into the apple driver config, which previously reached
+// exec unresolved because no per-driver resolver existed for apple.
+func TestResolveAgentAppleConfig(t *testing.T) {
+ agentConfig := &provider.ProviderAgentConfig{}
+ agentConfig.Apple.Path = "${CONTAINER_PATH}"
+ agentConfig.Apple.Rosetta = types.StrBool("${ROSETTA}")
+ agentConfig.Apple.Env = map[string]string{"KEY": "${VAL}"}
+
+ options := map[string]string{
+ "CONTAINER_PATH": "/opt/homebrew/bin/container",
+ "ROSETTA": "true",
+ "VAL": "resolved",
+ }
+
+ resolveAgentAppleConfig(agentConfig, options)
+
+ assert.Equal(t, "/opt/homebrew/bin/container", agentConfig.Apple.Path)
+ assert.Equal(t, types.StrBool("true"), agentConfig.Apple.Rosetta)
+ assert.Equal(t, "resolved", agentConfig.Apple.Env["KEY"])
+}
diff --git a/pkg/provider/parse.go b/pkg/provider/parse.go
index ca5471f59..803606221 100644
--- a/pkg/provider/parse.go
+++ b/pkg/provider/parse.go
@@ -235,8 +235,9 @@ func validateAgentDriver(config *ProviderConfig) error {
if config.Agent.Driver != "" && config.Agent.Driver != CustomDriver &&
config.Agent.Driver != DockerDriver &&
- config.Agent.Driver != KubernetesDriver {
- return fmt.Errorf("agent.driver can only be docker, kubernetes or custom")
+ config.Agent.Driver != KubernetesDriver &&
+ config.Agent.Driver != AppleDriver {
+ return fmt.Errorf("agent.driver can only be docker, kubernetes, apple or custom")
}
if config.Agent.Driver == CustomDriver {
diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go
index ad0896f1c..582d523b0 100644
--- a/pkg/provider/provider.go
+++ b/pkg/provider/provider.go
@@ -124,6 +124,9 @@ type ProviderAgentConfig struct {
// Kubernetes holds kubernetes specific configuration
Kubernetes ProviderKubernetesDriverConfig `json:"kubernetes"`
+
+ // Apple holds Apple container specific configuration
+ Apple ProviderAppleDriverConfig `json:"apple"`
}
type ProviderDockerlessOptions struct {
@@ -151,8 +154,22 @@ const (
DockerDriver = "docker"
KubernetesDriver = "kubernetes"
CustomDriver = "custom"
+ AppleDriver = "apple"
)
+// ProviderAppleDriverConfig holds configuration for the Apple container driver,
+// which runs Linux containers as lightweight VMs on Apple silicon (macOS 26+).
+type ProviderAppleDriverConfig struct {
+ // Path where to find the `container` binary, defaults to 'container'
+ Path string `json:"path,omitempty"`
+
+ // Rosetta enables x86_64 emulation inside the guest.
+ Rosetta types.StrBool `json:"rosetta,omitempty"`
+
+ // Environment variables to set when running `container` commands
+ Env map[string]string `json:"env,omitempty"`
+}
+
type ProviderCustomDriverConfig struct {
// FindDevContainer is used to find an existing devcontainer
FindDevContainer types.StrArray `json:"findDevContainer,omitempty"`
diff --git a/pkg/workspace/exec.go b/pkg/workspace/exec.go
index ee81f7e59..4d4d98fe5 100644
--- a/pkg/workspace/exec.go
+++ b/pkg/workspace/exec.go
@@ -202,6 +202,13 @@ type ContainerRuntime interface {
// ProbeEnv reads the container's environment via shell. Returns an empty
// map on any failure; probeMode comes from devcconfig.UserEnvProbe.
ProbeEnv(ctx context.Context, target ContainerTarget, probeMode string) map[string]string
+
+ // Command returns the container CLI binary to invoke for a raw
+ // (interactive) exec that bypasses the helper.
+ Command() string
+
+ // Environment returns extra environment for the CLI process in raw execs.
+ Environment() []string
}
// ExecRequest is the per-call input to ContainerRuntime.Exec.
@@ -238,6 +245,12 @@ func NewDockerRuntime(workspace *provider2.Workspace, override string) *DockerRu
func (r *DockerRuntime) DockerCommand() string { return r.helper.DockerCommand }
+// Command implements ContainerRuntime.
+func (r *DockerRuntime) Command() string { return r.helper.DockerCommand }
+
+// Environment implements ContainerRuntime.
+func (r *DockerRuntime) Environment() []string { return r.helper.Environment }
+
func (r *DockerRuntime) FindRunning(
ctx context.Context,
workspaceID string,
@@ -267,6 +280,37 @@ func (r *DockerRuntime) FindRunning(
}
func (r *DockerRuntime) Exec(ctx context.Context, req ExecRequest) (int, error) {
+ return execWithRunner(ctx, req, r.runner())
+}
+
+func (r *DockerRuntime) ProbeEnv(
+ ctx context.Context,
+ target ContainerTarget,
+ probe string,
+) map[string]string {
+ return probeEnvWithRunner(ctx, target, probe, r.runner())
+}
+
+// runner adapts the helper's Streams-based Run to containerRunFunc.
+func (r *DockerRuntime) runner() containerRunFunc {
+ return func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
+ return r.helper.Run(ctx, args, docker.Streams{Stdin: stdin, Stdout: stdout, Stderr: stderr})
+ }
+}
+
+// containerRunFunc executes a container CLI invocation. Both DockerHelper.Run
+// and AppleHelper.Run satisfy this signature, letting the exec and env-probe
+// logic be shared across runtimes.
+type containerRunFunc func(
+ ctx context.Context,
+ args []string,
+ stdin io.Reader,
+ stdout, stderr io.Writer,
+) error
+
+// execWithRunner builds the exec argument list and runs it via run, translating
+// a non-zero process exit into a returned exit code rather than an error.
+func execWithRunner(ctx context.Context, req ExecRequest, run containerRunFunc) (int, error) {
execArgs := []string{"exec", "-i"}
for k, v := range req.Env {
execArgs = append(execArgs, "-e", k+"="+v)
@@ -289,7 +333,7 @@ func (r *DockerRuntime) Exec(ctx context.Context, req ExecRequest) (int, error)
stderr = io.Discard
}
- err := r.helper.Run(ctx, execArgs, docker.Streams{Stdout: stdout, Stderr: stderr})
+ err := run(ctx, execArgs, nil, stdout, stderr)
if err == nil {
return 0, nil
}
@@ -300,10 +344,13 @@ func (r *DockerRuntime) Exec(ctx context.Context, req ExecRequest) (int, error)
return -1, fmt.Errorf("exec in container %s: %w", req.Target.ContainerID, err)
}
-func (r *DockerRuntime) ProbeEnv(
+// probeEnvWithRunner reads the container environment via a shell, tolerating
+// failure by returning an empty map.
+func probeEnvWithRunner(
ctx context.Context,
target ContainerTarget,
probe string,
+ run containerRunFunc,
) map[string]string {
userEnvProbe, err := devcconfig.NewUserEnvProbe(probe)
if err != nil {
@@ -315,8 +362,7 @@ func (r *DockerRuntime) ProbeEnv(
}
shellFlag := probeShellFlag(userEnvProbe)
-
- out, sep, probeErr := r.runProbeCommand(ctx, target, shellFlag)
+ out, sep, probeErr := runProbeCommand(ctx, target, shellFlag, run)
if probeErr != nil {
log.Warnf("Failed to probe user env: %v", probeErr)
return map[string]string{}
@@ -324,23 +370,21 @@ func (r *DockerRuntime) ProbeEnv(
return parseEnvOutput(out, sep)
}
-func (r *DockerRuntime) runProbeCommand(
+func runProbeCommand(
ctx context.Context,
target ContainerTarget,
shellFlag string,
+ run containerRunFunc,
) ([]byte, byte, error) {
args := buildProbeArgs(target, shellFlag, "cat /proc/self/environ")
var stdout bytes.Buffer
- err := r.helper.Run(ctx, args, docker.Streams{Stdout: &stdout, Stderr: io.Discard})
- if err == nil {
+ if err := run(ctx, args, nil, &stdout, io.Discard); err == nil {
return stdout.Bytes(), 0, nil
}
- log.Debugf("Env probe with /proc/self/environ failed: %v, trying printenv", err)
args = buildProbeArgs(target, shellFlag, "printenv")
stdout.Reset()
- err = r.helper.Run(ctx, args, docker.Streams{Stdout: &stdout, Stderr: io.Discard})
- if err != nil {
+ if err := run(ctx, args, nil, &stdout, io.Discard); err != nil {
return nil, 0, fmt.Errorf("probe user env: %w", err)
}
return stdout.Bytes(), '\n', nil
@@ -476,7 +520,10 @@ func resolveExecTarget(ctx context.Context, opts ExecOneShotOptions) (resolvedEx
}
workspaceConfig := client.WorkspaceConfig()
- runtime := NewDockerRuntime(workspaceConfig, "")
+ runtime, err := NewContainerRuntime(workspaceConfig, "")
+ if err != nil {
+ return resolvedExecTarget{}, err
+ }
containerDetails, err := runtime.FindRunning(
ctx, devcontainer.GetRunnerIDFromWorkspace(workspaceConfig), opts.IDLabels,
diff --git a/pkg/workspace/exec_apple.go b/pkg/workspace/exec_apple.go
new file mode 100644
index 000000000..676c4e9bc
--- /dev/null
+++ b/pkg/workspace/exec_apple.go
@@ -0,0 +1,145 @@
+package workspace
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/devsy-org/devsy/pkg/apple"
+ devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config"
+ "github.com/devsy-org/devsy/pkg/log"
+ provider2 "github.com/devsy-org/devsy/pkg/provider"
+)
+
+const defaultContainerCommand = "container"
+
+// NewContainerRuntime returns an AppleRuntime for the apple driver, else a
+// DockerRuntime. A provider-config load failure is returned rather than silently
+// falling back to the (wrong) Docker runtime for an apple workspace.
+func NewContainerRuntime(
+ workspace *provider2.Workspace,
+ override string,
+) (ContainerRuntime, error) {
+ isApple, err := isAppleDriver(workspace)
+ if err != nil {
+ return nil, err
+ }
+ if isApple {
+ return NewAppleRuntime(workspace, override), nil
+ }
+ return NewDockerRuntime(workspace, override), nil
+}
+
+func isAppleDriver(workspace *provider2.Workspace) (bool, error) {
+ if workspace == nil || workspace.Context == "" {
+ return false, nil
+ }
+ providerConfig, err := provider2.LoadProviderConfig(workspace.Context, workspace.Provider.Name)
+ if err != nil {
+ return false, fmt.Errorf("load provider config for runtime selection: %w", err)
+ }
+ return providerConfig.Agent.Driver == provider2.AppleDriver, nil
+}
+
+// ResolveContainerCommand resolves the `container` binary: override, then
+// agent.apple.path, then the default.
+func ResolveContainerCommand(workspace *provider2.Workspace, override string) string {
+ if override != "" {
+ return override
+ }
+ if workspace == nil || workspace.Context == "" {
+ return defaultContainerCommand
+ }
+ providerConfig, err := provider2.LoadProviderConfig(workspace.Context, workspace.Provider.Name)
+ if err != nil {
+ log.Warnf(
+ "failed to load provider config, using default container command (ignoring agent.apple.path): %v",
+ err,
+ )
+ return defaultContainerCommand
+ }
+ if providerConfig.Agent.Apple.Path != "" {
+ if expanded := expandWithOptions(
+ providerConfig.Agent.Apple.Path,
+ workspace.Provider.Options,
+ ); expanded != "" {
+ return expanded
+ }
+ }
+ return defaultContainerCommand
+}
+
+// resolveContainerEnv returns agent.apple.env so exec/logs match the `up` path.
+func resolveContainerEnv(workspace *provider2.Workspace) []string {
+ if workspace == nil || workspace.Context == "" {
+ return nil
+ }
+ providerConfig, err := provider2.LoadProviderConfig(workspace.Context, workspace.Provider.Name)
+ if err != nil {
+ return nil
+ }
+ var env []string
+ for k, v := range providerConfig.Agent.Apple.Env {
+ env = append(env, k+"="+expandWithOptions(v, workspace.Provider.Options))
+ }
+ return env
+}
+
+type AppleRuntime struct {
+ helper *apple.AppleHelper
+}
+
+func NewAppleRuntime(workspace *provider2.Workspace, override string) *AppleRuntime {
+ return &AppleRuntime{
+ helper: &apple.AppleHelper{
+ Command: ResolveContainerCommand(workspace, override),
+ Environment: resolveContainerEnv(workspace),
+ },
+ }
+}
+
+func (r *AppleRuntime) Command() string { return r.helper.Command }
+
+func (r *AppleRuntime) Environment() []string { return r.helper.Environment }
+
+func (r *AppleRuntime) FindRunning(
+ ctx context.Context,
+ workspaceID string,
+ idLabels []string,
+) (*devcconfig.ContainerDetails, error) {
+ labels := devcconfig.GetIDLabels(workspaceID, idLabels)
+ container, err := r.helper.FindDevContainer(ctx, labels)
+ if err != nil {
+ return nil, fmt.Errorf("find container: %w", err)
+ }
+ if container == nil {
+ return nil, fmt.Errorf("no running container found for workspace %q", workspaceID)
+ }
+ if !strings.EqualFold(container.State.Status, ContainerStatusRunning) {
+ return nil, fmt.Errorf(
+ "container %s is not running (status: %s)",
+ container.ID, container.State.Status,
+ )
+ }
+ return container, nil
+}
+
+func (r *AppleRuntime) Exec(ctx context.Context, req ExecRequest) (int, error) {
+ return execWithRunner(ctx, req, r.runner())
+}
+
+func (r *AppleRuntime) ProbeEnv(
+ ctx context.Context,
+ target ContainerTarget,
+ probe string,
+) map[string]string {
+ return probeEnvWithRunner(ctx, target, probe, r.runner())
+}
+
+// runner adapts the helper's Streams-based Run to containerRunFunc.
+func (r *AppleRuntime) runner() containerRunFunc {
+ return func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
+ return r.helper.Run(ctx, args, apple.Streams{Stdin: stdin, Stdout: stdout, Stderr: stderr})
+ }
+}
diff --git a/pkg/workspace/exec_shared_test.go b/pkg/workspace/exec_shared_test.go
new file mode 100644
index 000000000..c5220999d
--- /dev/null
+++ b/pkg/workspace/exec_shared_test.go
@@ -0,0 +1,100 @@
+//go:build !windows
+
+package workspace
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+// TestExecWithRunner covers the shared exec path used by both the Docker and
+// Apple runtimes: argument assembly and exit-code translation.
+func TestExecWithRunner(t *testing.T) {
+ var gotArgs []string
+ run := func(_ context.Context, args []string, _ io.Reader, _, _ io.Writer) error {
+ gotArgs = args
+ return nil
+ }
+
+ req := ExecRequest{
+ Target: ContainerTarget{ContainerID: "cid", User: "vscode"},
+ Workdir: "/work",
+ Env: map[string]string{"WSVAR": "wsval"},
+ Argv: []string{"echo", "hi"},
+ }
+ code, err := execWithRunner(context.Background(), req, run)
+ if err != nil || code != 0 {
+ t.Fatalf("execWithRunner = (%d, %v), want (0, nil)", code, err)
+ }
+ joined := strings.Join(gotArgs, " ")
+ for _, want := range []string{"exec -i", "-e WSVAR=wsval", "--workdir /work", "--user vscode", "cid", "echo hi"} {
+ if !strings.Contains(joined, want) {
+ t.Errorf("args missing %q: %s", want, joined)
+ }
+ }
+}
+
+func TestExecWithRunnerExitCode(t *testing.T) {
+ // A non-zero process exit is reported as an exit code, not a machinery error.
+ exitRun := func(_ context.Context, _ []string, _ io.Reader, _, _ io.Writer) error {
+ return exitErr(t, 7)
+ }
+ target := ExecRequest{Target: ContainerTarget{ContainerID: "c"}}
+ code, err := execWithRunner(context.Background(), target, exitRun)
+ if err != nil {
+ t.Fatalf("exit error must not surface as machinery error: %v", err)
+ }
+ if code != 7 {
+ t.Errorf("exit code = %d, want 7", code)
+ }
+
+ // A genuine machinery failure surfaces as (-1, err).
+ failRun := func(_ context.Context, _ []string, _ io.Reader, _, _ io.Writer) error {
+ return errors.New("binary not found")
+ }
+ req := ExecRequest{Target: ContainerTarget{ContainerID: "c"}}
+ code, err = execWithRunner(context.Background(), req, failRun)
+ if err == nil || code != -1 {
+ t.Errorf("machinery failure = (%d, %v), want (-1, err)", code, err)
+ }
+}
+
+func TestProbeEnvWithRunner(t *testing.T) {
+ // /proc/self/environ succeeds → NUL-separated parse.
+ run := func(_ context.Context, _ []string, _ io.Reader, stdout, _ io.Writer) error {
+ _, _ = stdout.Write([]byte("PATH=/bin\x00HOME=/root\x00"))
+ return nil
+ }
+ target := ContainerTarget{ContainerID: "c"}
+ env := probeEnvWithRunner(context.Background(), target, "loginInteractiveShell", run)
+ if env["PATH"] != "/bin" || env["HOME"] != "/root" {
+ t.Errorf("probed env = %v", env)
+ }
+
+ // Total failure → empty map (documented contract), never a panic.
+ failRun := func(_ context.Context, _ []string, _ io.Reader, _, _ io.Writer) error {
+ return errors.New("exec failed")
+ }
+ got := probeEnvWithRunner(context.Background(), target, "loginInteractiveShell", failRun)
+ if len(got) != 0 {
+ t.Errorf("expected empty map on failure, got %v", got)
+ }
+}
+
+// exitErr returns a real *exec.ExitError carrying the given exit code, produced
+// by running `sh -c "exit "`.
+func exitErr(t *testing.T, code int) error {
+ t.Helper()
+ //nolint:gosec // G204: test-controlled constant exit code, not user input
+ err := exec.Command("sh", "-c", fmt.Sprintf("exit %d", code)).Run()
+ var ee *exec.ExitError
+ if !errors.As(err, &ee) {
+ t.Fatalf("could not synthesize exit error: %v", err)
+ }
+ return ee
+}
diff --git a/pkg/workspace/exec_test.go b/pkg/workspace/exec_test.go
index 28472b7ec..5cdf7e63c 100644
--- a/pkg/workspace/exec_test.go
+++ b/pkg/workspace/exec_test.go
@@ -95,6 +95,10 @@ func (f *fakeRuntime) FindRunning(
return f.findResult, f.findErr
}
+func (f *fakeRuntime) Command() string { return "fake" }
+
+func (f *fakeRuntime) Environment() []string { return nil }
+
func (f *fakeRuntime) Exec(_ context.Context, req ExecRequest) (int, error) {
stdout := req.Stdout
if stdout == nil {
diff --git a/providers/apple/provider.yaml b/providers/apple/provider.yaml
new file mode 100644
index 000000000..312f2aada
--- /dev/null
+++ b/providers/apple/provider.yaml
@@ -0,0 +1,31 @@
+name: apple
+version: v1.0.0
+icon: https://dl.devsy.sh/assets/apple.svg
+home: https://github.com/devsy-org/devsy
+description: |-
+ Devsy on Apple container (macOS 26+, Apple silicon)
+optionGroups:
+ - options:
+ - CONTAINER_PATH
+ - ROSETTA
+ - INACTIVITY_TIMEOUT
+ name: "Advanced Options"
+options:
+ INACTIVITY_TIMEOUT:
+ description: "If defined, will automatically delete the container after the inactivity period. Examples: 10m, 1h"
+ CONTAINER_PATH:
+ description: The path where to find the `container` binary.
+ default: container
+ ROSETTA:
+ description: "Enable Rosetta for x86_64 emulation inside the Linux guest."
+ default: "false"
+agent:
+ containerInactivityTimeout: ${INACTIVITY_TIMEOUT}
+ local: true
+ driver: apple
+ apple:
+ path: ${CONTAINER_PATH}
+ rosetta: ${ROSETTA}
+exec:
+ command: |-
+ "${DEVSY}" internal sh -c "${COMMAND}"
diff --git a/providers/providers.go b/providers/providers.go
index 6f39d4861..d1dbe5a27 100644
--- a/providers/providers.go
+++ b/providers/providers.go
@@ -4,6 +4,9 @@ import (
_ "embed"
)
+//go:embed apple/provider.yaml
+var AppleProvider string
+
//go:embed docker/provider.yaml
var DockerProvider string
@@ -19,6 +22,7 @@ var ProProvider string
// GetBuiltInProviders retrieves the built in providers.
func GetBuiltInProviders() map[string]string {
return map[string]string{
+ "apple": AppleProvider,
"docker": DockerProvider,
"kubernetes": KubernetesProvider,
"podman": PodmanProvider,
diff --git a/providers/providers_test.go b/providers/providers_test.go
new file mode 100644
index 000000000..aef5993e7
--- /dev/null
+++ b/providers/providers_test.go
@@ -0,0 +1,35 @@
+package providers_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/devsy-org/devsy/pkg/provider"
+ "github.com/devsy-org/devsy/providers"
+)
+
+// TestBuiltInProvidersParse ensures every embedded provider.yaml parses and
+// validates, guarding against malformed YAML or invalid driver config.
+func TestBuiltInProvidersParse(t *testing.T) {
+ for name, raw := range providers.GetBuiltInProviders() {
+ t.Run(name, func(t *testing.T) {
+ cfg, err := provider.ParseProvider(strings.NewReader(raw))
+ if err != nil {
+ t.Fatalf("parse provider %q: %v", name, err)
+ }
+ if cfg.Name == "" {
+ t.Errorf("provider %q parsed with empty name", name)
+ }
+ })
+ }
+}
+
+func TestAppleProviderUsesAppleDriver(t *testing.T) {
+ cfg, err := provider.ParseProvider(strings.NewReader(providers.AppleProvider))
+ if err != nil {
+ t.Fatalf("parse apple provider: %v", err)
+ }
+ if cfg.Agent.Driver != provider.AppleDriver {
+ t.Errorf("apple provider driver = %q, want %q", cfg.Agent.Driver, provider.AppleDriver)
+ }
+}