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.
This commit is contained in:
Danny Canter
2026-02-11 15:09:21 -08:00
committed by GitHub
parent c9f81ca332
commit f7d00aad48
9 changed files with 101 additions and 47 deletions
@@ -16,6 +16,7 @@
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import ContainerizationOS
import Darwin
@@ -50,16 +51,13 @@ extension Application {
}
public mutating func run() async throws {
let set = Set<String>(containerIds)
let client = ContainerClient()
var containers = try await client.list().filter { c in
c.status == .running
}
if !self.all {
containers = containers.filter { c in
set.contains(c.id)
}
let containers: [String]
if self.all {
containers = try await client.list(filters: ContainerListFilters(status: .running)).map { $0.id }
} else {
containers = containerIds
}
let signalNumber = try Signals.parseSignal(signal)
@@ -67,8 +65,8 @@ extension Application {
var errors: [any Error] = []
for container in containers {
do {
try await client.kill(id: container.id, signal: signalNumber)
print(container.id)
try await client.kill(id: container, signal: signalNumber)
print(container)
} catch {
errors.append(error)
}
@@ -44,7 +44,8 @@ extension Application {
public func run() async throws {
let client = ContainerClient()
let containers = try await client.list()
let filters = self.all ? ContainerListFilters.all : ContainerListFilters(status: .running)
let containers = try await client.list(filters: filters)
try printContainers(containers: containers, format: format)
}
@@ -65,9 +66,6 @@ extension Application {
if self.quiet {
containers.forEach {
if !self.all && $0.status != .running {
return
}
print($0.id)
}
return
@@ -75,9 +73,6 @@ extension Application {
var rows = createHeader()
for container in containers {
if !self.all && container.status != .running {
continue
}
rows.append(container.asRow)
}
@@ -63,25 +63,23 @@ extension Application {
private func runStatic() async throws {
let client = ContainerClient()
let allContainers = try await client.list()
let containersToShow: [ContainerSnapshot]
if containers.isEmpty {
// No containers specified - show all running containers
containersToShow = allContainers.filter { $0.status == .running }
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
} else {
// Validate all specified containers exist before proceeding
var found: [ContainerSnapshot] = []
// Fetch specified containers by ID
containersToShow = try await client.list(filters: ContainerListFilters(ids: containers))
// Validate all specified containers were found
for containerId in containers {
guard let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) else {
guard containersToShow.contains(where: { $0.id == containerId }) else {
throw ContainerizationError(
.notFound,
message: "no such container: \(containerId)"
)
}
found.append(container)
}
containersToShow = found
}
let statsData = try await collectStats(client: client, for: containersToShow)
@@ -101,9 +99,9 @@ extension Application {
// If containers were specified, validate they all exist upfront
if !containers.isEmpty {
let allContainers = try await client.list()
let specifiedContainers = try await client.list(filters: ContainerListFilters(ids: containers))
for containerId in containers {
guard allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) != nil else {
guard specifiedContainers.contains(where: { $0.id == containerId }) else {
throw ContainerizationError(
.notFound,
message: "no such container: \(containerId)"
@@ -118,19 +116,11 @@ extension Application {
while true {
do {
let allContainers = try await client.list()
let containersToShow: [ContainerSnapshot]
if containers.isEmpty {
containersToShow = allContainers.filter { $0.status == .running }
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
} else {
var found: [ContainerSnapshot] = []
for containerId in containers {
if let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) {
found.append(container)
}
}
containersToShow = found
containersToShow = try await client.list(filters: ContainerListFilters(ids: containers))
}
let statsData = try await collectStats(client: client, for: containersToShow)
@@ -79,9 +79,8 @@ extension Application {
log.info("waiting for containers to exit")
do {
for _ in 0..<Self.shutdownTimeoutSeconds {
let anyRunning = try await client.list()
.contains { $0.status == .running }
guard anyRunning else {
let runningContainers = try await client.list(filters: ContainerListFilters(status: .running))
guard !runningContainers.isEmpty else {
break
}
try await Task.sleep(for: .seconds(1))
@@ -0,0 +1,40 @@
//===----------------------------------------------------------------------===//
// 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
/// Filters for listing containers.
public struct ContainerListFilters: Sendable, Codable {
/// Filter by container IDs. If non-empty, only containers with matching IDs are returned.
public var ids: [String]
/// Filter by container status.
public var status: RuntimeStatus?
/// Filter by labels. All specified labels must match.
public var labels: [String: String]
/// No filters applied. Will return all containers.
public static let all = ContainerListFilters()
public init(
ids: [String] = [],
status: RuntimeStatus? = nil,
labels: [String: String] = [:]
) {
self.ids = ids
self.status = status
self.labels = labels
}
}
@@ -75,10 +75,12 @@ public struct ContainerClient: Sendable {
}
}
/// List all containers.
public func list() async throws -> [ContainerSnapshot] {
/// List containers matching the given filters.
public func list(filters: ContainerListFilters = .all) async throws -> [ContainerSnapshot] {
do {
let request = XPCMessage(route: .containerList)
let filterData = try JSONEncoder().encode(filters)
request.set(key: .listFilters, value: filterData)
let response = try await xpcSend(
message: request,
@@ -100,8 +102,8 @@ public struct ContainerClient: Sendable {
/// Get the container for the provided id.
public func get(id: String) async throws -> ContainerSnapshot {
let containers = try await list()
guard let container = containers.first(where: { $0.configuration.id == id }) else {
let containers = try await list(filters: ContainerListFilters(ids: [id]))
guard let container = containers.first else {
throw ContainerizationError(
.notFound,
message: "get failed: container \(id) not found"
@@ -124,6 +124,9 @@ public enum XPCKeys: String {
case statistics
case containerSize
/// Container list filters
case listFilters
/// Disk usage
case diskUsageStats
}
@@ -33,7 +33,11 @@ public struct ContainersHarness: Sendable {
@Sendable
public func list(_ message: XPCMessage) async throws -> XPCMessage {
let containers = try await service.list()
var filters = ContainerListFilters.all
if let filterData = message.dataNoCopy(key: .listFilters) {
filters = try JSONDecoder().decode(ContainerListFilters.self, from: filterData)
}
let containers = try await service.list(filters: filters)
let data = try JSONEncoder().encode(containers)
let reply = message.reply()
@@ -106,10 +106,33 @@ public actor ContainersService {
return results
}
/// List all containers registered with the service.
public func list() async throws -> [ContainerSnapshot] {
/// List containers matching the given filters.
public func list(filters: ContainerListFilters = .all) async throws -> [ContainerSnapshot] {
self.log.debug("\(#function)")
return self.containers.values.map { $0.snapshot }
return self.containers.values.compactMap { state -> ContainerSnapshot? in
let snapshot = state.snapshot
if !filters.ids.isEmpty {
guard filters.ids.contains(snapshot.id) else {
return nil
}
}
if let status = filters.status {
guard snapshot.status == status else {
return nil
}
}
for (key, value) in filters.labels {
guard snapshot.configuration.labels[key] == value else {
return nil
}
}
return snapshot
}
}
/// Execute an operation with the current container list while maintaining atomicity