Skip to content

Commit 65a1d09

Browse files
committed
container clean command implementation
1 parent 6089024 commit 65a1d09

12 files changed

Lines changed: 415 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
@@ -388,4 +388,19 @@ public struct ContainerClient: Sendable {
388388
)
389389
}
390390
}
391+
392+
public func clean(id: String) async throws {
393+
let request = XPCMessage(route: .containerClean)
394+
request.set(key: .id, value: id)
395+
396+
do {
397+
try await xpcClient.send(request)
398+
} catch {
399+
throw ContainerizationError(
400+
.internalError,
401+
message: "failed to clean container",
402+
cause: error
403+
)
404+
}
405+
}
391406
}

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
@@ -386,4 +386,18 @@ public struct ContainersHarness: Sendable {
386386
try await service.exportRootfs(id: id, archive: archiveUrl)
387387
return message.reply()
388388
}
389+
390+
@Sendable
391+
public func clean(_ message: XPCMessage) async throws -> XPCMessage {
392+
let id = message.string(key: .id)
393+
guard let id else {
394+
throw ContainerizationError(
395+
.invalidArgument,
396+
message: "id cannot be empty"
397+
)
398+
}
399+
400+
try await service.clean(id: id)
401+
return message.reply()
402+
}
389403
}

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

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

914+
public func clean(id: String) async throws {
915+
self.log.debug("\(#function)")
916+
917+
let state = try self._getContainerState(id: id)
918+
guard state.snapshot.status == .running else {
919+
throw ContainerizationError(.invalidState, message: "container is not running")
920+
}
921+
922+
let client = try state.getClient()
923+
try await client.clean(id: id)
924+
}
925+
914926
private func handleContainerExit(id: String, code: ExitStatus? = nil) async throws {
915927
try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { [self] context in
916928
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
@@ -342,6 +342,21 @@ extension RuntimeClient {
342342

343343
return try JSONDecoder().decode(ContainerStats.self, from: data)
344344
}
345+
346+
public func clean(id: String) async throws {
347+
let request = XPCMessage(route: RuntimeRoutes.clean.rawValue)
348+
request.set(key: RuntimeKeys.id.rawValue, value: id)
349+
350+
do {
351+
try await self.client.send(request)
352+
} catch {
353+
throw ContainerizationError(
354+
.internalError,
355+
message: "failed to clean container \(self.id)",
356+
cause: error
357+
)
358+
}
359+
}
345360
}
346361

347362
extension XPCMessage {

Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,8 @@ public enum RuntimeRoutes: String {
5656
case copyIn = "com.apple.container.runtime/copyIn"
5757
/// Copy a file or directory out of the container.
5858
case copyOut = "com.apple.container.runtime/copyOut"
59+
/// Snapshot the container's root filesystem to an image file.
60+
case snapshotDisk = "com.apple.container.runtime/snapshotDisk"
61+
/// Clean up unused space in the container filesystem.
62+
case clean = "com.apple.container.runtime/clean"
5963
}

Sources/Services/RuntimeLinux/Server/RuntimeService.swift

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,108 @@ public actor RuntimeService {
772772
}
773773
}
774774

775+
<<<<<<< HEAD
776+
=======
777+
/// Snapshot the container's root filesystem by freezing it, cloning it to a destination image,
778+
/// and then thawing it. This ensures the filesystem is frozen for the minimal duration.
779+
///
780+
/// - Parameters:
781+
/// - message: An XPC message with the following parameters:
782+
/// - imagePath: The path to the source filesystem image.
783+
/// - destinationPath: The path where the snapshot will be written.
784+
///
785+
/// - Returns: An XPC message with no parameters.
786+
@Sendable
787+
public func snapshotDisk(_ message: XPCMessage) async throws -> XPCMessage {
788+
self.log.info("`snapshotDisk` xpc handler")
789+
switch self.state {
790+
case .running, .booted:
791+
guard let imagePath = message.string(key: RuntimeKeys.imagePath.rawValue) else {
792+
throw ContainerizationError(
793+
.invalidArgument,
794+
message: "no image path supplied for snapshotDisk"
795+
)
796+
}
797+
guard let destinationPath = message.string(key: RuntimeKeys.destinationPath.rawValue) else {
798+
throw ContainerizationError(
799+
.invalidArgument,
800+
message: "no destination path supplied for snapshotDisk"
801+
)
802+
}
803+
804+
let ctr = try getContainer()
805+
806+
// Freeze the filesystem
807+
try await ctr.container.filesystemOperation(operation: .freeze, path: "/")
808+
809+
do {
810+
// Clone the filesystem image atomically while frozen
811+
try FileManager.default.copyItem(atPath: imagePath, toPath: destinationPath)
812+
} catch {
813+
// Ensure we thaw even on error
814+
do {
815+
try await ctr.container.filesystemOperation(operation: .thaw, path: "/")
816+
} catch {
817+
self.log.error(
818+
"failed to thaw filesystem after snapshotDisk error",
819+
metadata: [
820+
"error": "\(error)"
821+
])
822+
}
823+
throw error
824+
}
825+
826+
// Thaw the filesystem
827+
try await ctr.container.filesystemOperation(operation: .thaw, path: "/")
828+
829+
return message.reply()
830+
default:
831+
throw ContainerizationError(
832+
.invalidState,
833+
message: "cannot snapshot disk: container is not running"
834+
)
835+
}
836+
}
837+
838+
/// Clean up unused space in the container filesystem using FITRIM.
839+
///
840+
/// - Parameters:
841+
/// - message: An XPC message with the following parameters:
842+
/// - id: The container ID.
843+
///
844+
/// - Returns: An XPC message with no parameters.
845+
@Sendable
846+
public func clean(_ message: XPCMessage) async throws -> XPCMessage {
847+
self.log.info("`clean` xpc handler")
848+
switch self.state {
849+
case .running:
850+
guard let id = message.string(key: RuntimeKeys.id.rawValue) else {
851+
throw ContainerizationError(
852+
.invalidArgument,
853+
message: "no id supplied for clean"
854+
)
855+
}
856+
857+
let ctr = try getContainer()
858+
859+
// Perform trim on the root filesystem
860+
try await ctr.container.filesystemOperation(operation: .trim, path: "/")
861+
862+
// Perform trim on each named volume mount
863+
for mount in ctr.config.mounts {
864+
if case .volume = mount.type {
865+
try await ctr.container.filesystemOperation(operation: .trim, path: mount.destination)
866+
}
867+
}
868+
869+
return message.reply()
870+
default:
871+
throw ContainerizationError(
872+
.invalidState,
873+
message: "cannot clean: container is not running"
874+
)
875+
}
876+
}
775877
/// Dial a vsock port on the virtual machine.
776878
///
777879
/// - Parameters:

0 commit comments

Comments
 (0)