diff --git a/Makefile b/Makefile index cbfb923d4..35258c0c0 100644 --- a/Makefile +++ b/Makefile @@ -214,6 +214,7 @@ integration: init-block $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIKernelSet || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIAnonymousVolumes || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLINotFound || exit_code=1 ; \ + $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIPrefixMatch || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLINoParallelCases || exit_code=1 ; \ echo Ensuring apiserver stopped after the CLI integration tests ; \ scripts/ensure-container-stopped.sh ; \ diff --git a/Sources/ContainerCommands/Container/ContainerInspect.swift b/Sources/ContainerCommands/Container/ContainerInspect.swift index 322798c4f..469ee80d2 100644 --- a/Sources/ContainerCommands/Container/ContainerInspect.swift +++ b/Sources/ContainerCommands/Container/ContainerInspect.swift @@ -36,9 +36,9 @@ extension Application { public func run() async throws { let client = ContainerClient() - let objects: [any Codable] = try await client.list().filter { - containerIds.contains($0.id) - }.map { + let objects: [any Codable] = try await client.list( + filters: ContainerListFilters(ids: containerIds) + ).map { PrintableContainer($0) } print(try objects.jsonArray()) diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index b1ea7981f..162637328 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -171,7 +171,7 @@ public actor ContainersService { let snapshot = state.snapshot if !filters.ids.isEmpty { - guard filters.ids.contains(snapshot.id) else { + guard filters.ids.contains(where: { snapshot.id == $0 || snapshot.id.hasPrefix($0) }) else { return nil } } @@ -418,6 +418,7 @@ public actor ContainersService { try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in var state = try await self.getContainerState(id: id, context: context) + let resolvedID = state.snapshot.id // We've already bootstrapped this container. Ideally we should be able to // return some sort of error code from the sandbox svc to check here, but this @@ -426,7 +427,7 @@ public actor ContainersService { return } - let path = self.containerRoot.appendingPathComponent(id) + let path = self.containerRoot.appendingPathComponent(resolvedID) let (config, _) = try Self.getContainerConfiguration(at: path) var allocatedAttachments = [AllocatedAttachment]() @@ -470,19 +471,19 @@ public actor ContainersService { let runtime = state.snapshot.configuration.runtimeHandler let sandboxClient = try await SandboxClient.create( - id: id, + id: resolvedID, runtime: runtime ) try await sandboxClient.bootstrap(stdio: stdio, allocatedAttachments: allocatedAttachments) try await self.exitMonitor.registerProcess( - id: id, + id: resolvedID, onExit: self.handleContainerExit ) state.client = sandboxClient state.allocatedAttachments = allocatedAttachments - await self.setContainerState(id, state, context: context) + await self.setContainerState(resolvedID, state, context: context) } catch { for allocatedAttach in allocatedAttachments { do { @@ -491,7 +492,7 @@ public actor ContainersService { self.log.error( "failed to deallocate network attachment", metadata: [ - "id": "\(id)", + "id": "\(resolvedID)", "network": "\(allocatedAttach.attachment.network)", "error": "\(error)", ]) @@ -500,10 +501,10 @@ public actor ContainersService { let label = Self.fullLaunchdServiceLabel( runtimeName: config.runtimeHandler, - instanceId: id + instanceId: resolvedID ) - await self.exitMonitor.stopTracking(id: id) + await self.exitMonitor.stopTracking(id: resolvedID) try? ServiceManager.deregister(fullServiceLabel: label) throw error } @@ -570,8 +571,9 @@ public actor ContainersService { try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)", "processId": "\(processID)"]) { context in var state = try await self.getContainerState(id: id, context: context) + let resolvedID = state.snapshot.id - let isInit = Self.isInitProcess(id: id, processID: processID) + let isInit = Self.isInitProcess(id: resolvedID, processID: processID) if state.snapshot.status == .running && isInit { return } @@ -587,25 +589,25 @@ public actor ContainersService { let log = self.log let waitFunc: ExitMonitor.WaitHandler = { log.info("registering container with exit monitor") - let code = try await client.wait(id) + let code = try await client.wait(resolvedID) log.info( "container finished in exit monitor", metadata: [ - "id": "\(id)", + "id": "\(resolvedID)", "rc": "\(code)", ]) return code } - try await self.exitMonitor.track(id: id, waitingOn: waitFunc) + try await self.exitMonitor.track(id: resolvedID, waitingOn: waitFunc) let sandboxSnapshot = try await client.state() state.snapshot.status = .running state.snapshot.networks = sandboxSnapshot.networks state.snapshot.startedDate = Date() - await self.setContainerState(id, state, context: context) + await self.setContainerState(resolvedID, state, context: context) } catch { - await self.exitMonitor.stopTracking(id: id) + await self.exitMonitor.stopTracking(id: resolvedID) try? await client.stop(options: ContainerStopOptions.default) throw error } @@ -660,6 +662,7 @@ public actor ContainersService { } let state = try self._getContainerState(id: id) + let resolvedID = state.snapshot.id // Stop should be idempotent. let client: SandboxClient @@ -676,7 +679,7 @@ public actor ContainersService { throw err } } - try await handleContainerExit(id: id) + try await handleContainerExit(id: resolvedID) } public func dial(id: String, port: UInt32) async throws -> FileHandle { @@ -781,8 +784,8 @@ public actor ContainersService { // first try and get the container state so we get a nicer error message // (container foo not found) however. do { - _ = try _getContainerState(id: id) - let path = self.containerRoot.appendingPathComponent(id) + let state = try _getContainerState(id: id) + let path = self.containerRoot.appendingPathComponent(state.snapshot.id) let bundle = ContainerResource.Bundle(path: path) return [ try FileHandle(forReadingFrom: bundle.containerLog), @@ -841,12 +844,13 @@ public actor ContainersService { } let state = try self._getContainerState(id: id) + let resolvedID = state.snapshot.id switch state.snapshot.status { case .running: if !force { throw ContainerizationError( .invalidState, - message: "container \(id) is \(state.snapshot.status) and can not be deleted" + message: "container \(resolvedID) is \(state.snapshot.status) and can not be deleted" ) } let opts = ContainerStopOptions( @@ -855,31 +859,31 @@ public actor ContainersService { ) let client = try state.getClient() try await client.stop(options: opts) - try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in + try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(resolvedID)"]) { context in self.log.info( "ContainersService: attempt cleanup", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) - try await self.cleanUp(id: id, context: context) + try await self.cleanUp(id: resolvedID, context: context) self.log.info( "ContainersService: successful cleanup", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) } case .stopping: throw ContainerizationError( .invalidState, - message: "container \(id) is \(state.snapshot.status) and can not be deleted" + message: "container \(resolvedID) is \(state.snapshot.status) and can not be deleted" ) default: - try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in - try await self.cleanUp(id: id, context: context) + try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(resolvedID)"]) { context in + try await self.cleanUp(id: resolvedID, context: context) } } } @@ -902,7 +906,8 @@ public actor ContainersService { ) } - let containerPath = self.containerRoot.appendingPathComponent(id).path + let state = try self._getContainerState(id: id) + let containerPath = self.containerRoot.appendingPathComponent(state.snapshot.id).path return Self.calculateDirectorySize(at: containerPath) } @@ -915,7 +920,7 @@ public actor ContainersService { throw ContainerizationError(.invalidState, message: "container is not stopped") } - let path = self.containerRoot.appendingPathComponent(id) + let path = self.containerRoot.appendingPathComponent(state.snapshot.id) let bundle = ContainerResource.Bundle(path: path) let rootfs = bundle.containerRootfsBlock try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive)) @@ -1144,14 +1149,27 @@ public actor ContainersService { } private func _getContainerState(id: String) throws -> ContainerState { - let state = self.containers[id] - guard let state else { + // Fast path: exact match. + if let state = self.containers[id] { + return state + } + + // Slow path: prefix match. + let matches = self.containers.keys.filter { $0.hasPrefix(id) } + switch matches.count { + case 1: + return self.containers[matches[0]]! + case let n where n > 1: + throw ContainerizationError( + .invalidArgument, + message: "multiple containers found with prefix \(id)" + ) + default: throw ContainerizationError( .notFound, message: "container with ID \(id) not found" ) } - return state } private static func isInitProcess(id: String, processID: String) -> Bool { diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIPrefixMatch.swift b/Tests/CLITests/Subcommands/Containers/TestCLIPrefixMatch.swift new file mode 100644 index 000000000..3441b2852 --- /dev/null +++ b/Tests/CLITests/Subcommands/Containers/TestCLIPrefixMatch.swift @@ -0,0 +1,111 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +/// Tests that containers can be referenced by a unique prefix of their ID. +class TestCLIPrefixMatch: CLITest { + + @Test func testStopByPrefix() throws { + let name = "prefix-stop-test" + try doLongRun(name: name, autoRemove: false) + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + try waitForContainerRunning(name) + + let fullId = try getContainerId(name) + let prefix = String(fullId.prefix(8)) + + let (_, _, error, status) = try run(arguments: ["stop", "-s", "SIGKILL", prefix]) + #expect(status == 0, "stop by prefix should succeed, error: \(error)") + } + + @Test func testInspectByPrefix() throws { + let name = "prefix-inspect-test" + try doLongRun(name: name, autoRemove: false) + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + try waitForContainerRunning(name) + + let fullId = try getContainerId(name) + let prefix = String(fullId.prefix(8)) + + let (_, output, error, status) = try run(arguments: ["inspect", prefix]) + #expect(status == 0, "inspect by prefix should succeed, error: \(error)") + #expect(output.contains(fullId), "inspect output should contain the full container ID") + } + + @Test func testExecByPrefix() throws { + let name = "prefix-exec-test" + try doLongRun(name: name, autoRemove: false) + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + try waitForContainerRunning(name) + + let fullId = try getContainerId(name) + let prefix = String(fullId.prefix(8)) + + let output = try doExec(name: prefix, cmd: ["echo", "hello"]) + #expect(output.contains("hello"), "exec by prefix should work") + } + + @Test func testDeleteByPrefix() throws { + let name = "prefix-delete-test" + try doLongRun(name: name, autoRemove: false) + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + try waitForContainerRunning(name) + + let fullId = try getContainerId(name) + let prefix = String(fullId.prefix(8)) + + // Stop first so we can delete. + try doStop(name: name) + + let (_, _, error, status) = try run(arguments: ["delete", prefix]) + #expect(status == 0, "delete by prefix should succeed, error: \(error)") + } + + @Test func testAmbiguousPrefixReturnsError() throws { + // Create two containers whose names share a common prefix. + let name1 = "ambiguous-pfx-aaa" + let name2 = "ambiguous-pfx-bbb" + try doLongRun(name: name1, autoRemove: false) + try doLongRun(name: name2, autoRemove: false) + defer { + try? doStop(name: name1) + try? doStop(name: name2) + try? doRemove(name: name1) + try? doRemove(name: name2) + } + try waitForContainerRunning(name1) + try waitForContainerRunning(name2) + + // "ambiguous-pfx" is a prefix of both container names. + let (_, _, error, status) = try run(arguments: ["stop", "-s", "SIGKILL", "ambiguous-pfx"]) + #expect(status != 0, "ambiguous prefix should fail") + #expect(error.contains("multiple containers found"), "error should mention multiple matches, got: \(error)") + } +}