Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ; \
Expand Down
6 changes: 3 additions & 3 deletions Sources/ContainerCommands/Container/ContainerInspect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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]()
Expand Down Expand Up @@ -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 {
Expand All @@ -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)",
])
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Expand All @@ -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)
}
}
}
Expand All @@ -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)
}
Expand All @@ -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))
Expand Down Expand Up @@ -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 {
Expand Down
111 changes: 111 additions & 0 deletions Tests/CLITests/Subcommands/Containers/TestCLIPrefixMatch.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
Loading