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
54 changes: 54 additions & 0 deletions Sources/ContainerClient/Core/ClientContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,60 @@ extension ClientContainer {
}
}

public static func search(searches: [String]) async throws -> [ClientContainer] {
do {
let client = Self.newXPCClient()
let request = XPCMessage(route: .containerSearch)
try request.set(key: .searches, value: searches)

let response = try await xpcSend(
client: client,
message: request,
timeout: .seconds(10)
)

let data = response.dataNoCopy(key: .containers)
guard let data else {
return []
}
let configs = try JSONDecoder().decode([ContainerSnapshot].self, from: data)
return configs.map { ClientContainer(snapshot: $0) }
} catch {
throw ContainerizationError(
.internalError,
message: "failed to search containers",
cause: error
)
}
}

public static func searchOne(search: String) async throws -> ClientContainer {
do {
let client = Self.newXPCClient()
let request = XPCMessage(route: .containerSearchOne)
request.set(key: .search, value: search)

let response = try await xpcSend(
client: client,
message: request,
timeout: .seconds(10)
)

let data = response.dataNoCopy(key: .containers)
guard let data else {
throw ContainerizationError(.notFound, message: "container \(search) not found")
}
let snapshot = try JSONDecoder().decode(ContainerSnapshot.self, from: data)
return ClientContainer(snapshot: snapshot)
} catch {
throw ContainerizationError(
.internalError,
message: "failed to search for container",
cause: error
)
}
}

/// Get the container for the provided id.
public static func get(id: String) async throws -> ClientContainer {
let containers = try await list()
Expand Down
28 changes: 28 additions & 0 deletions Sources/ContainerClient/Core/ClientNetwork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,34 @@ extension ClientNetwork {
return states
}

public static func search(searches: [String]) async throws -> [NetworkState] {
let client = Self.newClient()
let request = XPCMessage(route: .networkSearch)
try request.set(key: .searches, value: searches)

let response = try await xpcSend(client: client, message: request, timeout: .seconds(1))
let responseData = response.dataNoCopy(key: .networkStates)
guard let responseData else {
return []
}
let states = try JSONDecoder().decode([NetworkState].self, from: responseData)
return states
}

public static func searchOne(search: String) async throws -> NetworkState {
let client = Self.newClient()
let request = XPCMessage(route: .networkSearchOne)
request.set(key: .search, value: search)

let response = try await xpcSend(client: client, message: request, timeout: .seconds(1))
let responseData = response.dataNoCopy(key: .networkState)
guard let responseData else {
throw ContainerizationError(.notFound, message: "network \(search) not found")
}
let state = try JSONDecoder().decode(NetworkState.self, from: responseData)
return state
}

/// Get the network for the provided id.
public static func get(id: String) async throws -> NetworkState {
let networks = try await list()
Expand Down
26 changes: 26 additions & 0 deletions Sources/ContainerClient/Core/ClientVolume.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,32 @@ public struct ClientVolume {
return try JSONDecoder().decode([Volume].self, from: responseData)
}

public static func search(searches: [String]) async throws -> [Volume] {
let client = XPCClient(service: serviceIdentifier)
let message = XPCMessage(route: .volumeSearch)
try message.set(key: .searches, value: searches)
let reply = try await client.send(message)

guard let responseData = reply.dataNoCopy(key: .volumes) else {
return []
}

return try JSONDecoder().decode([Volume].self, from: responseData)
}

public static func searchOne(search: String) async throws -> Volume {
let client = XPCClient(service: serviceIdentifier)
let message = XPCMessage(route: .volumeSearchOne)
message.set(key: .search, value: search)
let reply = try await client.send(message)

guard let responseData = reply.dataNoCopy(key: .volume) else {
throw VolumeError.volumeNotFound(search)
}

return try JSONDecoder().decode(Volume.self, from: responseData)
}

public static func inspect(_ name: String) async throws -> Volume {
let client = XPCClient(service: serviceIdentifier)
let message = XPCMessage(route: .volumeInspect)
Expand Down
62 changes: 62 additions & 0 deletions Sources/ContainerClient/Core/StringMatcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 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

/// A helper class for matching partial strings against a collection of candidates.
/// Used primarily for resolving partial container IDs to full IDs.
public struct StringMatcher {

/// Represents the result of a match operation
public enum MatchResult: Equatable {
case exactMatch(String)
case singleMatch(String)
case noMatch
case multipleMatches([String])
}

/// Matches a partial string against a collection of candidates using prefix matching.
///
/// Matching rules:
/// 1. Exact match takes precedence over prefix match
/// 2. Prefix matching is case-sensitive
/// 3. Only prefix matches are considered (no substring matching)
/// 4. If multiple candidates match, returns ambiguous result
///
/// - Parameters:
/// - partial: The partial string to match
/// - candidates: The collection of strings to match against
/// - Returns: A MatchResult indicating the outcome of the match operation
public static func match(partial: String, candidates: [String]) -> MatchResult {
var matches: [String] = []
for candidate in candidates {
if candidate == partial {
return .exactMatch(candidate)
} else {
if partial.count >= 2 && candidate.hasPrefix(partial) {
matches.append(candidate)
}
}
}
if matches.count == 0 {
return .noMatch
} else if matches.count == 1 {
return .singleMatch(matches[0])
} else {
return .multipleMatches(matches)
}
}
}
19 changes: 19 additions & 0 deletions Sources/ContainerClient/Core/XPC+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,17 @@ public enum XPCKeys: String {
case volumeLabels
case volumeReadonly
case volumeContainerId

/// Search terms
case searches
/// Single search term
case search
}

public enum XPCRoute: String {
case containerList
case containerSearch
case containerSearchOne
case containerCreate
case containerBootstrap
case containerCreateProcess
Expand All @@ -142,10 +149,14 @@ public enum XPCRoute: String {
case networkCreate
case networkDelete
case networkList
case networkSearch
case networkSearchOne

case volumeCreate
case volumeDelete
case volumeList
case volumeSearch
case volumeSearchOne
case volumeInspect
case volumePrune

Expand Down Expand Up @@ -243,6 +254,14 @@ extension XPCMessage {
public func set(key: XPCKeys, value: xpc_endpoint_t) {
set(key: key.rawValue, value: value)
}

public func stringArray(key: XPCKeys) -> [String]? {
stringArray(key: key.rawValue)
}

public func set(key: XPCKeys, value: [String]) throws {
try set(key: key.rawValue, value: value)
}
}

#endif
88 changes: 46 additions & 42 deletions Sources/ContainerCommands/Container/ContainerDelete.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,70 +53,74 @@ extension Application {
}

public mutating func run() async throws {
let set = Set<String>(containerIds)
var containers = [ClientContainer]()

if all {
containers = try await ClientContainer.list()
} else {
let ctrs = try await ClientContainer.list()
containers = ctrs.filter { c in
set.contains(c.id)
}
// If one of the containers requested isn't present, let's throw. We don't need to do
containers = try await ClientContainer.search(searches: containerIds)

// If one of the search terms requested has no containers present, let's throw. We don't need to do
// this for --all as --all should be perfectly usable with no containers to remove; otherwise,
// it'd be quite clunky.
if containers.count != set.count {
let missing = set.filter { id in
!containers.contains { c in
c.id == id
}
var missingTerms: [String] = []

for searchTerm in containerIds {
if StringMatcher.match(partial: searchTerm, candidates: containers.map({ $0.id })) == .noMatch {
missingTerms.append(searchTerm)
}
}

if missingTerms.count > 0 {
throw ContainerizationError(
.notFound,
message: "failed to delete one or more containers: \(missing)"
message: "failed to delete one or more containers: \(missingTerms)"
)
}
}

var failed = [String]()
let force = self.force
let all = self.all
try await withThrowingTaskGroup(of: String?.self) { group in
for container in containers {
group.addTask {
do {
if container.status == .running && !force {
guard all else {
throw ContainerizationError(.invalidState, message: "container is running")
}
return nil // Skip running container when using --all
}
let failed = try await deleteContainers(containers: containers, force: self.force, all: self.all)

if failed.count > 0 {
throw ContainerizationError(
.internalError,
message: "delete failed for one or more containers: \(failed)"
)
}
}
}

try await container.delete(force: force)
print(container.id)
return nil
} catch {
log.error("failed to delete container \(container.id): \(error)")
return container.id
static func deleteContainers(containers: [ClientContainer], force: Bool, all: Bool) async throws -> [String] {
var failed = [String]()
try await withThrowingTaskGroup(of: String?.self) { group in
for container in containers {
group.addTask {
do {
if container.status == .running && !force {
guard all else {
throw ContainerizationError(.invalidState, message: "container is running")
}
return nil // Skip running container when using --all
}
}
}

for try await ctr in group {
guard let ctr else {
continue
try await container.delete(force: force)
print(container.id)
return nil
} catch {
log.error("one or more search terms have no container matches \(container.id): \(error)")
return container.id
}
failed.append(ctr)
}
}

if failed.count > 0 {
throw ContainerizationError(
.internalError,
message: "delete failed for one or more containers: \(failed)"
)
for try await ctr in group {
guard let ctr else {
continue
}
failed.append(ctr)
}
}

return failed
}
}
2 changes: 1 addition & 1 deletion Sources/ContainerCommands/Container/ContainerExec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ extension Application {

public func run() async throws {
var exitCode: Int32 = 127
let container = try await ClientContainer.get(id: containerId)
let container = try await ClientContainer.searchOne(search: containerId)
try ensureRunning(container: container)

let stdin = self.processFlags.interactive
Expand Down
11 changes: 5 additions & 6 deletions Sources/ContainerCommands/Container/ContainerInspect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,11 @@ extension Application {
var containerIds: [String]

public func run() async throws {
let objects: [any Codable] = try await ClientContainer.list().filter {
containerIds.contains($0.id)
}.map {
PrintableContainer($0)
}
print(try objects.jsonArray())
let containers: [any Codable] = try await ClientContainer.search(
searches: containerIds
)
.map({ PrintableContainer($0) })
print(try containers.jsonArray())
}
}
}
Loading