Skip to content

Commit c9c01f4

Browse files
committed
container clean command implementation
1 parent 18b447a commit c9c01f4

12 files changed

Lines changed: 351 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/Services/ContainerAPIService/Client/ContainerClient.swift

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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ public enum XPCRoute: String {
167167
case containerCopyIn
168168
case containerCopyOut
169169
case containerExport
170+
case containerClean
170171

171172
case pluginLoad
172173
case pluginGet

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,4 +387,18 @@ public struct ContainersHarness: Sendable {
387387
try await service.exportRootfs(id: id, archive: archiveUrl, live: live)
388388
return message.reply()
389389
}
390+
391+
@Sendable
392+
public func clean(_ message: XPCMessage) async throws -> XPCMessage {
393+
let id = message.string(key: .id)
394+
guard let id else {
395+
throw ContainerizationError(
396+
.invalidArgument,
397+
message: "id cannot be empty"
398+
)
399+
}
400+
401+
try await service.clean(id: id)
402+
return message.reply()
403+
}
390404
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,18 @@ public actor ContainersService {
921921
try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive))
922922
}
923923

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

Sources/Services/RuntimeLinux/Server/RuntimeService.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,46 @@ public actor RuntimeService {
833833
}
834834
}
835835

836+
/// Clean up unused space in the container filesystem using FITRIM.
837+
///
838+
/// - Parameters:
839+
/// - message: An XPC message with the following parameters:
840+
/// - id: The container ID.
841+
///
842+
/// - Returns: An XPC message with no parameters.
843+
@Sendable
844+
public func clean(_ message: XPCMessage) async throws -> XPCMessage {
845+
self.log.info("`clean` xpc handler")
846+
switch self.state {
847+
case .running:
848+
guard let id = message.string(key: RuntimeKeys.id.rawValue) else {
849+
throw ContainerizationError(
850+
.invalidArgument,
851+
message: "no id supplied for clean"
852+
)
853+
}
854+
855+
let ctr = try getContainer()
856+
857+
// Perform trim on the root filesystem
858+
try await ctr.container.filesystemOperation(operation: .trim, path: "/")
859+
860+
// Perform trim on each named volume mount
861+
for mount in ctr.config.mounts {
862+
if case .volume = mount.type {
863+
try await ctr.container.filesystemOperation(operation: .trim, path: mount.destination)
864+
}
865+
}
866+
867+
return message.reply()
868+
default:
869+
throw ContainerizationError(
870+
.invalidState,
871+
message: "cannot clean: container is not running"
872+
)
873+
}
874+
}
875+
836876
/// Dial a vsock port on the virtual machine.
837877
///
838878
/// - Parameters:

0 commit comments

Comments
 (0)