Skip to content

Commit f7d00aa

Browse files
authored
APIServer: Add support for filtering to list rpc (#1175)
This is not intended to be used to support `--filter` or similar on the CLIs list yet, it's solely to clean up our rather awkward use of `ContainerClient.list()` today in the CLI. The list RPC simply returns all of the containers we have created. Because of this, for a LOT of our commands we filter to what we need client side, which feels like a waste.. This change introduces a filter struct that we can provide an array of container IDs, labels, and the status of the containers to filter the `list()` output from. This additionally, because it was killing (pun not intended) me and I was already having to change this area for the `list()` additions, changes container kill slightly to return an error if you try and kill a container that doesn't exist.
1 parent c9f81ca commit f7d00aa

9 files changed

Lines changed: 101 additions & 47 deletions

File tree

Sources/ContainerCommands/Container/ContainerKill.swift

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import ArgumentParser
1818
import ContainerAPIClient
19+
import ContainerResource
1920
import ContainerizationError
2021
import ContainerizationOS
2122
import Darwin
@@ -50,25 +51,22 @@ extension Application {
5051
}
5152

5253
public mutating func run() async throws {
53-
let set = Set<String>(containerIds)
5454
let client = ContainerClient()
5555

56-
var containers = try await client.list().filter { c in
57-
c.status == .running
58-
}
59-
if !self.all {
60-
containers = containers.filter { c in
61-
set.contains(c.id)
62-
}
56+
let containers: [String]
57+
if self.all {
58+
containers = try await client.list(filters: ContainerListFilters(status: .running)).map { $0.id }
59+
} else {
60+
containers = containerIds
6361
}
6462

6563
let signalNumber = try Signals.parseSignal(signal)
6664

6765
var errors: [any Error] = []
6866
for container in containers {
6967
do {
70-
try await client.kill(id: container.id, signal: signalNumber)
71-
print(container.id)
68+
try await client.kill(id: container, signal: signalNumber)
69+
print(container)
7270
} catch {
7371
errors.append(error)
7472
}

Sources/ContainerCommands/Container/ContainerList.swift

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ extension Application {
4444

4545
public func run() async throws {
4646
let client = ContainerClient()
47-
let containers = try await client.list()
47+
let filters = self.all ? ContainerListFilters.all : ContainerListFilters(status: .running)
48+
let containers = try await client.list(filters: filters)
4849
try printContainers(containers: containers, format: format)
4950
}
5051

@@ -65,19 +66,13 @@ extension Application {
6566

6667
if self.quiet {
6768
containers.forEach {
68-
if !self.all && $0.status != .running {
69-
return
70-
}
7169
print($0.id)
7270
}
7371
return
7472
}
7573

7674
var rows = createHeader()
7775
for container in containers {
78-
if !self.all && container.status != .running {
79-
continue
80-
}
8176
rows.append(container.asRow)
8277
}
8378

Sources/ContainerCommands/Container/ContainerStats.swift

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -63,25 +63,23 @@ extension Application {
6363

6464
private func runStatic() async throws {
6565
let client = ContainerClient()
66-
let allContainers = try await client.list()
6766

6867
let containersToShow: [ContainerSnapshot]
6968
if containers.isEmpty {
7069
// No containers specified - show all running containers
71-
containersToShow = allContainers.filter { $0.status == .running }
70+
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
7271
} else {
73-
// Validate all specified containers exist before proceeding
74-
var found: [ContainerSnapshot] = []
72+
// Fetch specified containers by ID
73+
containersToShow = try await client.list(filters: ContainerListFilters(ids: containers))
74+
// Validate all specified containers were found
7575
for containerId in containers {
76-
guard let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) else {
76+
guard containersToShow.contains(where: { $0.id == containerId }) else {
7777
throw ContainerizationError(
7878
.notFound,
7979
message: "no such container: \(containerId)"
8080
)
8181
}
82-
found.append(container)
8382
}
84-
containersToShow = found
8583
}
8684

8785
let statsData = try await collectStats(client: client, for: containersToShow)
@@ -101,9 +99,9 @@ extension Application {
10199

102100
// If containers were specified, validate they all exist upfront
103101
if !containers.isEmpty {
104-
let allContainers = try await client.list()
102+
let specifiedContainers = try await client.list(filters: ContainerListFilters(ids: containers))
105103
for containerId in containers {
106-
guard allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) != nil else {
104+
guard specifiedContainers.contains(where: { $0.id == containerId }) else {
107105
throw ContainerizationError(
108106
.notFound,
109107
message: "no such container: \(containerId)"
@@ -118,19 +116,11 @@ extension Application {
118116

119117
while true {
120118
do {
121-
let allContainers = try await client.list()
122-
123119
let containersToShow: [ContainerSnapshot]
124120
if containers.isEmpty {
125-
containersToShow = allContainers.filter { $0.status == .running }
121+
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
126122
} else {
127-
var found: [ContainerSnapshot] = []
128-
for containerId in containers {
129-
if let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) {
130-
found.append(container)
131-
}
132-
}
133-
containersToShow = found
123+
containersToShow = try await client.list(filters: ContainerListFilters(ids: containers))
134124
}
135125

136126
let statsData = try await collectStats(client: client, for: containersToShow)

Sources/ContainerCommands/System/SystemStop.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,8 @@ extension Application {
7979
log.info("waiting for containers to exit")
8080
do {
8181
for _ in 0..<Self.shutdownTimeoutSeconds {
82-
let anyRunning = try await client.list()
83-
.contains { $0.status == .running }
84-
guard anyRunning else {
82+
let runningContainers = try await client.list(filters: ContainerListFilters(status: .running))
83+
guard !runningContainers.isEmpty else {
8584
break
8685
}
8786
try await Task.sleep(for: .seconds(1))
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
19+
/// Filters for listing containers.
20+
public struct ContainerListFilters: Sendable, Codable {
21+
/// Filter by container IDs. If non-empty, only containers with matching IDs are returned.
22+
public var ids: [String]
23+
/// Filter by container status.
24+
public var status: RuntimeStatus?
25+
/// Filter by labels. All specified labels must match.
26+
public var labels: [String: String]
27+
28+
/// No filters applied. Will return all containers.
29+
public static let all = ContainerListFilters()
30+
31+
public init(
32+
ids: [String] = [],
33+
status: RuntimeStatus? = nil,
34+
labels: [String: String] = [:]
35+
) {
36+
self.ids = ids
37+
self.status = status
38+
self.labels = labels
39+
}
40+
}

Sources/Services/ContainerAPIService/Client/ContainerClient.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,12 @@ public struct ContainerClient: Sendable {
7575
}
7676
}
7777

78-
/// List all containers.
79-
public func list() async throws -> [ContainerSnapshot] {
78+
/// List containers matching the given filters.
79+
public func list(filters: ContainerListFilters = .all) async throws -> [ContainerSnapshot] {
8080
do {
8181
let request = XPCMessage(route: .containerList)
82+
let filterData = try JSONEncoder().encode(filters)
83+
request.set(key: .listFilters, value: filterData)
8284

8385
let response = try await xpcSend(
8486
message: request,
@@ -100,8 +102,8 @@ public struct ContainerClient: Sendable {
100102

101103
/// Get the container for the provided id.
102104
public func get(id: String) async throws -> ContainerSnapshot {
103-
let containers = try await list()
104-
guard let container = containers.first(where: { $0.configuration.id == id }) else {
105+
let containers = try await list(filters: ContainerListFilters(ids: [id]))
106+
guard let container = containers.first else {
105107
throw ContainerizationError(
106108
.notFound,
107109
message: "get failed: container \(id) not found"

Sources/Services/ContainerAPIService/Client/XPC+.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ public enum XPCKeys: String {
124124
case statistics
125125
case containerSize
126126

127+
/// Container list filters
128+
case listFilters
129+
127130
/// Disk usage
128131
case diskUsageStats
129132
}

Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ public struct ContainersHarness: Sendable {
3333

3434
@Sendable
3535
public func list(_ message: XPCMessage) async throws -> XPCMessage {
36-
let containers = try await service.list()
36+
var filters = ContainerListFilters.all
37+
if let filterData = message.dataNoCopy(key: .listFilters) {
38+
filters = try JSONDecoder().decode(ContainerListFilters.self, from: filterData)
39+
}
40+
let containers = try await service.list(filters: filters)
3741
let data = try JSONEncoder().encode(containers)
3842

3943
let reply = message.reply()

Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,33 @@ public actor ContainersService {
106106
return results
107107
}
108108

109-
/// List all containers registered with the service.
110-
public func list() async throws -> [ContainerSnapshot] {
109+
/// List containers matching the given filters.
110+
public func list(filters: ContainerListFilters = .all) async throws -> [ContainerSnapshot] {
111111
self.log.debug("\(#function)")
112-
return self.containers.values.map { $0.snapshot }
112+
113+
return self.containers.values.compactMap { state -> ContainerSnapshot? in
114+
let snapshot = state.snapshot
115+
116+
if !filters.ids.isEmpty {
117+
guard filters.ids.contains(snapshot.id) else {
118+
return nil
119+
}
120+
}
121+
122+
if let status = filters.status {
123+
guard snapshot.status == status else {
124+
return nil
125+
}
126+
}
127+
128+
for (key, value) in filters.labels {
129+
guard snapshot.configuration.labels[key] == value else {
130+
return nil
131+
}
132+
}
133+
134+
return snapshot
135+
}
113136
}
114137

115138
/// Execute an operation with the current container list while maintaining atomicity

0 commit comments

Comments
 (0)