diff --git a/applecontainer-bridge/Sources/ACBridge/lifecycle.swift b/applecontainer-bridge/Sources/ACBridge/lifecycle.swift index ec58f10..b4a7f62 100644 --- a/applecontainer-bridge/Sources/ACBridge/lifecycle.swift +++ b/applecontainer-bridge/Sources/ACBridge/lifecycle.swift @@ -103,7 +103,11 @@ private func runContainer(spec: RunSpecJSON) async throws { var cfg = ContainerConfiguration(id: spec.id, image: img.description, process: process) cfg.platform = platform cfg.labels = spec.labels ?? [:] - cfg.mounts = try (spec.mounts ?? []).map(toFilesystem) + var resolvedMounts: [Filesystem] = [] + for m in spec.mounts ?? [] { + resolvedMounts.append(try await toFilesystem(m)) + } + cfg.mounts = resolvedMounts cfg.capAdd = spec.capAdd ?? [] cfg.useInit = spec.initProcess ?? false // Enable Rosetta when running an amd64 container on an arm64 @@ -232,7 +236,7 @@ private func imageConfigUser(_ cfg: ImageConfig?) -> ProcessConfiguration.User { return .id(uid: 0, gid: 0) } -private func toFilesystem(_ m: MountJSON) throws -> Filesystem { +private func toFilesystem(_ m: MountJSON) async throws -> Filesystem { var options: MountOptions = [] if m.readOnly ?? false { options.append("ro") @@ -246,13 +250,23 @@ private func toFilesystem(_ m: MountJSON) throws -> Filesystem { case "tmpfs": return .tmpfs(destination: m.target, options: options) case "volume": - // PR-C scope decision: treat named volumes as virtiofs binds - // against the supplied source. Real named-volume support (with - // the apiserver pre-creating the volume) is a later PR. - guard let src = m.source, !src.isEmpty else { - throw BridgeError.invalidArgument("volume mount on this backend currently requires source (named-volume support is deferred)") + // Named volume: source carries the volume name. Resolve it + // through ClientVolume.inspect to fetch the backing image + // path + filesystem format the apiserver expects, then build + // a proper Filesystem.volume. Treating the spec as a virtiofs + // bind (PR-C's interim shape) makes the apiserver resolve the + // name against CWD and fail with errno 2 at bootstrap. + guard let name = m.source, !name.isEmpty else { + throw BridgeError.invalidArgument("named volume mount requires source (volume name)") } - return .virtiofs(source: src, destination: m.target, options: options) + let vol = try await ClientVolume.inspect(name) + return .volume( + name: vol.name, + format: vol.format, + source: vol.source, + destination: m.target, + options: options + ) default: throw BridgeError.invalidArgument("unknown mount type \"\(m.type)\"") } diff --git a/runtime/applecontainer/lifecycle_darwin_arm64_test.go b/runtime/applecontainer/lifecycle_darwin_arm64_test.go index 3f5d633..990f357 100644 --- a/runtime/applecontainer/lifecycle_darwin_arm64_test.go +++ b/runtime/applecontainer/lifecycle_darwin_arm64_test.go @@ -5,6 +5,8 @@ package applecontainer import ( "context" "errors" + "os" + "path/filepath" "strings" "testing" "time" @@ -176,6 +178,77 @@ func TestRunContainer_BindMount(t *testing.T) { } } +// TestRunContainer_NamedVolume creates a container with a named volume +// mount and verifies the inspect path reports a MountVolume entry +// pointing at the volume's backing image (not a virtiofs bind against +// the launcher CWD). Regression guard for the bug where named volumes +// fell through to virtiofs and bootstrapped with errno 2. +func TestRunContainer_NamedVolume(t *testing.T) { + rt := runtimeOrSkip(t) + ctx := context.Background() + const ( + id = "ac-namedvol-test" + volume = "ac-namedvol-test-vol" + ) + _ = rt.RemoveContainer(ctx, id, runtime.RemoveOptions{Force: true}) + cliRun(t, "volume", "rm", volume) + t.Cleanup(func() { + _ = rt.RemoveContainer(ctx, id, runtime.RemoveOptions{Force: true}) + cliRun(t, "volume", "rm", volume) + }) + + cliRunStrict(t, "volume", "create", volume) + cliRunStrict(t, + "run", "--rm", "--name", "ac-alpine-warmup-vol", + "docker.io/library/alpine:latest", "/bin/true", + ) + + _, err := rt.RunContainer(ctx, runtime.RunSpec{ + Image: "docker.io/library/alpine:latest", + Name: id, + Cmd: []string{"sleep", "60"}, + Mounts: []runtime.MountSpec{ + {Type: runtime.MountVolume, Source: volume, Target: "/mnt/data"}, + }, + }) + if err != nil { + t.Fatalf("RunContainer with named volume: %v", err) + } + + details, err := rt.InspectContainer(ctx, id) + if err != nil { + t.Fatalf("InspectContainer: %v", err) + } + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + cwd = filepath.Clean(cwd) + var found bool + for _, m := range details.Mounts { + if m.Target == "/mnt/data" && m.Type == string(runtime.MountVolume) { + found = true + // The apiserver substitutes the volume's on-disk image + // path as the canonical source. We don't pin the exact + // path (changes across apple/container releases) but it + // must not be empty and must not be the launcher CWD — + // the original bug had a non-empty source rooted at the + // launcher's working directory (apple's virtiofs path + // resolution), so empty-source alone wouldn't catch it. + if m.Source == "" { + t.Errorf("named volume mount has empty source; expected backing path") + } + if strings.HasPrefix(filepath.Clean(m.Source), cwd+string(filepath.Separator)) { + t.Errorf("named volume mount source rooted at launcher CWD: source=%q cwd=%q", m.Source, cwd) + } + } + } + if !found { + t.Errorf("named volume mount not round-tripped; details.Mounts=%+v want target=/mnt/data type=volume", + details.Mounts) + } +} + // waitForState polls InspectContainer until the desired state is // observed or the timeout fires. Apple's runtime status transitions // asynchronously through the apiserver event loop.