mirror of
https://github.com/apple/container.git
synced 2026-09-22 23:55:34 +00:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)`
|
||||
|
||||
Reference in New Issue
Block a user