From a7a02285ce4fed3c5ada123e77bbfe46d9aaccc6 Mon Sep 17 00:00:00 2001 From: Rohithmatham12 Date: Fri, 12 Jun 2026 13:26:04 -0400 Subject: [PATCH] Resolve container ID prefixes --- Package.swift | 1 + .../Container/ContainerDelete.swift | 2 +- .../Container/ContainerInspect.swift | 7 +- .../Container/ContainerKill.swift | 2 +- .../Container/ContainerStats.swift | 17 +- .../Container/ContainerStop.swift | 2 +- .../Client/ContainerClient.swift | 48 +++- .../Server/Containers/ContainersService.swift | 213 +++++++++++------- .../ContainerIDResolutionTests.swift | 53 +++++ 9 files changed, 246 insertions(+), 99 deletions(-) create mode 100644 Tests/ContainerAPIServiceTests/ContainerIDResolutionTests.swift diff --git a/Package.swift b/Package.swift index e9d8579d0..4d2ecf175 100644 --- a/Package.swift +++ b/Package.swift @@ -207,6 +207,7 @@ let package = Package( name: "ContainerAPIServiceTests", dependencies: [ .product(name: "Containerization", package: "containerization"), + "ContainerAPIService", "ContainerResource", "ContainerRuntimeLinuxClient", "ContainerRuntimeClient", diff --git a/Sources/ContainerCommands/Container/ContainerDelete.swift b/Sources/ContainerCommands/Container/ContainerDelete.swift index 1eddc6b85..027a2d0dd 100644 --- a/Sources/ContainerCommands/Container/ContainerDelete.swift +++ b/Sources/ContainerCommands/Container/ContainerDelete.swift @@ -68,7 +68,7 @@ extension Application { return c.id } } else { - containers = Array(Set(containerIds)) + containers = try await client.resolve(ids: containerIds) } var errors: [any Error] = [] diff --git a/Sources/ContainerCommands/Container/ContainerInspect.swift b/Sources/ContainerCommands/Container/ContainerInspect.swift index 1c1ef16e7..ab67c5beb 100644 --- a/Sources/ContainerCommands/Container/ContainerInspect.swift +++ b/Sources/ContainerCommands/Container/ContainerInspect.swift @@ -36,10 +36,9 @@ extension Application { public func run() async throws { let client = ContainerClient() - let uniqueIds = Set(containerIds) - let containers = try await client.list().filter { - uniqueIds.contains($0.id) - } + let resolvedIds = try await client.resolve(ids: containerIds) + let uniqueIds = Set(resolvedIds) + let containers = try await client.list().filter { uniqueIds.contains($0.id) } if containers.count != uniqueIds.count { let found = Set(containers.map { $0.id }) diff --git a/Sources/ContainerCommands/Container/ContainerKill.swift b/Sources/ContainerCommands/Container/ContainerKill.swift index dbaa4f093..0f4808551 100644 --- a/Sources/ContainerCommands/Container/ContainerKill.swift +++ b/Sources/ContainerCommands/Container/ContainerKill.swift @@ -59,7 +59,7 @@ extension Application { let filters = ContainerListFilters(status: .running).withoutMachines() containers = try await client.list(filters: filters).map { $0.id } } else { - containers = containerIds + containers = try await client.resolve(ids: containerIds) } var errors: [any Error] = [] diff --git a/Sources/ContainerCommands/Container/ContainerStats.swift b/Sources/ContainerCommands/Container/ContainerStats.swift index 769a28809..8c2fc6825 100644 --- a/Sources/ContainerCommands/Container/ContainerStats.swift +++ b/Sources/ContainerCommands/Container/ContainerStats.swift @@ -88,9 +88,10 @@ extension Application { containersToShow = try await client.list(filters: ContainerListFilters(status: .running)) } else { // Fetch specified containers by ID - containersToShow = try await client.list(filters: ContainerListFilters(ids: containers)) + let resolvedContainers = try await client.resolve(ids: containers) + containersToShow = try await client.list(filters: ContainerListFilters(ids: resolvedContainers)) // Validate all specified containers were found - for containerId in containers { + for containerId in resolvedContainers { guard containersToShow.contains(where: { $0.id == containerId }) else { throw ContainerizationError( .notFound, @@ -111,9 +112,11 @@ extension Application { let client = ContainerClient() // If containers were specified, validate they all exist upfront + let resolvedContainerIds: [String] if !containerIds.isEmpty { - let specifiedContainers = try await client.list(filters: ContainerListFilters(ids: containerIds)) - for containerId in containerIds { + resolvedContainerIds = try await client.resolve(ids: containerIds) + let specifiedContainers = try await client.list(filters: ContainerListFilters(ids: resolvedContainerIds)) + for containerId in resolvedContainerIds { guard specifiedContainers.contains(where: { $0.id == containerId }) else { throw ContainerizationError( .notFound, @@ -121,6 +124,8 @@ extension Application { ) } } + } else { + resolvedContainerIds = [] } clearScreen() @@ -130,10 +135,10 @@ extension Application { while true { do { let containersToShow: [ContainerSnapshot] - if containerIds.isEmpty { + if resolvedContainerIds.isEmpty { containersToShow = try await client.list(filters: ContainerListFilters(status: .running)) } else { - containersToShow = try await client.list(filters: ContainerListFilters(ids: containerIds)) + containersToShow = try await client.list(filters: ContainerListFilters(ids: resolvedContainerIds)) } let statsData = try await collectStats(client: client, for: containersToShow) diff --git a/Sources/ContainerCommands/Container/ContainerStop.swift b/Sources/ContainerCommands/Container/ContainerStop.swift index 442b327d2..b4a31c90b 100644 --- a/Sources/ContainerCommands/Container/ContainerStop.swift +++ b/Sources/ContainerCommands/Container/ContainerStop.swift @@ -63,7 +63,7 @@ extension Application { let filters = ContainerListFilters().withoutMachines() containers = try await client.list(filters: filters).map { $0.id } } else { - containers = containerIds + containers = try await client.resolve(ids: containerIds) } let opts = ContainerStopOptions( diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index b1b64a66e..d25564540 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -107,16 +107,58 @@ public struct ContainerClient: Sendable { /// Get the container for the provided id. public func get(id: String) async throws -> ContainerSnapshot { - let containers = try await list(filters: ContainerListFilters(ids: [id])) - guard let container = containers.first else { + let containers = try await list() + let resolvedID = try Self.resolve(id: id, in: containers.map { $0.id }) + guard let container = containers.first(where: { $0.id == resolvedID }) else { throw ContainerizationError( .notFound, - message: "get failed: container \(id) not found" + message: "container with ID \(id) not found" ) } return container } + /// Resolve a container ID or unique ID prefix to its full ID. + public func resolve(id: String, filters: ContainerListFilters = .all) async throws -> String { + let containers = try await list(filters: filters) + let ids = containers.map { $0.id } + return try Self.resolve(id: id, in: ids) + } + + /// Resolve container IDs or unique ID prefixes to their full IDs. + public func resolve(ids: [String], filters: ContainerListFilters = .all) async throws -> [String] { + let containers = try await list(filters: filters) + let containerIDs = containers.map { $0.id } + var seen = Set() + var resolved = [String]() + for id in ids where seen.insert(id).inserted { + resolved.append(try Self.resolve(id: id, in: containerIDs)) + } + return resolved + } + + private static func resolve(id: String, in ids: [String]) throws -> String { + if ids.contains(id) { + return id + } + + let matches = ids.filter { $0.hasPrefix(id) } + if matches.count == 1, let match = matches.first { + return match + } + if matches.count > 1 { + throw ContainerizationError( + .invalidArgument, + message: "container ID prefix \(id) is ambiguous" + ) + } + + throw ContainerizationError( + .notFound, + message: "container with ID \(id) not found" + ) + } + /// Bootstrap the container's init process. public func bootstrap( id: String, diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index a37ad6912..61a9e57e0 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -389,11 +389,12 @@ public actor ContainersService { /// Bootstrap the init process of the container. public func bootstrap(id: String, stdio: [FileHandle?], dynamicEnv: [String: String]) async throws { + let resolvedID = try self.resolveContainerID(id) log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", "env": "\(dynamicEnv)", ] ) @@ -402,13 +403,13 @@ public actor ContainersService { "ContainersService: exit", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) } - try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in - var state = try await self.getContainerState(id: id, context: context) + try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(resolvedID)"]) { context in + var state = try await self.getContainerState(id: resolvedID, context: context) // 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 @@ -417,7 +418,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 networkBootstrapInfos = [NetworkBootstrapInfo]() @@ -439,25 +440,25 @@ public actor ContainersService { let runtime = state.snapshot.configuration.runtimeHandler let runtimeClient = try await RuntimeClient.create( - id: id, + id: resolvedID, runtime: runtime ) try await runtimeClient.bootstrap(stdio: stdio, networkBootstrapInfos: networkBootstrapInfos, dynamicEnv: dynamicEnv) try await self.exitMonitor.registerProcess( - id: id, + id: resolvedID, onExit: self.handleContainerExit ) state.client = runtimeClient - await self.setContainerState(id, state, context: context) + await self.setContainerState(resolvedID, state, context: context) } catch { 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 } @@ -471,12 +472,14 @@ public actor ContainersService { config: ProcessConfiguration, stdio: [FileHandle?] ) async throws { + let resolvedID = try self.resolveContainerID(id) + let resolvedProcessID = processID == id ? resolvedID : processID log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", - "id": "\(id)", - "processId": "\(processID)", + "id": "\(resolvedID)", + "processId": "\(resolvedProcessID)", "command": "\(config.arguments.isEmpty ? "" : config.arguments[0])", ] ) @@ -485,15 +488,15 @@ public actor ContainersService { "ContainersService: exit", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) } - let state = try self._getContainerState(id: id) + let state = try self._getContainerState(id: resolvedID) let client = try state.getClient() try await client.createProcess( - processID, + resolvedProcessID, config: config, stdio: stdio ) @@ -503,12 +506,14 @@ public actor ContainersService { /// createProcess, or the init process of the container which requires /// id == processID. public func startProcess(id: String, processID: String) async throws { + let resolvedID = try self.resolveContainerID(id) + let resolvedProcessID = processID == id ? resolvedID : processID log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", - "id": "\(id)", - "processId": "\(processID)", + "id": "\(resolvedID)", + "processId": "\(resolvedProcessID)", ] ) defer { @@ -516,22 +521,22 @@ public actor ContainersService { "ContainersService: exit", metadata: [ "func": "\(#function)", - "id": "\(id)", - "processId": "\(processID)", + "id": "\(resolvedID)", + "processId": "\(resolvedProcessID)", ] ) } - try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)", "processId": "\(processID)"]) { context in - var state = try await self.getContainerState(id: id, context: context) + try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(resolvedID)", "processId": "\(resolvedProcessID)"]) { context in + var state = try await self.getContainerState(id: resolvedID, context: context) - let isInit = Self.isInitProcess(id: id, processID: processID) + let isInit = Self.isInitProcess(id: resolvedID, processID: resolvedProcessID) if state.snapshot.status == .running && isInit { return } let client = try state.getClient() - try await client.startProcess(processID) + try await client.startProcess(resolvedProcessID) guard isInit else { return @@ -541,11 +546,11 @@ 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)", ]) @@ -557,9 +562,9 @@ public actor ContainersService { 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 } @@ -568,12 +573,14 @@ public actor ContainersService { /// Send a signal to the container. public func kill(id: String, processID: String, signal: String) async throws { + let resolvedID = try self.resolveContainerID(id) + let resolvedProcessID = processID == id ? resolvedID : processID log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", - "id": "\(id)", - "processId": "\(processID)", + "id": "\(resolvedID)", + "processId": "\(resolvedProcessID)", "signal": "\(signal)", ] ) @@ -582,32 +589,33 @@ public actor ContainersService { "ContainersService: exit", metadata: [ "func": "\(#function)", - "id": "\(id)", - "processId": "\(processID)", + "id": "\(resolvedID)", + "processId": "\(resolvedProcessID)", ] ) } - let state = try self._getContainerState(id: id) + let state = try self._getContainerState(id: resolvedID) let client = try state.getClient() - try await client.kill(processID, signal: signal) + try await client.kill(resolvedProcessID, signal: signal) // SIGKILL is guaranteed to terminate the target. When directed at the // container's init process, follow up with the same API-server cleanup // that `stop` performs. - if processID == id, (try? Signal(signal)) == .kill { - try await handleContainerExit(id: id) + if resolvedProcessID == resolvedID, (try? Signal(signal)) == .kill { + try await handleContainerExit(id: resolvedID) } } /// Stop all containers inside the sandbox, aborting any processes currently /// executing inside the container, before stopping the underlying sandbox. public func stop(id: String, options: ContainerStopOptions) async throws { + let resolvedID = try self.resolveContainerID(id) log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) defer { @@ -615,12 +623,12 @@ public actor ContainersService { "ContainersService: exit", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) } - let state = try self._getContainerState(id: id) + let state = try self._getContainerState(id: resolvedID) // Stop should be idempotent. let client: RuntimeClient @@ -642,15 +650,16 @@ 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 { + let resolvedID = try self.resolveContainerID(id) log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", "port": "\(port)", ] ) @@ -659,13 +668,13 @@ public actor ContainersService { "ContainersService: exit", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", "port": "\(port)", ] ) } - let state = try self._getContainerState(id: id) + let state = try self._getContainerState(id: resolvedID) let client = try state.getClient() return try await client.dial(port) } @@ -673,12 +682,14 @@ public actor ContainersService { /// Wait waits for the container's init process or exec to exit and returns the /// exit status. public func wait(id: String, processID: String) async throws -> ExitStatus { + let resolvedID = try self.resolveContainerID(id) + let resolvedProcessID = processID == id ? resolvedID : processID log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", - "id": "\(id)", - "processId": "\(processID)", + "id": "\(resolvedID)", + "processId": "\(resolvedProcessID)", ] ) defer { @@ -686,25 +697,27 @@ public actor ContainersService { "ContainersService: exit", metadata: [ "func": "\(#function)", - "id": "\(id)", - "processId": "\(processID)", + "id": "\(resolvedID)", + "processId": "\(resolvedProcessID)", ] ) } - let state = try self._getContainerState(id: id) + let state = try self._getContainerState(id: resolvedID) let client = try state.getClient() - return try await client.wait(processID) + return try await client.wait(resolvedProcessID) } /// Resize resizes the container's PTY if one exists. public func resize(id: String, processID: String, size: Terminal.Size) async throws { + let resolvedID = try self.resolveContainerID(id) + let resolvedProcessID = processID == id ? resolvedID : processID log.trace( "ContainersService: enter", metadata: [ "func": "\(#function)", - "id": "\(id)", - "processId": "\(processID)", + "id": "\(resolvedID)", + "processId": "\(resolvedProcessID)", ] ) defer { @@ -712,24 +725,25 @@ public actor ContainersService { "ContainersService: exit", metadata: [ "func": "\(#function)", - "id": "\(id)", - "processId": "\(processID)", + "id": "\(resolvedID)", + "processId": "\(resolvedProcessID)", ] ) } - let state = try self._getContainerState(id: id) + let state = try self._getContainerState(id: resolvedID) let client = try state.getClient() - try await client.resize(processID, size: size) + try await client.resize(resolvedProcessID, size: size) } // Get the logs for the container. public func logs(id: String) async throws -> [FileHandle] { + let resolvedID = try self.resolveContainerID(id) log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) defer { @@ -737,7 +751,7 @@ public actor ContainersService { "ContainersService: exit", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) } @@ -747,8 +761,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) + _ = try _getContainerState(id: resolvedID) + let path = self.containerRoot.appendingPathComponent(resolvedID) let bundle = ContainerResource.Bundle(path: path) return [ try FileHandle(forReadingFrom: bundle.containerLog), @@ -766,9 +780,10 @@ public actor ContainersService { public func copyIn(id: String, source: String, destination: String, mode: UInt32, createParents: Bool = true) async throws { self.log.debug("\(#function)") - let state = try self._getContainerState(id: id) + let resolvedID = try self.resolveContainerID(id) + let state = try self._getContainerState(id: resolvedID) guard state.snapshot.status == .running else { - throw ContainerizationError(.invalidState, message: "container \(id) is not running") + throw ContainerizationError(.invalidState, message: "container \(resolvedID) is not running") } let client = try state.getClient() try await client.copyIn(source: source, destination: destination, mode: mode, createParents: createParents) @@ -778,9 +793,10 @@ public actor ContainersService { public func copyOut(id: String, source: String, destination: String, createParents: Bool = true) async throws { self.log.debug("\(#function)") - let state = try self._getContainerState(id: id) + let resolvedID = try self.resolveContainerID(id) + let state = try self._getContainerState(id: resolvedID) guard state.snapshot.status == .running else { - throw ContainerizationError(.invalidState, message: "container \(id) is not running") + throw ContainerizationError(.invalidState, message: "container \(resolvedID) is not running") } let client = try state.getClient() try await client.copyOut(source: source, destination: destination, createParents: createParents) @@ -788,11 +804,12 @@ public actor ContainersService { /// Get statistics for the container. public func stats(id: String) async throws -> ContainerStats { + let resolvedID = try self.resolveContainerID(id) log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) defer { @@ -800,23 +817,24 @@ public actor ContainersService { "ContainersService: exit", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) } - let state = try self._getContainerState(id: id) + let state = try self._getContainerState(id: resolvedID) let client = try state.getClient() return try await client.statistics() } /// Delete a container and its resources. public func delete(id: String, force: Bool) async throws { + let resolvedID = try self.resolveContainerID(id) log.info( "ContainersService: enter", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", "force": "\(force)", ] ) @@ -825,18 +843,18 @@ public actor ContainersService { "ContainersService: exit", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) } - let state = try self._getContainerState(id: id) + let state = try self._getContainerState(id: resolvedID) 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( @@ -845,41 +863,42 @@ 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) } } } public func containerDiskUsage(id: String) async throws -> UInt64 { + let resolvedID = try self.resolveContainerID(id) log.debug( "ContainersService: enter", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) defer { @@ -887,12 +906,12 @@ public actor ContainersService { "ContainersService: exit", metadata: [ "func": "\(#function)", - "id": "\(id)", + "id": "\(resolvedID)", ] ) } - let containerPath = self.containerRoot.appendingPathComponent(id).path + let containerPath = self.containerRoot.appendingPathComponent(resolvedID).path return FileManager.default.allocatedSize(of: URL(fileURLWithPath: containerPath)) } @@ -900,12 +919,13 @@ public actor ContainersService { public func exportRootfs(id: String, archive: URL) async throws { self.log.debug("\(#function)") - let state = try self._getContainerState(id: id) + let resolvedID = try self.resolveContainerID(id) + let state = try self._getContainerState(id: resolvedID) guard state.snapshot.status == .stopped else { throw ContainerizationError(.invalidState, message: "container is not stopped") } - let path = self.containerRoot.appendingPathComponent(id) + let path = self.containerRoot.appendingPathComponent(resolvedID) let bundle = ContainerResource.Bundle(path: path) let rootfs = bundle.containerRootfsBlock try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive)) @@ -1111,16 +1131,43 @@ public actor ContainersService { self.containers[id] = state } + func resolveContainerID(_ id: String) throws -> String { + try Self.resolveContainerID(id, in: self.containers.keys) + } + + static func resolveContainerID(_ id: String, in containerIDs: some Collection) throws -> String { + if containerIDs.contains(id) { + return id + } + + let matches = containerIDs.filter { $0.hasPrefix(id) } + if matches.count == 1, let match = matches.first { + return match + } + if matches.count > 1 { + throw ContainerizationError( + .invalidArgument, + message: "container ID prefix \(id) is ambiguous" + ) + } + + throw ContainerizationError( + .notFound, + message: "container with ID \(id) not found" + ) + } + private func getContainerState(id: String, context: AsyncLock.Context) throws -> ContainerState { try self._getContainerState(id: id) } private func _getContainerState(id: String) throws -> ContainerState { - let state = self.containers[id] + let resolvedID = try self.resolveContainerID(id) + let state = self.containers[resolvedID] guard let state else { throw ContainerizationError( .notFound, - message: "container with ID \(id) not found" + message: "container with ID \(resolvedID) not found" ) } return state diff --git a/Tests/ContainerAPIServiceTests/ContainerIDResolutionTests.swift b/Tests/ContainerAPIServiceTests/ContainerIDResolutionTests.swift new file mode 100644 index 000000000..056253ee5 --- /dev/null +++ b/Tests/ContainerAPIServiceTests/ContainerIDResolutionTests.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerAPIService + +struct ContainerIDResolutionTests { + private let ids = [ + "983d8fe8-09b1-47ab-b65b-a6ce9da824a5", + "983d8fff-65aa-4521-9ae2-69ed20c9f813", + "custom-name", + ] + + @Test func exactIDWins() throws { + let id = "custom-name" + + let resolved = try ContainersService.resolveContainerID(id, in: ids) + + #expect(resolved == id) + } + + @Test func uniquePrefixResolvesToFullID() throws { + let resolved = try ContainersService.resolveContainerID("983d8fe", in: ids) + + #expect(resolved == "983d8fe8-09b1-47ab-b65b-a6ce9da824a5") + } + + @Test func ambiguousPrefixThrows() { + #expect(throws: (any Error).self) { + try ContainersService.resolveContainerID("983d8", in: ids) + } + } + + @Test func unknownPrefixThrows() { + #expect(throws: (any Error).self) { + try ContainersService.resolveContainerID("missing", in: ids) + } + } +}