Skip to content

Commit 9854ded

Browse files
committed
container clean command
1 parent af405ff commit 9854ded

13 files changed

Lines changed: 356 additions & 0 deletions

File tree

Sources/APIServer/APIServer+Start.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ extension APIServer {
307307
routes[XPCRoute.containerCopyIn] = XPCServer.route(harness.copyIn)
308308
routes[XPCRoute.containerCopyOut] = XPCServer.route(harness.copyOut)
309309
routes[XPCRoute.containerExport] = XPCServer.route(harness.export)
310+
routes[XPCRoute.containerClean] = XPCServer.route(harness.clean)
310311

311312
return service
312313
}

Sources/ContainerCommands/Application.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public struct Application: AsyncLoggableCommand {
5454
CommandGroup(
5555
name: "Container",
5656
subcommands: [
57+
ContainerClean.self,
5758
ContainerCopy.self,
5859
ContainerCreate.self,
5960
ContainerDelete.self,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 ArgumentParser
18+
import ContainerAPIClient
19+
import ContainerizationError
20+
import Foundation
21+
22+
extension Application {
23+
public struct ContainerClean: AsyncLoggableCommand {
24+
public init() {}
25+
public static let configuration = CommandConfiguration(
26+
commandName: "clean",
27+
abstract: "Clean one or more running containers"
28+
)
29+
30+
@OptionGroup
31+
public var logOptions: Flags.Logging
32+
33+
@Argument(help: "Container IDs")
34+
var containerIds: [String] = []
35+
36+
public func validate() throws {
37+
if containerIds.count == 0 {
38+
throw ContainerizationError(.invalidArgument, message: "no containers specified")
39+
}
40+
}
41+
42+
public mutating func run() async throws {
43+
let client = ContainerClient()
44+
let containers = Array(Set(containerIds))
45+
46+
var errors: [any Error] = []
47+
try await withThrowingTaskGroup(of: (any Error)?.self) { group in
48+
for container in containers {
49+
group.addTask {
50+
do {
51+
try await client.clean(id: container)
52+
print(container)
53+
return nil
54+
} catch {
55+
return error
56+
}
57+
}
58+
}
59+
60+
for try await error in group {
61+
if let error {
62+
errors.append(error)
63+
}
64+
}
65+
}
66+
67+
if !errors.isEmpty {
68+
throw AggregateError(errors)
69+
}
70+
}
71+
}
72+
}

Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ extension ContainerFixture {
152152
public func doExport(_ name: String, to path: FilePath) throws {
153153
try run(["export", name, "-o", path.string]).check()
154154
}
155+
156+
/// Cleans a running container.
157+
func doClean(name: String) throws {
158+
try run(["clean", name]).check()
159+
}
155160
}
156161

157162
// MARK: - Inspect helpers

Sources/Services/ContainerAPIService/Client/ContainerClient.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,4 +390,19 @@ public struct ContainerClient: Sendable {
390390
)
391391
}
392392
}
393+
394+
public func clean(id: String) async throws {
395+
let request = XPCMessage(route: .containerClean)
396+
request.set(key: .id, value: id)
397+
398+
do {
399+
try await xpcClient.send(request)
400+
} catch {
401+
throw ContainerizationError(
402+
.internalError,
403+
message: "failed to clean container",
404+
cause: error
405+
)
406+
}
407+
}
393408
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ public enum XPCRoute: String {
165165
case containerCopyIn
166166
case containerCopyOut
167167
case containerExport
168+
case containerClean
168169

169170
case pluginLoad
170171
case pluginGet

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,4 +404,18 @@ public struct ContainersHarness: Sendable {
404404
try await service.exportRootfs(id: id, archive: archiveUrl)
405405
return message.reply()
406406
}
407+
408+
@Sendable
409+
public func clean(_ message: XPCMessage) async throws -> XPCMessage {
410+
let id = message.string(key: .id)
411+
guard let id else {
412+
throw ContainerizationError(
413+
.invalidArgument,
414+
message: "id cannot be empty"
415+
)
416+
}
417+
418+
try await service.clean(id: id)
419+
return message.reply()
420+
}
407421
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,18 @@ public actor ContainersService {
919919
}
920920
}
921921

922+
public func clean(id: String) async throws {
923+
self.log.debug("\(#function)")
924+
925+
let state = try self._getContainerState(id: id)
926+
guard state.snapshot.status == .running else {
927+
throw ContainerizationError(.invalidState, message: "container is not running")
928+
}
929+
930+
let client = try state.getClient()
931+
try await client.clean(id: id)
932+
}
933+
922934
private func handleContainerExit(id: String, code: ExitStatus? = nil) async throws {
923935
try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { [self] context in
924936
try await handleContainerExit(id: id, code: code, context: context)

Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,21 @@ extension RuntimeClient {
358358

359359
return try JSONDecoder().decode(ContainerStats.self, from: data)
360360
}
361+
362+
public func clean(id: String) async throws {
363+
let request = XPCMessage(route: RuntimeRoutes.clean.rawValue)
364+
request.set(key: RuntimeKeys.id.rawValue, value: id)
365+
366+
do {
367+
try await self.client.send(request)
368+
} catch {
369+
throw ContainerizationError(
370+
.internalError,
371+
message: "failed to clean container \(self.id)",
372+
cause: error
373+
)
374+
}
375+
}
361376
}
362377

363378
extension XPCMessage {

Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,6 @@ public enum RuntimeRoutes: String {
5858
case copyOut = "com.apple.container.runtime/copyOut"
5959
/// Snapshot the container's root filesystem to an image file.
6060
case snapshotDisk = "com.apple.container.runtime/snapshotDisk"
61+
/// Clean up unused space in the container filesystem.
62+
case clean = "com.apple.container.runtime/clean"
6163
}

0 commit comments

Comments
 (0)