Skip to content

Commit d2ddad2

Browse files
joelhelblingclaude
andcommitted
feat: add Apple Containers runtime with auto-detection
Implement Phase 4: Apple Containers as the preferred runtime on macOS. Detection checks for `container` CLI first, falls back to Docker with a user-friendly message about better isolation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dea2082 commit d2ddad2

4 files changed

Lines changed: 490 additions & 5 deletions

File tree

internal/runtime/apple.go

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
package runtime
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os/exec"
7+
"sort"
8+
"strings"
9+
)
10+
11+
// Compile-time check that AppleRuntime implements Runtime.
12+
var _ Runtime = (*AppleRuntime)(nil)
13+
14+
// AppleRuntime implements Runtime using Apple Containers (macOS).
15+
type AppleRuntime struct {
16+
io Stdio
17+
}
18+
19+
// NewApple creates an Apple Containers runtime with the given I/O streams.
20+
func NewApple(io Stdio) *AppleRuntime {
21+
return &AppleRuntime{io: io}
22+
}
23+
24+
func (a *AppleRuntime) Name() string { return "Apple Containers" }
25+
26+
func (a *AppleRuntime) ImageExists(name string) bool {
27+
cmd := exec.Command("container", "image", "inspect", name)
28+
return cmd.Run() == nil
29+
}
30+
31+
func (a *AppleRuntime) GetImageDigest(name string) (string, error) {
32+
cmd := exec.Command("container", "image", "inspect", name)
33+
output, err := cmd.Output()
34+
if err != nil {
35+
return "", err
36+
}
37+
var images []struct {
38+
Index struct {
39+
Digest string `json:"digest"`
40+
} `json:"index"`
41+
}
42+
if err := json.Unmarshal(output, &images); err != nil {
43+
return "", fmt.Errorf("failed to parse image inspect output: %w", err)
44+
}
45+
if len(images) == 0 {
46+
return "", fmt.Errorf("no image found for %q", name)
47+
}
48+
return images[0].Index.Digest, nil
49+
}
50+
51+
func (a *AppleRuntime) BuildImage(dockerfilePath, contextDir, imageName string) error {
52+
// Ensure builder is running before building
53+
if err := a.ensureBuilder(); err != nil {
54+
return fmt.Errorf("failed to start builder: %w", err)
55+
}
56+
57+
cmd := exec.Command("container", "build", "-t", imageName, "-f", dockerfilePath, contextDir)
58+
cmd.Stdout = a.io.Stdout
59+
cmd.Stderr = a.io.Stderr
60+
if err := cmd.Run(); err != nil {
61+
return fmt.Errorf("container build failed: %w", err)
62+
}
63+
return nil
64+
}
65+
66+
func (a *AppleRuntime) RemoveImage(name string) error {
67+
return exec.Command("container", "image", "rm", name).Run()
68+
}
69+
70+
// appleImageEntry represents a single entry from `container image ls --format json`.
71+
type appleImageEntry struct {
72+
Reference string `json:"reference"`
73+
}
74+
75+
func (a *AppleRuntime) ListImages(filterRef string) ([]string, error) {
76+
cmd := exec.Command("container", "image", "ls", "--format", "json")
77+
output, err := cmd.Output()
78+
if err != nil {
79+
return nil, err
80+
}
81+
82+
var allImages []appleImageEntry
83+
if err := json.Unmarshal(output, &allImages); err != nil {
84+
return nil, fmt.Errorf("failed to parse image list: %w", err)
85+
}
86+
87+
var images []string
88+
for _, img := range allImages {
89+
// Strip "docker.io/library/" prefix for matching against short names
90+
name := stripDockerHubPrefix(img.Reference)
91+
if matchesFilter(name, filterRef) {
92+
images = append(images, name)
93+
}
94+
}
95+
return images, nil
96+
}
97+
98+
func (a *AppleRuntime) ContainerExists(name string) bool {
99+
cmd := exec.Command("container", "inspect", name)
100+
output, err := cmd.Output()
101+
if err != nil {
102+
return false
103+
}
104+
// Apple Containers returns [] (empty array) with exit 0 for non-existent containers.
105+
var results []json.RawMessage
106+
if err := json.Unmarshal(output, &results); err != nil {
107+
return false
108+
}
109+
return len(results) > 0
110+
}
111+
112+
func (a *AppleRuntime) ContainerRunning(name string) bool {
113+
cmd := exec.Command("container", "inspect", name)
114+
output, err := cmd.Output()
115+
if err != nil {
116+
return false
117+
}
118+
var containers []struct {
119+
Status string `json:"status"`
120+
}
121+
if err := json.Unmarshal(output, &containers); err != nil {
122+
return false
123+
}
124+
return len(containers) > 0 && containers[0].Status == "running"
125+
}
126+
127+
// buildRunArgs constructs the argument list for `container run`.
128+
func (a *AppleRuntime) buildRunArgs(cfg RunConfig) []string {
129+
args := []string{
130+
"run", "-it",
131+
"--name", cfg.ContainerName,
132+
"-v", fmt.Sprintf("%s:%s", cfg.HostPath, cfg.WorkspacePath),
133+
"-w", cfg.WorkspacePath,
134+
}
135+
// Apple Containers has no --hostname flag; --name implicitly sets hostname.
136+
137+
keys := make([]string, 0, len(cfg.Env))
138+
for k := range cfg.Env {
139+
keys = append(keys, k)
140+
}
141+
sort.Strings(keys)
142+
for _, key := range keys {
143+
args = append(args, "-e", fmt.Sprintf("%s=%s", key, cfg.Env[key]))
144+
}
145+
146+
args = append(args, cfg.ImageName)
147+
return args
148+
}
149+
150+
func (a *AppleRuntime) RunInteractive(cfg RunConfig) error {
151+
args := a.buildRunArgs(cfg)
152+
cmd := exec.Command("container", args...)
153+
cmd.Stdin = a.io.Stdin
154+
cmd.Stdout = a.io.Stdout
155+
cmd.Stderr = a.io.Stderr
156+
return a.normalizeExitError(cmd.Run())
157+
}
158+
159+
func (a *AppleRuntime) StartInteractive(name string) error {
160+
cmd := exec.Command("container", "start", "-a", "-i", name)
161+
cmd.Stdin = a.io.Stdin
162+
cmd.Stdout = a.io.Stdout
163+
cmd.Stderr = a.io.Stderr
164+
return a.normalizeExitError(cmd.Run())
165+
}
166+
167+
// Attach connects to a running container. Apple Containers has no `attach`
168+
// command, so we use `container exec -it name /bin/sh` instead.
169+
func (a *AppleRuntime) Attach(name string) error {
170+
cmd := exec.Command("container", "exec", "-it", name, "/bin/sh")
171+
cmd.Stdin = a.io.Stdin
172+
cmd.Stdout = a.io.Stdout
173+
cmd.Stderr = a.io.Stderr
174+
return a.normalizeExitError(cmd.Run())
175+
}
176+
177+
func (a *AppleRuntime) RemoveContainer(name string) error {
178+
return exec.Command("container", "rm", name).Run()
179+
}
180+
181+
func (a *AppleRuntime) ForceRemoveContainer(name string) error {
182+
return exec.Command("container", "rm", "-f", name).Run()
183+
}
184+
185+
// appleContainerEntry represents a single entry from `container ls --format json`.
186+
type appleContainerEntry struct {
187+
Configuration struct {
188+
ID string `json:"id"`
189+
Image struct {
190+
Reference string `json:"reference"`
191+
} `json:"image"`
192+
} `json:"configuration"`
193+
}
194+
195+
func (a *AppleRuntime) ListContainers(filterName string, all bool) ([]ContainerInfo, error) {
196+
args := []string{"ls", "--format", "json"}
197+
if all {
198+
args = append(args, "-a")
199+
}
200+
201+
cmd := exec.Command("container", args...)
202+
output, err := cmd.Output()
203+
if err != nil {
204+
return nil, err
205+
}
206+
207+
var allContainers []appleContainerEntry
208+
if err := json.Unmarshal(output, &allContainers); err != nil {
209+
return nil, fmt.Errorf("failed to parse container list: %w", err)
210+
}
211+
212+
var containers []ContainerInfo
213+
for _, c := range allContainers {
214+
name := c.Configuration.ID
215+
image := c.Configuration.Image.Reference
216+
if filterName == "" || strings.Contains(name, filterName) {
217+
containers = append(containers, ContainerInfo{Name: name, Image: image})
218+
}
219+
}
220+
return containers, nil
221+
}
222+
223+
func (a *AppleRuntime) Diff(name string) ([]FileDiff, error) {
224+
return nil, ErrNotSupported
225+
}
226+
227+
func (a *AppleRuntime) Commit(containerName, imageName string) error {
228+
return ErrNotSupported
229+
}
230+
231+
func (a *AppleRuntime) Capabilities() Capabilities {
232+
return Capabilities{
233+
SupportsDiff: false,
234+
SupportsCommit: false,
235+
SupportsExport: true,
236+
}
237+
}
238+
239+
// ensureBuilder starts the Apple Containers builder if it's not already running.
240+
func (a *AppleRuntime) ensureBuilder() error {
241+
// Check if builder is already running
242+
if exec.Command("container", "builder", "info").Run() == nil {
243+
return nil
244+
}
245+
// Start the builder
246+
return exec.Command("container", "builder", "start").Run()
247+
}
248+
249+
// normalizeExitError filters out normal container exit codes.
250+
// Apple Containers uses similar conventions to Docker for exit codes.
251+
func (a *AppleRuntime) normalizeExitError(err error) error {
252+
if err == nil {
253+
return nil
254+
}
255+
exitErr, ok := err.(*exec.ExitError)
256+
if !ok {
257+
return err
258+
}
259+
260+
code := exitErr.ExitCode()
261+
switch {
262+
case code >= 125 && code <= 127:
263+
return fmt.Errorf("container runtime error (exit %d): %w", code, err)
264+
case code == 137:
265+
return fmt.Errorf("container was killed (exit 137, possibly out of memory)")
266+
default:
267+
return nil
268+
}
269+
}
270+
271+
// stripDockerHubPrefix removes the "docker.io/library/" prefix that Apple Containers
272+
// adds to locally-built images, so names match the short form used by glovebox.
273+
func stripDockerHubPrefix(ref string) string {
274+
ref = strings.TrimPrefix(ref, "docker.io/library/")
275+
return ref
276+
}
277+
278+
// matchesFilter checks if an image name matches a Docker-style reference filter.
279+
// Supports simple prefix/glob matching (e.g., "glovebox*" matches "glovebox:base").
280+
func matchesFilter(name, filter string) bool {
281+
if filter == "" {
282+
return true
283+
}
284+
// Handle glob-style filter (e.g., "glovebox*")
285+
if strings.HasSuffix(filter, "*") {
286+
prefix := strings.TrimSuffix(filter, "*")
287+
return strings.HasPrefix(name, prefix)
288+
}
289+
// Exact match or name-only match
290+
return name == filter || strings.HasPrefix(name, filter+":")
291+
}

0 commit comments

Comments
 (0)