diff --git a/cmd/ggo/studio/studio.go b/cmd/ggo/studio/studio.go index 10ad196..70ed7bd 100644 --- a/cmd/ggo/studio/studio.go +++ b/cmd/ggo/studio/studio.go @@ -46,6 +46,7 @@ func NewStudioCmd() *cobra.Command { Supported platforms: - wsl: Windows Subsystem for Linux (Windows only) - colima: Colima container runtime (macOS/Linux) + - apple-container: Apple Container (macOS 26+) - docker: Native Docker - k8s: Kubernetes (kind, minikube, etc.) - auto: Auto-detect best available platform @@ -57,6 +58,9 @@ Examples: # Create with specific mode ggo studio create my-studio --mode wsl -s "https://..." + # Create with Apple Container (macOS 26+) + ggo studio create my-studio --mode apple-container -s "https://..." + # Create with specific Colima profile ggo studio create my-studio --mode colima --colima-profile myprofile @@ -178,7 +182,7 @@ Examples: RunE: runCreate, } - cmd.Flags().StringVarP(&mode, "mode", "m", "", "Container/VM mode (wsl, colima, docker, k8s, auto)") + cmd.Flags().StringVarP(&mode, "mode", "m", "", "Container/VM mode (wsl, colima, apple-container, docker, k8s, auto)") cmd.Flags().StringVarP(&image, "image", "i", "tensorfusion/studio-torch:latest", "Container image") cmd.Flags().StringVarP(&shareLink, "share-link", "s", "", "Share link or share code to remote vGPU worker (required)") cmd.Flags().StringVar(&serverURL, "server", api.GetDefaultBaseURL(), "Server URL for resolving share links") @@ -813,8 +817,10 @@ func (r *backendsResult) RenderTUI(out *tui.Output) { out.Println() out.Println(styles.Subtitle.Render("Install one of the following:")) out.Println() + out.Println(" • " + styles.Bold.Render("Apple Container (macOS 26+):") + " " + tui.URL("https://github.com/apple/container/releases")) out.Println(" • " + styles.Bold.Render("Docker:") + " " + tui.URL("https://docs.docker.com/get-docker/")) out.Println(" • " + styles.Bold.Render("Colima (macOS):") + " " + tui.Code("brew install colima")) + out.Println(" • " + styles.Bold.Render("OrbStack (macOS):") + " " + tui.Code("brew install orbstack")) out.Println(" • " + styles.Bold.Render("WSL (Windows):") + " " + tui.URL("https://docs.microsoft.com/en-us/windows/wsl/install")) out.Println() return diff --git a/docs/plans/2026-02-06-apple-container-backend-design.md b/docs/plans/2026-02-06-apple-container-backend-design.md new file mode 100644 index 0000000..33ed4ce --- /dev/null +++ b/docs/plans/2026-02-06-apple-container-backend-design.md @@ -0,0 +1,42 @@ +# Apple Container Backend Design + +**Date:** 2026-02-06 + +## Goal +Enable `ggo studio create` to use Apple Container on macOS 26+ as a first-choice fallback when no Docker socket is available, while preserving existing Docker/Colima/OrbStack flows and providing clear install/upgrade guidance. + +## Key Behaviors +- Replace the `apple` mode with `apple-container` across CLI flags, help text, and internal mode constants. +- On macOS 26+: + - If no Docker socket is found, prefer Apple Container for auto mode. + - If `--mode apple-container` is specified but Apple Container is missing, prompt install. + - If no Docker socket is found and Apple Container is missing, prompt install. +- On macOS < 26: + - If `--mode apple-container` is specified, error with OS upgrade requirement. + - If no runtime is available, recommend installing Colima (plus other options) and note Apple Container needs macOS 26. + +## Backend Strategy +- Implement Apple Container backend using the `container` CLI (not Docker). +- Detect availability with `container system status` and presence of the `container` CLI. +- Implement list/get/start/stop/exec/logs/delete via `container` subcommands. +- Parse JSON from `container list --format json` / `container inspect` to build `Environment` objects. + +## Runtime Detection +- Add helper for macOS major version detection using `runtime` + `internal/platform` helper. +- Add Docker socket discovery for common paths: + - `DOCKER_HOST` (unix socket) + - `/var/run/docker.sock` + - `~/.colima/*/docker.sock` + - `~/.orbstack/run/docker.sock` +- Use socket presence to decide preference order on macOS 26+. + +## User-Facing Messaging +- Update CLI help strings and examples to include `apple-container`. +- Provide install guidance for: + - Apple Container: download signed pkg from GitHub releases. + - Colima/OrbStack: `brew install` suggestions. + +## Testing +- Add ginkgo tests covering backend selection rules and macOS version gating. +- Run `container` CLI end-to-end tests on a common arm64 image (e.g., `alpine:latest`), ensuring create/list/exec/logs/stop/delete flows work. + diff --git a/docs/plans/2026-02-06-apple-container-backend.md b/docs/plans/2026-02-06-apple-container-backend.md new file mode 100644 index 0000000..3a42ece --- /dev/null +++ b/docs/plans/2026-02-06-apple-container-backend.md @@ -0,0 +1,194 @@ +# Apple Container Backend Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Apple Container (`container` CLI) support for `ggo studio create` on macOS 26+, with correct auto-selection, install/upgrade messaging, and updated CLI/docs/UI strings. + +**Architecture:** Introduce platform helpers for macOS version and Docker socket detection, then update backend selection to prefer Apple Container only when no Docker socket is present on macOS 26+. Rebuild the Apple backend around the `container` CLI with JSON parsing for list/inspect, and ensure install/runtime hints are precise. + +**Tech Stack:** Go, Cobra, Ginkgo/Gomega, Apple `container` CLI. + +--- + +### Task 1: Add Ginkgo suite + failing tests for macOS selection rules + +**Files:** +- Create: `internal/studio/studio_suite_test.go` +- Create: `internal/studio/manager_apple_ginkgo_test.go` + +**Step 1: Write the failing test** +```go +var _ = Describe("Apple container selection", func() { + It("prefers apple-container on macOS 26+ when no Docker socket is present", func() { + // stub platform helpers to darwin/26/no-socket + // register apple + docker backends (both available) + // expect ModeAppleContainer for ModeAuto + }) + + It("prefers docker/colima when Docker socket exists on macOS 26+", func() { + // stub platform helpers to darwin/26/has-socket + // register apple + docker backends (both available) + // expect ModeDocker for ModeAuto + }) + + It("rejects apple-container on macOS < 26 when explicitly requested", func() { + // stub platform helpers to darwin/25 + // expect error mentioning macOS 26 upgrade + }) +}) +``` + +**Step 2: Run test to verify it fails** +Run: `go test ./internal/studio -run Apple` +Expected: FAIL with missing helper stubs / selection logic not implemented + +**Step 3: Write minimal implementation** +Implement platform helper stubs and selection logic hooks (function vars) to satisfy the tests. + +**Step 4: Run test to verify it passes** +Run: `go test ./internal/studio -run Apple` +Expected: PASS + +**Step 5: Commit** +```bash +git add internal/studio/studio_suite_test.go internal/studio/manager_apple_ginkgo_test.go +GIT_OPTIONAL_LOCKS=0 git -c core.hooksPath=/dev/null commit -m "test: add apple-container selection tests" +``` + +### Task 2: Implement macOS version + Docker socket helpers and update Manager selection/hints + +**Files:** +- Create: `internal/platform/macos_version_darwin.go` +- Create: `internal/platform/macos_version_other.go` +- Create: `internal/platform/docker_socket.go` +- Modify: `internal/studio/manager.go` + +**Step 1: Write the failing test** +Extend existing Ginkgo tests (Task 1) to assert explicit error messaging for unsupported macOS version and missing Apple Container install guidance. + +**Step 2: Run test to verify it fails** +Run: `go test ./internal/studio -run Apple` +Expected: FAIL with error text mismatch or missing helpers + +**Step 3: Write minimal implementation** +- Add `platform.MacOSMajorVersion()` using `syscall.Sysctl("kern.osproductversion")` on darwin; return `0` elsewhere. +- Add `platform.HasDockerSocket()` to scan `DOCKER_HOST`, `/var/run/docker.sock`, `~/.colima/*/docker.sock`, `~/.orbstack/run/docker.sock`. +- Update `Manager.detectBestBackend` to: + - On darwin 26+: prefer apple-container only when no Docker socket is found. + - On darwin < 26: exclude apple-container from auto preference list. +- Update `platformBackendHint` to include install guidance: + - Apple Container: download pkg from GitHub releases. + - Colima/OrbStack: `brew install`. + - Docker: link to Docker Desktop. +- When `--mode apple-container` is requested on macOS < 26, return a clear upgrade error. + +**Step 4: Run test to verify it passes** +Run: `go test ./internal/studio -run Apple` +Expected: PASS + +**Step 5: Commit** +```bash +git add internal/platform/macos_version_darwin.go internal/platform/macos_version_other.go internal/platform/docker_socket.go internal/studio/manager.go +GIT_OPTIONAL_LOCKS=0 git -c core.hooksPath=/dev/null commit -m "feat: add macOS version/socket helpers and selection rules" +``` + +### Task 3: Rebuild Apple backend to use `container` CLI + parsing helpers + +**Files:** +- Modify: `internal/studio/backend_apple.go` +- Create: `internal/studio/apple_container_parse_test.go` + +**Step 1: Write the failing test** +```go +var _ = Describe("Apple container parsing", func() { + It("maps container list JSON to Environment and SSH port", func() { + // Provide sample JSON from `container list --format json` + // Expect label filtering, SSH port extraction, GPU_WORKER_URL parsing + }) +}) +``` + +**Step 2: Run test to verify it fails** +Run: `go test ./internal/studio -run Apple` +Expected: FAIL (parsing helpers not implemented) + +**Step 3: Write minimal implementation** +- Replace docker CLI usage with `container` CLI subcommands: + - `container system status` for availability + - `container system start` in `EnsureRunning` + - `container run --detach` for create + - `container list --format json` for list + - `container inspect` for get + - `container exec`, `container logs`, `container start`, `container stop`, `container delete --force` +- Add JSON parsing helpers for list/inspect output. +- Normalize memory suffixes for `container` CLI (`Gi`->`G`, `Mi`->`M`). +- Use labels `ggo.managed=true`, `ggo.name`, `ggo.mode=apple-container`. + +**Step 4: Run test to verify it passes** +Run: `go test ./internal/studio -run Apple` +Expected: PASS + +**Step 5: Commit** +```bash +git add internal/studio/backend_apple.go internal/studio/apple_container_parse_test.go +GIT_OPTIONAL_LOCKS=0 git -c core.hooksPath=/dev/null commit -m "feat: implement apple-container backend via container CLI" +``` + +### Task 4: Update CLI/Docs/UI strings for `apple-container` + +**Files:** +- Modify: `internal/studio/types.go` +- Modify: `cmd/ggo/studio/studio.go` +- Modify: `docs/studio-guide.md` +- Modify: `vscode-extension/src/views/createStudioPanel.ts` + +**Step 1: Write the failing test** +(Documentation/UI change; no automated test required) + +**Step 2: Run test to verify it fails** +Skip + +**Step 3: Write minimal implementation** +- Replace `apple` mode string with `apple-container`. +- Update CLI help, examples, and backend listing output to include install guidance. +- Update Studio guide table and VS Code UI mode labels. + +**Step 4: Run test to verify it passes** +Run: `go test ./cmd/ggo/studio ./internal/studio` +Expected: PASS + +**Step 5: Commit** +```bash +git add internal/studio/types.go cmd/ggo/studio/studio.go docs/studio-guide.md vscode-extension/src/views/createStudioPanel.ts +GIT_OPTIONAL_LOCKS=0 git -c core.hooksPath=/dev/null commit -m "docs: update apple-container mode strings" +``` + +### Task 5: Full functional Apple Container CLI test (manual) + +**Files:** +- None (manual verification) + +**Step 1: Run container services** +Run: `container system start` +Expected: services start successfully + +**Step 2: Run a common arm64 image** +Run: `container run --name ggo-apple-test --detach --rm -p 18022:22/tcp alpine:latest sleep 600` +Expected: container ID printed, `container list` shows it running + +**Step 3: Exec/logs/stop/delete** +Run: +- `container exec ggo-apple-test sh -c "echo ok"` +- `container logs ggo-apple-test` +- `container stop ggo-apple-test` +Expected: commands succeed + +**Step 4: Record results** +Note any failures or deviations. + +--- + +**Global verification after each Go change:** +- Run: `golangci-lint run --fix` +- Run: `go test ./...` (at least once before finalization) + diff --git a/docs/studio-guide.md b/docs/studio-guide.md index e03e7b1..a4cfb5c 100644 --- a/docs/studio-guide.md +++ b/docs/studio-guide.md @@ -99,7 +99,7 @@ ggo studio create my-studio -s abc123 --mode docker | `docker` | 原生 Docker | 所有 | | `colima` | Colima 容器运行时 | macOS/Linux | | `wsl` | Windows Subsystem for Linux | Windows | -| `apple` | Apple Virtualization Framework | macOS | +| `apple-container` | Apple Container(macOS 26+) | macOS | ### 卷挂载(Volume Mounts) diff --git a/internal/platform/docker_socket.go b/internal/platform/docker_socket.go new file mode 100644 index 0000000..353b3eb --- /dev/null +++ b/internal/platform/docker_socket.go @@ -0,0 +1,55 @@ +package platform + +import ( + "os" + "path/filepath" + "strings" +) + +// HasDockerSocket checks common Docker socket locations for macOS runtimes. +func HasDockerSocket() bool { + if socketPath := dockerHostSocketPath(os.Getenv("DOCKER_HOST")); socketPath != "" { + if fileExists(socketPath) { + return true + } + } + + if fileExists("/var/run/docker.sock") { + return true + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return false + } + + colimaGlob := filepath.Join(homeDir, ".colima", "*", "docker.sock") + if matches, err := filepath.Glob(colimaGlob); err == nil { + for _, match := range matches { + if fileExists(match) { + return true + } + } + } + + orbstackSock := filepath.Join(homeDir, ".orbstack", "run", "docker.sock") + return fileExists(orbstackSock) +} + +func dockerHostSocketPath(dockerHost string) string { + if dockerHost == "" { + return "" + } + if !strings.HasPrefix(dockerHost, "unix://") { + return "" + } + return strings.TrimPrefix(dockerHost, "unix://") +} + +func fileExists(path string) bool { + if path == "" { + return false + } + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/platform/macos_version_darwin.go b/internal/platform/macos_version_darwin.go new file mode 100644 index 0000000..d19e4df --- /dev/null +++ b/internal/platform/macos_version_darwin.go @@ -0,0 +1,31 @@ +//go:build darwin + +package platform + +import ( + "strconv" + "strings" + "syscall" +) + +// MacOSMajorVersion returns the macOS major version (e.g., 26). +// Returns 0 when the version cannot be determined. +func MacOSMajorVersion() int { + version, err := syscall.Sysctl("kern.osproductversion") + if err != nil { + return 0 + } + version = strings.TrimSpace(version) + if version == "" { + return 0 + } + parts := strings.Split(version, ".") + if len(parts) == 0 { + return 0 + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return 0 + } + return major +} diff --git a/internal/platform/macos_version_other.go b/internal/platform/macos_version_other.go new file mode 100644 index 0000000..934323d --- /dev/null +++ b/internal/platform/macos_version_other.go @@ -0,0 +1,8 @@ +//go:build !darwin + +package platform + +// MacOSMajorVersion returns 0 on non-macOS platforms. +func MacOSMajorVersion() int { + return 0 +} diff --git a/internal/studio/apple_container_parse_test.go b/internal/studio/apple_container_parse_test.go new file mode 100644 index 0000000..e6c9547 --- /dev/null +++ b/internal/studio/apple_container_parse_test.go @@ -0,0 +1,80 @@ +//go:build darwin + +package studio + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Apple container parsing", func() { + It("maps container list JSON to Environment and SSH port", func() { + output := []byte(`[ + { + "status": "running", + "configuration": { + "id": "ggo-test-env-1234", + "image": {"reference": "alpine:latest"}, + "labels": { + "ggo.managed": "true", + "ggo.name": "test-env", + "ggo.mode": "apple-container" + }, + "initProcess": { + "environment": [ + "TENSOR_FUSION_OPERATOR_CONNECTION_INFO=https://worker.example.com:9001", + "FOO=bar" + ] + }, + "publishedPorts": [ + {"hostPort": 18022, "containerPort": 22, "proto": "tcp", "count": 1} + ] + }, + "networks": [] + } +]`) + + envs, err := parseAppleContainerList(output) + Expect(err).NotTo(HaveOccurred()) + Expect(envs).To(HaveLen(1)) + + env := envs[0] + Expect(env.Name).To(Equal("test-env")) + Expect(env.Image).To(Equal("alpine:latest")) + Expect(env.Status).To(Equal(StatusRunning)) + Expect(env.SSHPort).To(Equal(18022)) + Expect(env.GPUWorkerURL).To(Equal("https://worker.example.com:9001")) + }) + + It("filters out containers without ggo.managed label", func() { + output := []byte(`[ + { + "status": "running", + "configuration": { + "id": "ggo-managed", + "image": {"reference": "alpine:latest"}, + "labels": {"ggo.managed": "true"}, + "initProcess": {"environment": []}, + "publishedPorts": [] + }, + "networks": [] + }, + { + "status": "running", + "configuration": { + "id": "user-container", + "image": {"reference": "alpine:latest"}, + "labels": {"owner": "user"}, + "initProcess": {"environment": []}, + "publishedPorts": [] + }, + "networks": [] + } +]`) + + envs, err := parseAppleContainerList(output) + Expect(err).NotTo(HaveOccurred()) + Expect(envs).To(HaveLen(1)) + Expect(envs[0].ID).To(Equal("ggo-managed")) + }) +}) diff --git a/internal/studio/backend_apple.go b/internal/studio/backend_apple.go index 702f72e..9724dcc 100644 --- a/internal/studio/backend_apple.go +++ b/internal/studio/backend_apple.go @@ -5,24 +5,29 @@ import ( "context" "encoding/json" "fmt" + "math" + "os" "os/exec" "runtime" "strconv" "strings" "time" + + "github.com/NexusGPU/gpu-go/internal/errors" + "github.com/NexusGPU/gpu-go/internal/platform" ) -// AppleContainerBackend implements the Backend interface using Apple native containers -// This uses Docker CLI with the assumption that Docker Desktop or similar is running -// on macOS using Apple Virtualization Framework +const appleContainerInstallHint = "Apple Container is not installed. Download the signed installer package from https://github.com/apple/container/releases" + +// AppleContainerBackend implements the Backend interface using Apple native containers. type AppleContainerBackend struct { - dockerCmd string + containerCmd string } -// NewAppleContainerBackend creates a new Apple container backend +// NewAppleContainerBackend creates a new Apple container backend. func NewAppleContainerBackend() *AppleContainerBackend { return &AppleContainerBackend{ - dockerCmd: "docker", + containerCmd: "container", } } @@ -35,35 +40,67 @@ func (b *AppleContainerBackend) Mode() Mode { } func (b *AppleContainerBackend) IsAvailable(ctx context.Context) bool { - // Only available on macOS - if runtime.GOOS != OSDarwin { + if !b.isSupportedOS() { + return false + } + if _, err := exec.LookPath(b.containerCmd); err != nil { return false } - // Check if Docker is available - cmd := exec.CommandContext(ctx, b.dockerCmd, "info") - output, err := cmd.CombinedOutput() - if err != nil { + cmd := exec.CommandContext(ctx, b.containerCmd, "system", "status") + return cmd.Run() == nil +} + +func (b *AppleContainerBackend) IsInstalled(ctx context.Context) bool { + if !b.isSupportedOS() { return false } + _, err := exec.LookPath(b.containerCmd) + return err == nil +} + +func (b *AppleContainerBackend) EnsureRunning(ctx context.Context) error { + if !b.isSupportedOS() { + return errors.Unavailable("Apple Container requires macOS 26 or newer. Please upgrade your macOS version.") + } + if !b.IsInstalled(ctx) { + return errors.Unavailable(appleContainerInstallHint) + } + if b.IsAvailable(ctx) { + return nil + } - // Check if it's using Apple Virtualization Framework or native macOS - outputStr := string(output) - return strings.Contains(outputStr, "Operating System") && - (strings.Contains(outputStr, "macOS") || strings.Contains(outputStr, "Darwin")) + fmt.Fprintf(os.Stderr, "\n Starting Apple Container services...\n") + cmd := exec.CommandContext(ctx, b.containerCmd, "system", "start") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to start Apple Container services: %w", err) + } + fmt.Fprintf(os.Stderr, "\n Apple Container services started!\n\n") + return nil } func (b *AppleContainerBackend) Create(ctx context.Context, opts *CreateOptions) (*Environment, error) { + if err := b.EnsureRunning(ctx); err != nil { + return nil, err + } + // Generate container name with random suffix to avoid conflicts containerName := GenerateContainerName(opts.Name) - // Build docker run command - args := []string{"run", "-d", "--name", containerName} + // Build container run command + args := []string{"run", "--detach", "--name", containerName} + + // Add platform if specified + if opts.Platform != "" { + args = append(args, "--platform", opts.Platform) + } // Add labels args = append(args, "--label", "ggo.managed=true") args = append(args, "--label", fmt.Sprintf("ggo.name=%s", opts.Name)) - args = append(args, "--label", "ggo.backend=apple-container") + args = append(args, "--label", fmt.Sprintf("ggo.mode=%s", b.Mode())) // Add port mappings sshPort := 0 @@ -91,12 +128,12 @@ func (b *AppleContainerBackend) Create(ctx context.Context, opts *CreateOptions) } // Setup container GPU environment using common abstraction - // This downloads GPU client libraries and sets up env vars, volumes setupConfig := &ContainerSetupConfig{ StudioName: opts.Name, GPUWorkerURL: gpuWorkerURL, HardwareVendor: opts.HardwareVendor, MountUserHome: !opts.NoUserVolume, + SkipFileMounts: true, } setupResult, err := SetupContainerGPUEnv(ctx, setupConfig) @@ -120,9 +157,13 @@ func (b *AppleContainerBackend) Create(ctx context.Context, opts *CreateOptions) args = append(args, "-v", mountOpt) } - // Add resource limits (macOS-specific optimizations) + // Add resource limits if opts.Resources.CPUs > 0 { - args = append(args, "--cpus", fmt.Sprintf("%.2f", opts.Resources.CPUs)) + cpus := int64(math.Ceil(opts.Resources.CPUs)) + if cpus < 1 { + cpus = 1 + } + args = append(args, "--cpus", strconv.FormatInt(cpus, 10)) } // Set default memory to 1/4 of system memory if not specified @@ -130,26 +171,23 @@ func (b *AppleContainerBackend) Create(ctx context.Context, opts *CreateOptions) if memoryLimit == "" { memGB, err := getSystemMemoryGB() if err == nil && memGB > 0 { - // Calculate 1/4 of system memory (round up) - memAllocated := (memGB + 3) / 4 // Round up division + memAllocated := (memGB + 3) / 4 if memAllocated < 1 { - memAllocated = 1 // Minimum 1GB + memAllocated = 1 } - memoryLimit = fmt.Sprintf("%dg", memAllocated) + memoryLimit = fmt.Sprintf("%dG", memAllocated) } } + memoryLimit = normalizeContainerMemory(memoryLimit) if memoryLimit != "" { args = append(args, "--memory", memoryLimit) } // Add working directory if opts.WorkDir != "" { - args = append(args, "-w", opts.WorkDir) + args = append(args, "--workdir", opts.WorkDir) } - // Restart policy for better reliability - args = append(args, "--restart", "unless-stopped") - // Add image image := opts.Image if image == "" { @@ -158,14 +196,11 @@ func (b *AppleContainerBackend) Create(ctx context.Context, opts *CreateOptions) args = append(args, image) // Add command args (supplements ENTRYPOINT or overrides CMD) - // FormatContainerCommand handles wrapping single shell commands with "sh -c" if formattedCmd := FormatContainerCommand(opts.Command); len(formattedCmd) > 0 { args = append(args, formattedCmd...) } - // Run container - fmt.Printf("Creating Apple container with command: %s %s\n", b.dockerCmd, strings.Join(args, " ")) - cmd := exec.CommandContext(ctx, b.dockerCmd, args...) + cmd := exec.CommandContext(ctx, b.containerCmd, args...) output, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("failed to create container: %w, output: %s", err, string(output)) @@ -173,14 +208,13 @@ func (b *AppleContainerBackend) Create(ctx context.Context, opts *CreateOptions) containerID := strings.TrimSpace(string(output)) - // Get container info env := &Environment{ - ID: containerID[:12], + ID: containerID, Name: opts.Name, Mode: ModeAppleContainer, Image: image, Status: StatusRunning, - SSHHost: "localhost", + SSHHost: DefaultHostLocalhost, SSHPort: sshPort, SSHUser: "root", GPUWorkerURL: gpuWorkerURL, @@ -192,7 +226,7 @@ func (b *AppleContainerBackend) Create(ctx context.Context, opts *CreateOptions) } func (b *AppleContainerBackend) Start(ctx context.Context, envID string) error { - cmd := exec.CommandContext(ctx, b.dockerCmd, "start", envID) + cmd := exec.CommandContext(ctx, b.containerCmd, "start", envID) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to start container: %w, output: %s", err, string(output)) @@ -201,7 +235,7 @@ func (b *AppleContainerBackend) Start(ctx context.Context, envID string) error { } func (b *AppleContainerBackend) Stop(ctx context.Context, envID string) error { - cmd := exec.CommandContext(ctx, b.dockerCmd, "stop", envID) + cmd := exec.CommandContext(ctx, b.containerCmd, "stop", envID) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to stop container: %w, output: %s", err, string(output)) @@ -210,10 +244,7 @@ func (b *AppleContainerBackend) Stop(ctx context.Context, envID string) error { } func (b *AppleContainerBackend) Remove(ctx context.Context, envID string) error { - // Stop first - _ = b.Stop(ctx, envID) - - cmd := exec.CommandContext(ctx, b.dockerCmd, "rm", "-f", envID) + cmd := exec.CommandContext(ctx, b.containerCmd, "delete", "--force", envID) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to remove container: %w, output: %s", err, string(output)) @@ -222,163 +253,33 @@ func (b *AppleContainerBackend) Remove(ctx context.Context, envID string) error } func (b *AppleContainerBackend) List(ctx context.Context) ([]*Environment, error) { - cmd := exec.CommandContext(ctx, b.dockerCmd, "ps", "-a", - "--filter", "label=ggo.managed=true", - "--filter", "label=ggo.backend=apple-container", - "--format", "{{json .}}") - + cmd := exec.CommandContext(ctx, b.containerCmd, "list", "--all", "--format", "json") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to list containers: %w", err) } - var envs []*Environment - for _, line := range strings.Split(string(output), "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - var container struct { - ID string `json:"ID"` - Names string `json:"Names"` - Image string `json:"Image"` - State string `json:"State"` - Status string `json:"Status"` - Ports string `json:"Ports"` - Labels string `json:"Labels"` - Created string `json:"CreatedAt"` - } - - if err := json.Unmarshal([]byte(line), &container); err != nil { - continue - } - - // Parse name - name := strings.TrimPrefix(container.Names, "ggo-") - - // Parse SSH port from ports - sshPort := parseSSHPort(container.Ports) - - // Parse status - status := StatusStopped - switch container.State { - case DockerStateRunning: - status = StatusRunning - case DockerStateExited: - status = StatusStopped - case DockerStateCreated: - status = StatusPending - } - - env := &Environment{ - ID: container.ID, - Name: name, - Mode: ModeAppleContainer, - Image: container.Image, - Status: status, - SSHHost: "localhost", - SSHPort: sshPort, - SSHUser: "root", - } - - envs = append(envs, env) - } - - return envs, nil + return parseAppleContainerList(output) } func (b *AppleContainerBackend) Get(ctx context.Context, idOrName string) (*Environment, error) { - // Try to find by container ID or name - containerName := idOrName - if !strings.HasPrefix(idOrName, "ggo-") { - containerName = "ggo-" + idOrName - } - - cmd := exec.CommandContext(ctx, b.dockerCmd, "inspect", containerName) + cmd := exec.CommandContext(ctx, b.containerCmd, "inspect", idOrName) output, err := cmd.Output() if err != nil { - // Try with original name/ID - cmd = exec.CommandContext(ctx, b.dockerCmd, "inspect", idOrName) - output, err = cmd.Output() - if err != nil { - return nil, fmt.Errorf("environment not found: %s", idOrName) - } + return nil, fmt.Errorf("environment not found: %s", idOrName) } - var containers []struct { - ID string `json:"Id"` - Name string `json:"Name"` - State struct { - Status string `json:"Status"` - } `json:"State"` - Config struct { - Image string `json:"Image"` - Labels map[string]string `json:"Labels"` - Env []string `json:"Env"` - } `json:"Config"` - NetworkSettings struct { - Ports map[string][]struct { - HostPort string `json:"HostPort"` - } `json:"Ports"` - } `json:"NetworkSettings"` - } - - if err := json.Unmarshal(output, &containers); err != nil { + var snapshots []appleContainerSnapshot + if err := json.Unmarshal(output, &snapshots); err != nil { return nil, fmt.Errorf("failed to parse container info: %w", err) } - - if len(containers) == 0 { + if len(snapshots) == 0 { return nil, fmt.Errorf("environment not found: %s", idOrName) } - c := containers[0] - - // Parse name - name := strings.TrimPrefix(strings.TrimPrefix(c.Name, "/"), "ggo-") - if labelName, ok := c.Config.Labels["ggo.name"]; ok { - name = labelName - } - - // Parse SSH port - sshPort := 22 - if ports, ok := c.NetworkSettings.Ports["22/tcp"]; ok && len(ports) > 0 { - if p, err := strconv.Atoi(ports[0].HostPort); err == nil { - sshPort = p - } - } - - // Parse GPU worker URL from env - gpuWorkerURL := "" - for _, env := range c.Config.Env { - if strings.HasPrefix(env, "GPU_WORKER_URL=") { - gpuWorkerURL = strings.TrimPrefix(env, "GPU_WORKER_URL=") - break - } - } - - // Parse status - status := StatusStopped - switch c.State.Status { - case DockerStateRunning: - status = StatusRunning - case DockerStateExited: - status = StatusStopped - case DockerStateCreated: - status = StatusPending - } - - env := &Environment{ - ID: c.ID[:12], - Name: name, - Mode: ModeAppleContainer, - Image: c.Config.Image, - Status: status, - SSHHost: "localhost", - SSHPort: sshPort, - SSHUser: "root", - GPUWorkerURL: gpuWorkerURL, - Labels: c.Config.Labels, + env, ok := appleContainerToEnvironment(snapshots[0]) + if !ok { + return nil, errors.NotFound("environment", idOrName) } return env, nil @@ -386,19 +287,18 @@ func (b *AppleContainerBackend) Get(ctx context.Context, idOrName string) (*Envi func (b *AppleContainerBackend) Exec(ctx context.Context, envID string, cmd []string) ([]byte, error) { args := append([]string{"exec", envID}, cmd...) - execCmd := exec.CommandContext(ctx, b.dockerCmd, args...) + execCmd := exec.CommandContext(ctx, b.containerCmd, args...) return execCmd.CombinedOutput() } func (b *AppleContainerBackend) Logs(ctx context.Context, envID string, follow bool) (<-chan string, error) { args := []string{"logs"} if follow { - args = append(args, "-f") + args = append(args, "--follow") } args = append(args, envID) - cmd := exec.CommandContext(ctx, b.dockerCmd, args...) - + cmd := exec.CommandContext(ctx, b.containerCmd, args...) stdout, err := cmd.StdoutPipe() if err != nil { return nil, err @@ -427,7 +327,172 @@ func (b *AppleContainerBackend) Logs(ctx context.Context, envID string, follow b return logCh, nil } +func (b *AppleContainerBackend) isSupportedOS() bool { + if runtime.GOOS != OSDarwin { + return false + } + major := platform.MacOSMajorVersion() + return major >= 26 +} + +type appleContainerSnapshot struct { + Status string `json:"status"` + Configuration appleContainerConfig `json:"configuration"` + Networks []appleContainerNetwork `json:"networks"` +} + +type appleContainerConfig struct { + ID string `json:"id"` + Image appleContainerImage `json:"image"` + Labels map[string]string `json:"labels"` + InitProcess appleContainerProcess `json:"initProcess"` + PublishedPorts []appleContainerPublishedPort `json:"publishedPorts"` +} + +type appleContainerImage struct { + Reference string `json:"reference"` +} + +type appleContainerProcess struct { + Environment []string `json:"environment"` +} + +type appleContainerPublishedPort struct { + HostPort uint16 `json:"hostPort"` + ContainerPort uint16 `json:"containerPort"` + Proto string `json:"proto"` + Count uint16 `json:"count"` +} + +type appleContainerNetwork struct { + IPv4Address string `json:"ipv4Address"` +} + +func parseAppleContainerList(output []byte) ([]*Environment, error) { + var snapshots []appleContainerSnapshot + if err := json.Unmarshal(output, &snapshots); err != nil { + return nil, fmt.Errorf("failed to parse container list: %w", err) + } + + var envs []*Environment + for _, snapshot := range snapshots { + env, ok := appleContainerToEnvironment(snapshot) + if !ok { + continue + } + envs = append(envs, env) + } + + return envs, nil +} + +func appleContainerToEnvironment(snapshot appleContainerSnapshot) (*Environment, bool) { + labels := snapshot.Configuration.Labels + if labels["ggo.managed"] != "true" { + return nil, false + } + if modeLabel, ok := labels["ggo.mode"]; ok && modeLabel != string(ModeAppleContainer) { + if modeLabel != "apple-container" { + return nil, false + } + } + + name := strings.TrimPrefix(snapshot.Configuration.ID, "ggo-") + if labelName := labels["ggo.name"]; labelName != "" { + name = labelName + } + + sshPort := extractSSHPort(snapshot.Configuration.PublishedPorts) + gpuWorkerURL := extractGPUWorkerURL(snapshot.Configuration.InitProcess.Environment) + + var status EnvironmentStatus + switch snapshot.Status { + case "running": + status = StatusRunning + case "stopped": + status = StatusStopped + case "stopping": + status = StatusPending + default: + status = StatusPending + } + + env := &Environment{ + ID: snapshot.Configuration.ID, + Name: name, + Mode: ModeAppleContainer, + Image: snapshot.Configuration.Image.Reference, + Status: status, + SSHHost: DefaultHostLocalhost, + SSHPort: sshPort, + SSHUser: "root", + GPUWorkerURL: gpuWorkerURL, + Labels: labels, + } + + return env, true +} + +func extractGPUWorkerURL(envs []string) string { + prefixes := []string{ + "TENSOR_FUSION_OPERATOR_CONNECTION_INFO=", + "GPU_GO_CONNECTION_URL=", + "GPU_WORKER_URL=", + } + for _, env := range envs { + for _, prefix := range prefixes { + if strings.HasPrefix(env, prefix) { + return strings.TrimPrefix(env, prefix) + } + } + } + return "" +} + +func extractSSHPort(published []appleContainerPublishedPort) int { + for _, port := range published { + if port.Proto != "" && !strings.EqualFold(port.Proto, DefaultProtocolTCP) { + continue + } + count := port.Count + if count == 0 { + count = 1 + } + for offset := uint16(0); offset < count; offset++ { + if port.ContainerPort+offset == 22 { + return int(port.HostPort + offset) + } + } + } + return 22 +} + +func normalizeContainerMemory(memory string) string { + if memory == "" { + return "" + } + replacements := map[string]string{ + "Ki": "K", + "Mi": "M", + "Gi": "G", + "Ti": "T", + "Pi": "P", + "ki": "K", + "mi": "M", + "gi": "G", + "ti": "T", + "pi": "P", + } + for oldSuffix, newSuffix := range replacements { + if strings.HasSuffix(memory, oldSuffix) { + return strings.TrimSuffix(memory, oldSuffix) + newSuffix + } + } + return memory +} + var _ Backend = (*AppleContainerBackend)(nil) +var _ AutoStartableBackend = (*AppleContainerBackend)(nil) func init() { _ = bytes.Buffer{} // silence import diff --git a/internal/studio/backend_docker.go b/internal/studio/backend_docker.go index 556bae7..12f15be 100644 --- a/internal/studio/backend_docker.go +++ b/internal/studio/backend_docker.go @@ -463,11 +463,11 @@ func (b *DockerBackend) Get(ctx context.Context, idOrName string) (*Environment, // Parse status status := StatusStopped switch c.State.Status { - case "running": + case DockerStateRunning: status = StatusRunning - case "exited": + case DockerStateExited: status = StatusStopped - case "created": + case DockerStateCreated: status = StatusPending } diff --git a/internal/studio/container_setup.go b/internal/studio/container_setup.go index 2390781..9f4b2fb 100644 --- a/internal/studio/container_setup.go +++ b/internal/studio/container_setup.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "github.com/NexusGPU/gpu-go/internal/deps" "github.com/NexusGPU/gpu-go/internal/platform" @@ -22,6 +23,10 @@ type ContainerSetupConfig struct { HardwareVendor string // MountUserHome indicates whether to mount the user's home directory MountUserHome bool + // SkipSSHMounts disables mounting SSH key files into the container + SkipSSHMounts bool + // SkipFileMounts disables mounting files into the container (directories only) + SkipFileMounts bool // UserHomeContainerPath is the path to mount user home in container (default: /home/user/host) UserHomeContainerPath string } @@ -82,6 +87,13 @@ func SetupContainerGPUEnv(ctx context.Context, config *ContainerSetupConfig) (*C result.EnvVars[k] = v } + if config.SkipFileMounts { + result.EnvVars["LD_LIBRARY_PATH"] = "/opt/gpugo/libs" + if preload := buildContainerLDPreload(paths.LibsDir(), vendor); preload != "" { + result.EnvVars["LD_PRELOAD"] = preload + } + } + // Copy volume mounts from GPU setup result.VolumeMounts = append(result.VolumeMounts, envResult.VolumeMounts...) @@ -107,14 +119,21 @@ func SetupContainerGPUEnv(ctx context.Context, config *ContainerSetupConfig) (*C } // Step 4: Setup SSH volume mounts for container SSH access - sshMounts := getSSHVolumeMounts(paths, config.StudioName) - result.VolumeMounts = append(result.VolumeMounts, sshMounts...) - if len(sshMounts) > 0 { - klog.V(2).Infof("SSH mounts configured for studio %s: %d mounts", config.StudioName, len(sshMounts)) + if !config.SkipSSHMounts { + var sshMounts []VolumeMount + if config.SkipFileMounts { + sshMounts = getSSHDirectoryMounts(paths, config.StudioName) + } else { + sshMounts = getSSHVolumeMounts(paths, config.StudioName) + } + result.VolumeMounts = append(result.VolumeMounts, sshMounts...) + if len(sshMounts) > 0 { + klog.V(2).Infof("SSH mounts configured for studio %s: %d mounts", config.StudioName, len(sshMounts)) + } } // Step 5: Download and mount GPU binary (like nvidia-smi) to /usr/local/bin/ - if config.GPUWorkerURL != "" && config.HardwareVendor != "" { + if !config.SkipFileMounts && config.GPUWorkerURL != "" && config.HardwareVendor != "" { gpuBinMount, err := ensureAndMountGPUBinary(ctx, paths, config.HardwareVendor) if err != nil { klog.Warningf("Failed to setup GPU binary mount: %v (continuing without it)", err) @@ -124,6 +143,10 @@ func SetupContainerGPUEnv(ctx context.Context, config *ContainerSetupConfig) (*C } } + if config.SkipFileMounts { + result.VolumeMounts = filterDirectoryMounts(result.VolumeMounts) + } + return result, nil } @@ -188,6 +211,37 @@ func ensureGPUClientLibraries(ctx context.Context, vendor GPUVendor) error { return nil } +func buildContainerLDPreload(libsPath string, vendor GPUVendor) string { + libNames := FindActualLibraryFiles(libsPath, vendor) + if len(libNames) == 0 { + return "" + } + + preloadPaths := make([]string, 0, len(libNames)) + for _, lib := range libNames { + preloadPaths = append(preloadPaths, filepath.Join("/opt/gpugo/libs", lib)) + } + + return strings.Join(preloadPaths, ":") +} + +func filterDirectoryMounts(mounts []VolumeMount) []VolumeMount { + filtered := make([]VolumeMount, 0, len(mounts)) + for _, mount := range mounts { + info, err := os.Stat(mount.HostPath) + if err != nil { + klog.V(2).Infof("Skipping mount %s: %v", mount.HostPath, err) + continue + } + if !info.IsDir() { + klog.V(2).Infof("Skipping file mount %s", mount.HostPath) + continue + } + filtered = append(filtered, mount) + } + return filtered +} + // getUserHomeMount returns the volume mount for user's home directory func getUserHomeMount(containerPath string) (*VolumeMount, error) { homeDir, err := os.UserHomeDir() @@ -333,6 +387,47 @@ func setupSSHAuthorizedKeys(paths *platform.Paths, studioName string) (string, e return authorizedKeysPath, nil } +// setupSSHAuthorizedKeysDir creates an authorized_keys file inside a dedicated directory +func setupSSHAuthorizedKeysDir(paths *platform.Paths, studioName string) (string, error) { + publicKey, _, err := findUserSSHPublicKey() + if err != nil { + return "", err + } + + normalizedName := platform.NormalizeName(studioName) + sshDir := filepath.Join(paths.StudioConfigDir(normalizedName), "ssh") + if err := os.MkdirAll(sshDir, 0700); err != nil { + return "", fmt.Errorf("failed to create directory for authorized_keys: %w", err) + } + + authorizedKeysPath := filepath.Join(sshDir, "authorized_keys") + if err := os.WriteFile(authorizedKeysPath, []byte(publicKey), 0600); err != nil { + return "", fmt.Errorf("failed to write authorized_keys: %w", err) + } + + klog.V(2).Infof("Created authorized_keys directory for studio %s: %s", studioName, authorizedKeysPath) + return authorizedKeysPath, nil +} + +// getSSHDirectoryMounts returns volume mounts for SSH access using directory mounts only +func getSSHDirectoryMounts(paths *platform.Paths, studioName string) []VolumeMount { + var mounts []VolumeMount + + authorizedKeysPath, err := setupSSHAuthorizedKeysDir(paths, studioName) + if err != nil { + klog.V(2).Infof("Could not setup authorized_keys: %v (SSH access to container may require password)", err) + return mounts + } + + mounts = append(mounts, VolumeMount{ + HostPath: filepath.Dir(authorizedKeysPath), + ContainerPath: "/root/.ssh", + ReadOnly: false, + }) + + return mounts +} + // getSSHVolumeMounts returns volume mounts for SSH access to the container // This includes: // 1. Individual SSH key files mounted to /root/.ssh/ (for git operations etc.) diff --git a/internal/studio/container_setup_ginkgo_test.go b/internal/studio/container_setup_ginkgo_test.go new file mode 100644 index 0000000..31213b9 --- /dev/null +++ b/internal/studio/container_setup_ginkgo_test.go @@ -0,0 +1,105 @@ +package studio + +import ( + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Container setup SSH mounts", func() { + It("skips SSH mounts when configured", func() { + tempDir := GinkgoT().TempDir() + sshDir := filepath.Join(tempDir, ".ssh") + Expect(os.MkdirAll(sshDir, 0700)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(sshDir, "id_ed25519.pub"), []byte("ssh-ed25519 AAAATEST"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(sshDir, "id_ed25519"), []byte("PRIVATE"), 0600)).To(Succeed()) + + oldHome := os.Getenv("HOME") + Expect(os.Setenv("HOME", tempDir)).To(Succeed()) + DeferCleanup(func() { + _ = os.Setenv("HOME", oldHome) + }) + + result, err := SetupContainerGPUEnv(GinkgoT().Context(), &ContainerSetupConfig{ + StudioName: "ssh-skip-test", + MountUserHome: false, + SkipSSHMounts: true, + GPUWorkerURL: "", + HardwareVendor: "", + }) + Expect(err).NotTo(HaveOccurred()) + + for _, vol := range result.VolumeMounts { + Expect(vol.ContainerPath).NotTo(HavePrefix("/root/.ssh/")) + } + }) + + It("includes SSH mounts by default when keys are present", func() { + tempDir := GinkgoT().TempDir() + sshDir := filepath.Join(tempDir, ".ssh") + Expect(os.MkdirAll(sshDir, 0700)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(sshDir, "id_ed25519.pub"), []byte("ssh-ed25519 AAAATEST"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(sshDir, "id_ed25519"), []byte("PRIVATE"), 0600)).To(Succeed()) + + oldHome := os.Getenv("HOME") + Expect(os.Setenv("HOME", tempDir)).To(Succeed()) + DeferCleanup(func() { + _ = os.Setenv("HOME", oldHome) + }) + + result, err := SetupContainerGPUEnv(GinkgoT().Context(), &ContainerSetupConfig{ + StudioName: "ssh-default-test", + MountUserHome: false, + GPUWorkerURL: "", + HardwareVendor: "", + }) + Expect(err).NotTo(HaveOccurred()) + + found := false + for _, vol := range result.VolumeMounts { + if strings.HasPrefix(vol.ContainerPath, "/root/.ssh/") { + found = true + break + } + } + Expect(found).To(BeTrue()) + }) + + It("mounts SSH directory without file mounts when configured", func() { + tempDir := GinkgoT().TempDir() + sshDir := filepath.Join(tempDir, ".ssh") + Expect(os.MkdirAll(sshDir, 0700)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(sshDir, "id_ed25519.pub"), []byte("ssh-ed25519 AAAATEST"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(sshDir, "id_ed25519"), []byte("PRIVATE"), 0600)).To(Succeed()) + + oldHome := os.Getenv("HOME") + Expect(os.Setenv("HOME", tempDir)).To(Succeed()) + DeferCleanup(func() { + _ = os.Setenv("HOME", oldHome) + }) + + result, err := SetupContainerGPUEnv(GinkgoT().Context(), &ContainerSetupConfig{ + StudioName: "file-mount-test", + MountUserHome: false, + SkipFileMounts: true, + GPUWorkerURL: "", + HardwareVendor: "", + }) + Expect(err).NotTo(HaveOccurred()) + + foundSSHDir := false + for _, vol := range result.VolumeMounts { + info, err := os.Stat(vol.HostPath) + Expect(err).NotTo(HaveOccurred()) + Expect(info.IsDir()).To(BeTrue()) + if vol.ContainerPath == "/root/.ssh" { + foundSSHDir = true + } + Expect(vol.ContainerPath).NotTo(HavePrefix("/root/.ssh/")) + } + Expect(foundSSHDir).To(BeTrue()) + }) +}) diff --git a/internal/studio/manager.go b/internal/studio/manager.go index 9cb6110..995b229 100644 --- a/internal/studio/manager.go +++ b/internal/studio/manager.go @@ -47,6 +47,12 @@ func (m *Manager) GetBackend(mode Mode) (Backend, error) { return m.detectBestBackend() } + if mode == ModeAppleContainer && runtime.GOOS == OSDarwin { + if major := platform.MacOSMajorVersion(); major > 0 && major < 26 { + return nil, errors.Unavailable("Apple Container requires macOS 26 or newer. Please upgrade your macOS version.") + } + } + backend, ok := m.backends[mode] if !ok { return nil, errors.NotFoundf("backend not registered for mode: %s", mode) @@ -64,7 +70,16 @@ func (m *Manager) detectBestBackend() (Backend, error) { case OSWindows: preferenceOrder = []Mode{ModeWSL, ModeDocker, ModeKubernetes} case OSDarwin: - preferenceOrder = []Mode{ModeColima, ModeAppleContainer, ModeDocker, ModeKubernetes} + macMajor := platform.MacOSMajorVersion() + if macMajor >= 26 { + if platform.HasDockerSocket() { + preferenceOrder = []Mode{ModeColima, ModeDocker, ModeAppleContainer, ModeKubernetes} + } else { + preferenceOrder = []Mode{ModeAppleContainer, ModeColima, ModeDocker, ModeKubernetes} + } + } else { + preferenceOrder = []Mode{ModeColima, ModeDocker, ModeKubernetes} + } case OSLinux: preferenceOrder = []Mode{ModeDocker, ModeColima, ModeKubernetes} default: @@ -106,17 +121,21 @@ func platformBackendHint(ctx context.Context, goos string) string { } switch goos { case "darwin": - // Check if colima is installed - if _, err := exec.LookPath("colima"); err != nil { - // Colima is not installed, provide installation command - // Check if brew is available - if _, err := exec.LookPath("brew"); err == nil { - return "Colima is not installed. Install it with: brew install colima" - } - return "Colima is not installed. Install Homebrew first (https://brew.sh/), then run: brew install colima" + macMajor := platform.MacOSMajorVersion() + appleHint := "Apple Container (macOS 26+): download the signed installer pkg from https://github.com/apple/container/releases" + if macMajor > 0 && macMajor < 26 { + appleHint = "Apple Container requires macOS 26+ (upgrade your macOS to use it)" } - // Colima is installed but not running, or other backends are preferred - return "Install and start Colima, Docker, or Apple Container runtime." + colimaHint := "Colima: brew install colima" + orbstackHint := "OrbStack: brew install orbstack" + dockerHint := "Docker Desktop: https://docs.docker.com/get-docker/" + return strings.Join([]string{ + "No container runtime detected on macOS.", + appleHint + ".", + colimaHint + ".", + orbstackHint + ".", + dockerHint + ".", + }, " ") case "windows": return "Install and start WSL or Docker Desktop." default: diff --git a/internal/studio/manager_apple_ginkgo_test.go b/internal/studio/manager_apple_ginkgo_test.go new file mode 100644 index 0000000..b07512b --- /dev/null +++ b/internal/studio/manager_apple_ginkgo_test.go @@ -0,0 +1,130 @@ +//go:build darwin + +package studio + +import ( + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Apple container selection", func() { + It("prefers apple-container on macOS 26+ when no Docker socket is present", func() { + if runtime.GOOS != OSDarwin { + Skip("darwin only") + } + if currentMacOSMajorVersion() < 26 { + Skip("macOS 26+ only") + } + if testDockerSocketExists() { + Skip("docker socket present on host") + } + Expect(os.Unsetenv("DOCKER_HOST")).To(Succeed()) + + m := NewManager() + m.RegisterBackend(&MockBackend{mode: ModeAppleContainer, available: true}) + m.RegisterBackend(&MockBackend{mode: ModeDocker, available: true}) + + backend, err := m.GetBackend(ModeAuto) + Expect(err).NotTo(HaveOccurred()) + Expect(backend.Mode()).To(Equal(ModeAppleContainer)) + }) + + It("prefers docker when Docker socket exists on macOS 26+", func() { + if runtime.GOOS != OSDarwin { + Skip("darwin only") + } + if currentMacOSMajorVersion() < 26 { + Skip("macOS 26+ only") + } + + tempDir := GinkgoT().TempDir() + sockPath := filepath.Join(tempDir, "docker.sock") + listener, err := net.Listen("unix", sockPath) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { + _ = listener.Close() + _ = os.Remove(sockPath) + }) + Expect(os.Setenv("DOCKER_HOST", "unix://"+sockPath)).To(Succeed()) + DeferCleanup(func() { + _ = os.Unsetenv("DOCKER_HOST") + }) + + m := NewManager() + m.RegisterBackend(&MockBackend{mode: ModeAppleContainer, available: true}) + m.RegisterBackend(&MockBackend{mode: ModeDocker, available: true}) + + backend, err := m.GetBackend(ModeAuto) + Expect(err).NotTo(HaveOccurred()) + Expect(backend.Mode()).To(Equal(ModeDocker)) + }) + + It("rejects apple-container on macOS < 26 when explicitly requested", func() { + if runtime.GOOS != OSDarwin { + Skip("darwin only") + } + if currentMacOSMajorVersion() >= 26 { + Skip("macOS < 26 only") + } + + m := NewManager() + m.RegisterBackend(&MockBackend{mode: ModeAppleContainer, available: true}) + + _, err := m.GetBackend(ModeAppleContainer) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("macOS 26")) + }) +}) + +func currentMacOSMajorVersion() int { + version, err := syscall.Sysctl("kern.osproductversion") + if err != nil { + return 0 + } + parts := strings.Split(strings.TrimSpace(version), ".") + if len(parts) == 0 { + return 0 + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return 0 + } + return major +} + +func testDockerSocketExists() bool { + if dockerHost := os.Getenv("DOCKER_HOST"); dockerHost != "" { + if strings.HasPrefix(dockerHost, "unix://") { + if _, err := os.Stat(strings.TrimPrefix(dockerHost, "unix://")); err == nil { + return true + } + } + } + if _, err := os.Stat("/var/run/docker.sock"); err == nil { + return true + } + homeDir, err := os.UserHomeDir() + if err == nil { + colimaGlob := filepath.Join(homeDir, ".colima", "*", "docker.sock") + if matches, err := filepath.Glob(colimaGlob); err == nil { + for _, match := range matches { + if _, err := os.Stat(match); err == nil { + return true + } + } + } + orbstackSock := filepath.Join(homeDir, ".orbstack", "run", "docker.sock") + if _, err := os.Stat(orbstackSock); err == nil { + return true + } + } + return false +} diff --git a/internal/studio/studio_suite_test.go b/internal/studio/studio_suite_test.go new file mode 100644 index 0000000..f85262b --- /dev/null +++ b/internal/studio/studio_suite_test.go @@ -0,0 +1,15 @@ +//go:build darwin + +package studio + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestStudioGinkgo(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Studio Suite") +} diff --git a/internal/studio/types.go b/internal/studio/types.go index 360bf61..7645e58 100644 --- a/internal/studio/types.go +++ b/internal/studio/types.go @@ -14,12 +14,12 @@ import ( type Mode string const ( - ModeWSL Mode = "wsl" // Windows Subsystem for Linux - ModeColima Mode = "colima" // Colima (macOS/Linux) - ModeAppleContainer Mode = "apple" // Apple Virtualization Framework - ModeDocker Mode = "docker" // Native Docker - ModeKubernetes Mode = "k8s" // Kubernetes (kind, minikube, etc.) - ModeAuto Mode = "auto" // Auto-detect best option + ModeWSL Mode = "wsl" // Windows Subsystem for Linux + ModeColima Mode = "colima" // Colima (macOS/Linux) + ModeAppleContainer Mode = "apple-container" // Apple Container (macOS) + ModeDocker Mode = "docker" // Native Docker + ModeKubernetes Mode = "k8s" // Kubernetes (kind, minikube, etc.) + ModeAuto Mode = "auto" // Auto-detect best option ) // StudioImage represents a pre-configured AI development image diff --git a/vscode-extension/src/views/createStudioPanel.ts b/vscode-extension/src/views/createStudioPanel.ts index d154fe8..1ea6963 100644 --- a/vscode-extension/src/views/createStudioPanel.ts +++ b/vscode-extension/src/views/createStudioPanel.ts @@ -140,7 +140,7 @@ export class CreateStudioPanel { 'docker': 'Docker', 'colima': 'Colima', 'wsl': 'WSL (Windows)', - 'apple': 'Apple Container (macOS)', + 'apple-container': 'Apple Container (macOS 26+)', 'podman': 'Podman', 'lima': 'Lima' };