From 083b3eccb3c18cb158bab13993d32d9d84bb65d4 Mon Sep 17 00:00:00 2001 From: saehejkang <20051028+saehejkang@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:27:57 -0700 Subject: [PATCH 1/2] container clean command --- Sources/APIServer/APIServer+Start.swift | 1 + Sources/ContainerCommands/Application.swift | 1 + .../Container/ContainerClean.swift | 72 ++++++++ .../Client/ContainerClient.swift | 15 ++ .../ContainerAPIService/Client/XPC+.swift | 1 + .../Server/Containers/ContainersHarness.swift | 14 ++ .../Server/Containers/ContainersService.swift | 12 ++ .../Runtime/RuntimeClient/RuntimeClient.swift | 15 ++ .../Runtime/RuntimeClient/RuntimeRoutes.swift | 2 + .../RuntimeLinux/Server/RuntimeService.swift | 40 +++++ .../Containers/TestCLIClean.swift | 154 ++++++++++++++++++ .../ContainerFixture+ContainerHelpers.swift | 5 + docs/command-reference.md | 24 +++ 13 files changed, 356 insertions(+) create mode 100644 Sources/ContainerCommands/Container/ContainerClean.swift create mode 100644 Tests/IntegrationTests/Containers/TestCLIClean.swift diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index b6038b550..cb364226b 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -307,6 +307,7 @@ extension APIServer { routes[XPCRoute.containerCopyIn] = XPCServer.route(harness.copyIn) routes[XPCRoute.containerCopyOut] = XPCServer.route(harness.copyOut) routes[XPCRoute.containerExport] = XPCServer.route(harness.export) + routes[XPCRoute.containerClean] = XPCServer.route(harness.clean) return service } diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index c77f51a59..1402a6f52 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -54,6 +54,7 @@ public struct Application: AsyncLoggableCommand { CommandGroup( name: "Container", subcommands: [ + ContainerClean.self, ContainerCopy.self, ContainerCreate.self, ContainerDelete.self, diff --git a/Sources/ContainerCommands/Container/ContainerClean.swift b/Sources/ContainerCommands/Container/ContainerClean.swift new file mode 100644 index 000000000..22200d596 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerClean.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerClean: AsyncLoggableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "clean", + abstract: "Clean one or more running containers" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Container IDs") + var containerIds: [String] = [] + + public func validate() throws { + if containerIds.count == 0 { + throw ContainerizationError(.invalidArgument, message: "no containers specified") + } + } + + public mutating func run() async throws { + let client = ContainerClient() + let containers = Array(Set(containerIds)) + + var errors: [any Error] = [] + try await withThrowingTaskGroup(of: (any Error)?.self) { group in + for container in containers { + group.addTask { + do { + try await client.clean(id: container) + print(container) + return nil + } catch { + return error + } + } + } + + for try await error in group { + if let error { + errors.append(error) + } + } + } + + if !errors.isEmpty { + throw AggregateError(errors) + } + } + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index b1b64a66e..8469a2d07 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -388,4 +388,19 @@ public struct ContainerClient: Sendable { ) } } + + public func clean(id: String) async throws { + let request = XPCMessage(route: .containerClean) + request.set(key: .id, value: id) + + do { + try await xpcClient.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to clean container", + cause: error + ) + } + } } diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index a4d5aebd3..7fdd83537 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -165,6 +165,7 @@ public enum XPCRoute: String { case containerCopyIn case containerCopyOut case containerExport + case containerClean case pluginLoad case pluginGet diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index d7da46e3d..fde4656a2 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -386,4 +386,18 @@ public struct ContainersHarness: Sendable { try await service.exportRootfs(id: id, archive: archiveUrl) return message.reply() } + + @Sendable + public func clean(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + + try await service.clean(id: id) + return message.reply() + } } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 41f33d491..8ae4b855a 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -911,6 +911,18 @@ public actor ContainersService { try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive)) } + public func clean(id: String) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + guard state.snapshot.status == .running else { + throw ContainerizationError(.invalidState, message: "container is not running") + } + + let client = try state.getClient() + try await client.clean(id: id) + } + private func handleContainerExit(id: String, code: ExitStatus? = nil) async throws { try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { [self] context in try await handleContainerExit(id: id, code: code, context: context) diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift index 087a81204..e1306a6e4 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift @@ -342,6 +342,21 @@ extension RuntimeClient { return try JSONDecoder().decode(ContainerStats.self, from: data) } + + public func clean(id: String) async throws { + let request = XPCMessage(route: RuntimeRoutes.clean.rawValue) + request.set(key: RuntimeKeys.id.rawValue, value: id) + + do { + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to clean container \(self.id)", + cause: error + ) + } + } } extension XPCMessage { diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift index cda604387..1c8aae6d9 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift @@ -56,4 +56,6 @@ public enum RuntimeRoutes: String { case copyIn = "com.apple.container.runtime/copyIn" /// Copy a file or directory out of the container. case copyOut = "com.apple.container.runtime/copyOut" + /// Clean up unused space in the container filesystem. + case clean = "com.apple.container.runtime/clean" } diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 2320ff455..87d690b1c 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -772,6 +772,46 @@ public actor RuntimeService { } } + /// Clean up unused space in the container filesystem. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - id: The container ID. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func clean(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`clean` xpc handler") + switch self.state { + case .running: + guard message.string(key: RuntimeKeys.id.rawValue) != nil else { + throw ContainerizationError( + .invalidArgument, + message: "no id supplied for clean" + ) + } + + let ctr = try getContainer() + + // Perform filesystem trim on the root filesystem + try await ctr.container.filesystemOperation(operation: .trim, path: "/") + + // Perform filesystem trim on each named volume mount + for mount in ctr.config.mounts { + if case .volume = mount.type { + try await ctr.container.filesystemOperation(operation: .trim, path: mount.destination) + } + } + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot clean: container is not running" + ) + } + } + /// Dial a vsock port on the virtual machine. /// /// - Parameters: diff --git a/Tests/IntegrationTests/Containers/TestCLIClean.swift b/Tests/IntegrationTests/Containers/TestCLIClean.swift new file mode 100644 index 000000000..392abea5f --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIClean.swift @@ -0,0 +1,154 @@ +//===----------------------------------------------------------------------===// +// 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(.serialSuites) +class TestCLIClean: CLITest { + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + @Test func testCleanRunningContainer() throws { + let name = getTestName() + try doLongRun(name: name, autoRemove: false) + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + + try waitForContainerRunning(name) + + // Clean should succeed on running container + try doClean(name: name) + + // Container should still be running after clean + let status = try getContainerStatus(name) + #expect(status == "running") + } + + @Test func testCleanStoppedContainerFails() throws { + let name = getTestName() + try doLongRun(name: name, autoRemove: false) + defer { try? doRemove(name: name) } + + try waitForContainerRunning(name) + try doStop(name: name) + + let status = try getContainerStatus(name) + #expect(status == "stopped") + + // Clean should fail on stopped container + let (_, _, _, exitStatus) = try run(arguments: ["clean", name]) + #expect(exitStatus != 0) + } + + @Test func testCleanMultipleContainers() throws { + let name1 = getTestName() + "1" + let name2 = getTestName() + "2" + + try doLongRun(name: name1, autoRemove: false) + try doLongRun(name: name2, autoRemove: false) + + defer { + try? doStop(name: name1) + try? doStop(name: name2) + try? doRemove(name: name1) + try? doRemove(name: name2) + } + + try waitForContainerRunning(name1) + try waitForContainerRunning(name2) + + // Clean both containers + let (_, _, _, exitStatus1) = try run(arguments: ["clean", name1, name2]) + #expect(exitStatus1 == 0) + + // Both containers should still be running + let status1 = try getContainerStatus(name1) + let status2 = try getContainerStatus(name2) + #expect(status1 == "running") + #expect(status2 == "running") + } + + @Test func testCleanAfterFileCreation() throws { + let name = getTestName() + try doLongRun(name: name, autoRemove: false) + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + + try waitForContainerRunning(name) + + // Create some files to exercise the filesystem trim path + _ = try doExec(name: name, cmd: ["sh", "-c", "dd if=/dev/zero of=/test-file bs=1M count=10"]) + _ = try doExec(name: name, cmd: ["rm", "/test-file"]) + + // Clean should succeed + try doClean(name: name) + + // Container should still be running + let status = try getContainerStatus(name) + #expect(status == "running") + } + + @Test func testCleanWithVolume() throws { + let name = getTestName() + let volumeName = name + "-vol" + + // Create a volume + let (_, _, volError, volStatus) = try run(arguments: ["volume", "create", volumeName]) + guard volStatus == 0 else { + throw CLIError.executionFailed("volume create failed: \(volError)") + } + + defer { + try? run(arguments: ["volume", "rm", volumeName]) + } + + // Create container with volume mount + try doCreate( + name: name, + image: nil, + args: nil, + volumes: ["\(volumeName):/mnt/vol"], + networks: [], + ports: [] + ) + + try doStart(name: name) + + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + + try waitForContainerRunning(name) + + // Write to volume + _ = try doExec(name: name, cmd: ["sh", "-c", "dd if=/dev/zero of=/mnt/vol/test bs=1M count=5"]) + _ = try doExec(name: name, cmd: ["rm", "/mnt/vol/test"]) + + // Clean should succeed and also trim the volume + try doClean(name: name) + + // Container should still be running + let status = try getContainerStatus(name) + #expect(status == "running") + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift b/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift index e628cd4c1..3682de211 100644 --- a/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift +++ b/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift @@ -145,6 +145,11 @@ extension ContainerFixture { func doExport(_ name: String, to path: FilePath) throws { try run(["export", name, "-o", path.string]).check() } + + /// Cleans a running container. + func doClean(name: String) throws { + try run(["clean", name]).check() + } } // MARK: - Inspect helpers diff --git a/docs/command-reference.md b/docs/command-reference.md index c7d854806..f6597220c 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -408,6 +408,30 @@ container export -o mycontainer.tar mycontainer container export mycontainer > mycontainer.tar ``` +### `container clean` + +Cleans unused space on the root filesystem and each named volume mount in one or more running containers. The command only works while the container is running. + +**Usage** + +```bash +container clean [--debug] ... +``` + +**Arguments** + +* ``: Container IDs + +**Examples** + +```bash +# clean a single running container +container clean mycontainer + +# clean multiple running containers +container clean mycontainer1 mycontainer2 +``` + ### `container logs` Fetches logs from a container. You can follow the logs (`-f`/`--follow`), restrict the number of lines shown, or view boot logs. From b0560a219bc98e8300d70332a8fb8ff50d9261d9 Mon Sep 17 00:00:00 2001 From: saehejkang <20051028+saehejkang@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:49:57 -0700 Subject: [PATCH 2/2] add missing route + update tests --- .../RuntimeLinuxHelper+Start.swift | 1 + .../Containers/TestCLIClean.swift | 75 +++++++++++++++---- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift index 3c7938b8e..0eb336713 100644 --- a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -106,6 +106,7 @@ extension RuntimeLinuxHelper { RuntimeRoutes.statistics.rawValue: XPCServer.route(server.statistics), RuntimeRoutes.copyIn.rawValue: XPCServer.route(server.copyIn), RuntimeRoutes.copyOut.rawValue: XPCServer.route(server.copyOut), + RuntimeRoutes.clean.rawValue: XPCServer.route(server.clean), ], log: log ) diff --git a/Tests/IntegrationTests/Containers/TestCLIClean.swift b/Tests/IntegrationTests/Containers/TestCLIClean.swift index 392abea5f..ea0d1e998 100644 --- a/Tests/IntegrationTests/Containers/TestCLIClean.swift +++ b/Tests/IntegrationTests/Containers/TestCLIClean.swift @@ -17,28 +17,55 @@ import Foundation import Testing -@Suite(.serialSuites) +@Suite class TestCLIClean: CLITest { + private struct StatusJSON: Codable { + let appRoot: String + } + private func getTestName() -> String { Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() } - @Test func testCleanRunningContainer() throws { - let name = getTestName() - try doLongRun(name: name, autoRemove: false) - defer { - try? doStop(name: name) - try? doRemove(name: name) + private func appRoot() throws -> URL { + let result = try run(arguments: ["system", "status", "--format", "json"]).check() + let status = try JSONDecoder().decode(StatusJSON.self, from: result.outputData) + return URL(fileURLWithPath: status.appRoot, isDirectory: true) + } + + private func allocatedBytes(at url: URL) throws -> Int64 { + let values = try url.resourceValues(forKeys: [.fileAllocatedSizeKey, .totalFileAllocatedSizeKey]) + let allocated = values.totalFileAllocatedSize ?? values.fileAllocatedSize + guard let allocated else { + throw CLIError.executionFailed("failed to read allocated size for \(url.path)") } + return Int64(allocated) + } - try waitForContainerRunning(name) + private func containerRootfsBlockURL(name: String) throws -> URL { + let id = try getContainerId(name) + return try appRoot() + .appendingPathComponent("containers", isDirectory: true) + .appendingPathComponent(id, isDirectory: true) + .appendingPathComponent("rootfs.ext4", isDirectory: false) + } - // Clean should succeed on running container - try doClean(name: name) + private func volumeBlockURL(name: String) throws -> URL { + try appRoot() + .appendingPathComponent("volumes", isDirectory: true) + .appendingPathComponent(name, isDirectory: true) + .appendingPathComponent("volume.img", isDirectory: false) + } - // Container should still be running after clean - let status = try getContainerStatus(name) - #expect(status == "running") + private func assertCleanReclaimedSpace(beforeWrite: Int64, afterWrite: Int64, afterClean: Int64) { + let writeAllocated = afterWrite - beforeWrite + #expect(writeAllocated > 0) + + let reclaimed = afterWrite - afterClean + #expect(reclaimed > 0) + + let minExpectedReclaimed = Int64(Double(writeAllocated) * 0.8) + #expect(reclaimed >= minExpectedReclaimed) } @Test func testCleanStoppedContainerFails() throws { @@ -95,12 +122,21 @@ class TestCLIClean: CLITest { try waitForContainerRunning(name) + let rootfsBlockURL = try containerRootfsBlockURL(name: name) + let beforeWrite = try allocatedBytes(at: rootfsBlockURL) + // Create some files to exercise the filesystem trim path - _ = try doExec(name: name, cmd: ["sh", "-c", "dd if=/dev/zero of=/test-file bs=1M count=10"]) + _ = try doExec(name: name, cmd: ["sh", "-c", "dd if=/dev/urandom of=/test-file bs=1M count=10"]) + _ = try doExec(name: name, cmd: ["sync"]) + let afterWrite = try allocatedBytes(at: rootfsBlockURL) + _ = try doExec(name: name, cmd: ["rm", "/test-file"]) // Clean should succeed try doClean(name: name) + _ = try doExec(name: name, cmd: ["sync"]) + let afterClean = try allocatedBytes(at: rootfsBlockURL) + assertCleanReclaimedSpace(beforeWrite: beforeWrite, afterWrite: afterWrite, afterClean: afterClean) // Container should still be running let status = try getContainerStatus(name) @@ -140,12 +176,21 @@ class TestCLIClean: CLITest { try waitForContainerRunning(name) + let volumeBlockURL = try volumeBlockURL(name: volumeName) + let beforeWrite = try allocatedBytes(at: volumeBlockURL) + // Write to volume - _ = try doExec(name: name, cmd: ["sh", "-c", "dd if=/dev/zero of=/mnt/vol/test bs=1M count=5"]) + _ = try doExec(name: name, cmd: ["sh", "-c", "dd if=/dev/urandom of=/mnt/vol/test bs=1M count=5"]) + _ = try doExec(name: name, cmd: ["sync"]) + let afterWrite = try allocatedBytes(at: volumeBlockURL) + _ = try doExec(name: name, cmd: ["rm", "/mnt/vol/test"]) // Clean should succeed and also trim the volume try doClean(name: name) + _ = try doExec(name: name, cmd: ["sync"]) + let afterClean = try allocatedBytes(at: volumeBlockURL) + assertCleanReclaimedSpace(beforeWrite: beforeWrite, afterWrite: afterWrite, afterClean: afterClean) // Container should still be running let status = try getContainerStatus(name)