Implement container prune (#904)

- Fixed #892.
- By contrast with `rm`, `prune` should display
  the amount of reclaimed storage, so added code
  to retrieve it.

Signed-off-by: ChengHao Yang <17496418+tico88612@users.noreply.github.com>
This commit is contained in:
ChengHao Yang
2026-01-27 14:55:49 -08:00
committed by GitHub
parent a57527e4b0
commit 24cbed56b3
11 changed files with 202 additions and 1 deletions
+1
View File
@@ -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 ; \
@@ -62,6 +62,7 @@ public struct Application: AsyncLoggableCommand {
ContainerStart.self,
ContainerStats.self,
ContainerStop.self,
ContainerPrune.self,
]
),
CommandGroup(
@@ -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")
}
}
}
@@ -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
}
@@ -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(
@@ -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
@@ -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)
@@ -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)
@@ -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")
}
}
+4
View File
@@ -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",
+14
View File
@@ -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)`