Skip to content

Commit 72be5b2

Browse files
committed
container clean command
1 parent 6089024 commit 72be5b2

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/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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,6 @@ 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+
/// Clean up unused space in the container filesystem.
60+
case clean = "com.apple.container.runtime/clean"
5961
}

Sources/Services/RuntimeLinux/Server/RuntimeService.swift

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

775+
/// Clean up unused space in the container filesystem.
776+
///
777+
/// - Parameters:
778+
/// - message: An XPC message with the following parameters:
779+
/// - id: The container ID.
780+
///
781+
/// - Returns: An XPC message with no parameters.
782+
@Sendable
783+
public func clean(_ message: XPCMessage) async throws -> XPCMessage {
784+
self.log.info("`clean` xpc handler")
785+
switch self.state {
786+
case .running:
787+
guard let id = message.string(key: RuntimeKeys.id.rawValue) else {
788+
throw ContainerizationError(
789+
.invalidArgument,
790+
message: "no id supplied for clean"
791+
)
792+
}
793+
794+
let ctr = try getContainer()
795+
796+
// Perform filesystem trim on the root filesystem
797+
try await ctr.container.filesystemOperation(operation: .trim, path: "/")
798+
799+
// Perform filesystem trim on each named volume mount
800+
for mount in ctr.config.mounts {
801+
if case .volume = mount.type {
802+
try await ctr.container.filesystemOperation(operation: .trim, path: mount.destination)
803+
}
804+
}
805+
806+
return message.reply()
807+
default:
808+
throw ContainerizationError(
809+
.invalidState,
810+
message: "cannot clean: container is not running"
811+
)
812+
}
813+
}
814+
775815
/// Dial a vsock port on the virtual machine.
776816
///
777817
/// - Parameters:

0 commit comments

Comments
 (0)