diff --git a/Makefile b/Makefile index 241f9a6a..31816886 100644 --- a/Makefile +++ b/Makefile @@ -187,6 +187,7 @@ integration: init-block $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand1 || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand2 || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand3 || exit_code=1 ; \ + $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIPruneCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIStatsCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIImagesCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunBase || exit_code=1 ; \ diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index ff7b059c..266ab060 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -62,6 +62,7 @@ public struct Application: AsyncLoggableCommand { ContainerStart.self, ContainerStats.self, ContainerStop.self, + ContainerPrune.self, ] ), CommandGroup( diff --git a/Sources/ContainerCommands/Container/ContainerPrune.swift b/Sources/ContainerCommands/Container/ContainerPrune.swift new file mode 100644 index 00000000..23f10eca --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerPrune.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import ContainerAPIClient +import ContainerizationError +import Foundation + +extension Application { + public struct ContainerPrune: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "prune", + abstract: "Remove all stopped containers" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + public func run() async throws { + let containersToPrune = try await ClientContainer.list().filter { $0.status == .stopped } + + var prunedContainerIds = [String]() + var totalSize: UInt64 = 0 + + for container in containersToPrune { + do { + let actualSize = try await ClientContainer.containerDiskUsage(id: container.id) + totalSize += actualSize + try await container.delete() + prunedContainerIds.append(container.id) + } catch { + log.error("Failed to prune container \(container.id): \(error)") + } + } + + let formatter = ByteCountFormatter() + let freed = formatter.string(fromByteCount: Int64(totalSize)) + + for name in prunedContainerIds { + print(name) + } + print("Reclaimed \(freed) in disk space") + } + } +} diff --git a/Sources/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index abae90c5..c56fb4f4 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -242,6 +242,7 @@ extension APIServer { routes[XPCRoute.containerWait] = harness.wait routes[XPCRoute.containerKill] = harness.kill routes[XPCRoute.containerStats] = harness.stats + routes[XPCRoute.containerDiskUsage] = harness.diskUsage return service } diff --git a/Sources/Services/ContainerAPIService/Client/ClientContainer.swift b/Sources/Services/ContainerAPIService/Client/ClientContainer.swift index 0eb3dc6e..1894fd76 100644 --- a/Sources/Services/ContainerAPIService/Client/ClientContainer.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientContainer.swift @@ -228,6 +228,16 @@ extension ClientContainer { } } + public static func containerDiskUsage(id: String) async throws -> UInt64 { + let client = Self.newXPCClient() + let request = XPCMessage(route: .containerDiskUsage) + request.set(key: .id, value: id) + let reply = try await client.send(request) + + let size = reply.uint64(key: .containerSize) + return size + } + /// Create a new process inside a running container. The process is in a /// created state and must still be started. public func createProcess( diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 6a196ffc..ca838aad 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -110,6 +110,7 @@ public enum XPCKeys: String { case volume case volumes case volumeName + case volumeSize case volumeDriver case volumeDriverOpts case volumeLabels @@ -118,7 +119,7 @@ public enum XPCKeys: String { /// Container statistics case statistics - case volumeSize + case containerSize /// Disk usage case diskUsageStats @@ -140,6 +141,7 @@ public enum XPCRoute: String { case containerLogs case containerEvent case containerStats + case containerDiskUsage case pluginLoad case pluginGet diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index 7aa5f96d..b96d86de 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -256,6 +256,19 @@ public struct ContainersHarness: Sendable { return message.reply() } + @Sendable + public func diskUsage(_ message: XPCMessage) async throws -> XPCMessage { + guard let containerId = message.string(key: .id) else { + throw ContainerizationError(.invalidArgument, message: "id cannot be empty") + } + + let size = try await service.containerDiskUsage(id: containerId) + + let reply = message.reply() + reply.set(key: .containerSize, value: size) + return reply + } + @Sendable public func logs(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 30742b46..7b749f46 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -504,6 +504,14 @@ public actor ContainersService { } } + public func containerDiskUsage(id: String) async throws -> UInt64 { + self.log.debug("\(#function)") + + let containerPath = self.containerRoot.appendingPathComponent(id).path + + return Self.calculateDirectorySize(at: containerPath) + } + private func handleContainerExit(id: String, code: ExitStatus? = nil) async throws { try await self.lock.withLock { [self] context in try await handleContainerExit(id: id, code: code, context: context) diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIPrune.swift b/Tests/CLITests/Subcommands/Containers/TestCLIPrune.swift new file mode 100644 index 00000000..aedfe6d9 --- /dev/null +++ b/Tests/CLITests/Subcommands/Containers/TestCLIPrune.swift @@ -0,0 +1,87 @@ +//===----------------------------------------------------------------------===// +// 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 + +@Suite(.serialized) +class TestCLIPruneCommand: CLITest { + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + @Test func testContainerPruneNoContainers() throws { + let (_, output, error, status) = try run(arguments: ["prune"]) + if status != 0 { + throw CLIError.executionFailed("container prune failed: \(error)") + } + + #expect(output.contains("Reclaimed Zero KB in disk space"), "should show no containers message") + } + + @Test func testContainerPruneStoppedContainers() throws { + let testName = getTestName() + let npcName = "\(testName)_wont_be_pruned" + let pc0Name = "\(testName)_pruned_0" + let pc1Name = "\(testName)_pruned_1" + + try doLongRun(name: npcName, containerArgs: ["sleep", "3600"], autoRemove: true) + try doLongRun(name: pc0Name, containerArgs: ["sleep", "3600"], autoRemove: false) + try doLongRun(name: pc1Name, containerArgs: ["sleep", "3600"], autoRemove: false) + defer { + try? doStop(name: npcName) + try? doStop(name: pc0Name) + try? doStop(name: pc1Name) + try? doRemove(name: npcName) + try? doRemove(name: pc0Name) + try? doRemove(name: pc1Name) + } + try waitForContainerRunning(npcName) + try waitForContainerRunning(pc0Name) + try waitForContainerRunning(pc1Name) + + try doStop(name: pc0Name) + try doStop(name: pc1Name) + + let pc0Id = try getContainerId(pc0Name) + let pc1Id = try getContainerId(pc1Name) + + // Poll status until both containers are stopped, with interval checks and a timeout to avoid infinite loop + let start = Date() + let timeout: TimeInterval = 30 // seconds + while true { + let s0 = try getContainerStatus(pc0Name) + let s1 = try getContainerStatus(pc1Name) + if s0 == "stopped" && s1 == "stopped" { break } + if Date().timeIntervalSince(start) > timeout { + throw CLIError.executionFailed("Timeout waiting for containers to stop: pc0=\(s0), pc1=\(s1)") + } + Thread.sleep(forTimeInterval: 0.2) + } + + let (_, output, error, status) = try run(arguments: ["prune"]) + + if status != 0 { + throw CLIError.executionFailed("container prune failed: \(error)") + } + + #expect(output.contains(pc0Id) && output.contains(pc1Id), "should show the stopped containers id") + #expect(!output.contains("Reclaimed Zero KB in disk space"), "reclaimed spaces should not Zero KB") + + let checkStatus = try getContainerStatus(npcName) + #expect(checkStatus == "running", "not pruned container should still be running") + } +} diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index 59299464..fad18159 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -321,6 +321,10 @@ class CLITest { try inspectContainer(name).status } + func getContainerId(_ name: String) throws -> String { + try inspectContainer(name).configuration.id + } + func inspectContainer(_ name: String) throws -> inspectOutput { let response = try run(arguments: [ "inspect", diff --git a/docs/command-reference.md b/docs/command-reference.md index 873b8b43..cc68fab8 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -414,6 +414,20 @@ container stats --no-stream web container stats --format json --no-stream web ``` +### `container prune` + +Removes stopped containers to reclaim disk space. The command outputs the amount of space freed after deletion. + +**Usage** + +```bash +container prune [--debug] +``` + +**Options** + +No options. + ## Image Management ### `container image list (ls)`