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) + } +}