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/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/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..ea0d1e998 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIClean.swift @@ -0,0 +1,199 @@ +//===----------------------------------------------------------------------===// +// 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 +class TestCLIClean: CLITest { + private struct StatusJSON: Codable { + let appRoot: String + } + + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + 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) + } + + 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) + } + + private func volumeBlockURL(name: String) throws -> URL { + try appRoot() + .appendingPathComponent("volumes", isDirectory: true) + .appendingPathComponent(name, isDirectory: true) + .appendingPathComponent("volume.img", isDirectory: false) + } + + 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 { + 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) + + 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/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) + #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) + + 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/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) + #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.