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/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift index 6cc2ace44..73c6c45b9 100644 --- a/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+ContainerHelpers.swift @@ -152,6 +152,11 @@ extension ContainerFixture { public func doExport(_ name: String, to path: FilePath) throws { try run(["export", name, "-o", path.string]).check() } + + /// Cleans a running container. + public func doClean(_ name: String) throws { + try run(["clean", name]).check() + } } // MARK: - Inspect helpers diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift index d4c049b4b..59d032657 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), RuntimeRoutes.snapshotDisk.rawValue: XPCServer.route(server.snapshotDisk), ], log: log diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 5a2b6d0d3..b52538d94 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -390,4 +390,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 1871cd149..72f78b337 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -404,4 +404,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 81612495f..dbc1bbb55 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -919,6 +919,18 @@ public actor ContainersService { } } + 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() + } + 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 32a4db062..17b72cecc 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift @@ -358,6 +358,21 @@ extension RuntimeClient { return try JSONDecoder().decode(ContainerStats.self, from: data) } + + public func clean() async throws { + let request = XPCMessage(route: RuntimeRoutes.clean.rawValue) + request.set(key: RuntimeKeys.id.rawValue, value: self.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 bbe1485f4..addf46ff9 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift @@ -56,6 +56,8 @@ 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" /// Snapshot the container's root filesystem to an image file. case snapshotDisk = "com.apple.container.runtime/snapshotDisk" } diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 948a65603..774d6807d 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -782,6 +782,54 @@ 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 let id = message.string(key: RuntimeKeys.id.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no id supplied for clean" + ) + } + + let ctr = try getContainer() + guard id == ctr.config.id else { + throw ContainerizationError( + .invalidArgument, + message: "clean id does not match runtime container" + ) + } + + // Perform filesystem trim on the root filesystem + try await ctr.container.filesystemOperation(operation: .trim, path: "/") + + // Trim all block-backed mounts. Named volumes are expected to be + // block-backed, and may be represented as either `.volume` or + // `.block` depending on how configuration was created. + for mount in ctr.config.mounts { + if mount.isBlock { + try await ctr.container.filesystemOperation(operation: .trim, path: mount.destination) + } + } + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot clean: container is not running" + ) + } + } + /// Snapshot the container's root filesystem. /// /// When the container is running, freeze/thaw around the copy for consistency. diff --git a/Tests/IntegrationTests/Containers/TestCLIClean.swift b/Tests/IntegrationTests/Containers/TestCLIClean.swift new file mode 100644 index 000000000..629cdbcf2 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIClean.swift @@ -0,0 +1,247 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerTestSupport +import Darwin +import Foundation +import Testing + +@Suite +struct TestCLIClean { + private struct StatusJSON: Codable { + let appRoot: String + } + + private func appRoot(_ fixture: ContainerFixture) throws -> URL { + let result = try fixture.run(["system", "status", "--format", "json"]).check() + let status = try JSONDecoder().decode(StatusJSON.self, from: result.outputData) + return URL(filePath: status.appRoot, directoryHint: .isDirectory) + } + + private func allocatedBytes(at url: URL) throws -> Int64 { + var fileStatus = stat() + guard lstat(url.path, &fileStatus) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + return Int64(fileStatus.st_blocks) * 512 + } + + private func containerRootfsBlockURL(_ fixture: ContainerFixture, name: String) throws -> URL { + let id = try fixture.getContainerId(name) + return try appRoot(fixture) + .appending(path: "containers", directoryHint: .isDirectory) + .appending(path: id, directoryHint: .isDirectory) + .appending(path: "rootfs.ext4", directoryHint: .notDirectory) + } + + private func volumeBlockURL(_ fixture: ContainerFixture, name: String) throws -> URL { + try appRoot(fixture) + .appending(path: "volumes", directoryHint: .isDirectory) + .appending(path: name, directoryHint: .isDirectory) + .appending(path: "volume.img", directoryHint: .notDirectory) + } + + private func expectReclaimedSpace(beforeWrite: Int64, afterWrite: Int64, afterClean: Int64) { + let allocatedByWrite = afterWrite - beforeWrite + #expect(allocatedByWrite > 0, "test write should allocate host storage") + + let reclaimed = afterWrite - afterClean + #expect(reclaimed > 0, "clean should reclaim host storage") + + let minimumExpectedReclaimed = Int64(Double(allocatedByWrite) * 0.8) + #expect( + reclaimed >= minimumExpectedReclaimed, + "clean should reclaim at least 80% of storage allocated by the test write") + } + + private func waitForStableAllocatedSpace( + at url: URL, + timeout: TimeInterval = 10 + ) async throws -> Int64 { + let deadline = Date.now.addingTimeInterval(timeout) + var previous = try allocatedBytes(at: url) + var unchangedSamples = 0 + + while Date.now < deadline { + try await Task.sleep(for: .milliseconds(250)) + let current = try allocatedBytes(at: url) + if current == previous { + unchangedSamples += 1 + if unchangedSamples == 4 { + return current + } + } else { + previous = current + unchangedSamples = 0 + } + } + return previous + } + + private func waitForAllocatedSpace( + after baseline: Int64, + at url: URL, + timeout: TimeInterval = 10 + ) async throws -> Int64 { + let deadline = Date.now.addingTimeInterval(timeout) + var allocated = try allocatedBytes(at: url) + + while allocated <= baseline, Date.now < deadline { + try await Task.sleep(for: .milliseconds(250)) + allocated = try allocatedBytes(at: url) + } + return allocated + } + + private func waitForReclaimedSpace( + beforeWrite: Int64, + afterWrite: Int64, + at url: URL, + timeout: TimeInterval = 10 + ) async throws -> Int64 { + let allocatedByWrite = afterWrite - beforeWrite + let minimumExpectedReclaimed = Int64(Double(allocatedByWrite) * 0.8) + let deadline = Date.now.addingTimeInterval(timeout) + var afterClean = try allocatedBytes(at: url) + + while afterWrite - afterClean < minimumExpectedReclaimed, Date.now < deadline { + try await Task.sleep(for: .milliseconds(250)) + afterClean = try allocatedBytes(at: url) + } + return afterClean + } + + @Test func cleanRejectsStoppedContainer() async throws { + try await ContainerFixture.with { fixture in + let name = "\(fixture.testID)-clean-stopped" + try await fixture.doLongRun( + name: name, + autoRemove: false, + waitUntilRunning: true) + fixture.addCleanup { try? fixture.doRemove(name, force: true) } + + try fixture.doStop(name) + #expect(try fixture.getContainerStatus(name) == "stopped") + + let result = try fixture.run(["clean", name]) + #expect(result.status != 0, "clean should reject a stopped container") + #expect( + result.error.contains("not running"), + "clean should report that the stopped container is not running; stderr: \(result.error)") + } + } + + @Test func cleanSupportsMultipleRunningContainers() async throws { + try await ContainerFixture.with { fixture in + let primary = "\(fixture.testID)-clean-primary" + let secondary = "\(fixture.testID)-clean-secondary" + + try await fixture.doLongRun( + name: primary, + autoRemove: false, + waitUntilRunning: true) + fixture.addCleanup { try? fixture.doRemove(primary, force: true) } + + try await fixture.doLongRun( + name: secondary, + autoRemove: false, + waitUntilRunning: true) + fixture.addCleanup { try? fixture.doRemove(secondary, force: true) } + + try fixture.run(["clean", primary, secondary]).check() + #expect(try fixture.getContainerStatus(primary) == "running") + #expect(try fixture.getContainerStatus(secondary) == "running") + } + } + + @Test func cleanReclaimsRootFilesystemSpace() async throws { + try await ContainerFixture.with { fixture in + let name = "\(fixture.testID)-clean-rootfs-reclaim" + try await fixture.doLongRun( + name: name, + autoRemove: false, + waitUntilRunning: true) + fixture.addCleanup { try? fixture.doRemove(name, force: true) } + + let rootfsBlockURL = try containerRootfsBlockURL(fixture, name: name) + try fixture.doClean(name) + let beforeWrite = try await waitForStableAllocatedSpace(at: rootfsBlockURL) + + try fixture.doExec( + name, + cmd: ["sh", "-c", "dd if=/dev/urandom of=/rootfs-reclaim.dat bs=1M count=256"]) + try fixture.doExec(name, cmd: ["sync"]) + let afterWrite = try await waitForAllocatedSpace(after: beforeWrite, at: rootfsBlockURL) + + try fixture.doExec(name, cmd: ["rm", "/rootfs-reclaim.dat"]) + try fixture.doExec(name, cmd: ["sync"]) + try fixture.doClean(name) + let afterClean = try await waitForReclaimedSpace( + beforeWrite: beforeWrite, + afterWrite: afterWrite, + at: rootfsBlockURL) + print("rootfs allocated bytes before=\(beforeWrite) afterWrite=\(afterWrite) afterClean=\(afterClean)") + + expectReclaimedSpace( + beforeWrite: beforeWrite, + afterWrite: afterWrite, + afterClean: afterClean) + #expect(try fixture.getContainerStatus(name) == "running") + } + } + + @Test func cleanReclaimsNamedVolumeSpace() async throws { + try await ContainerFixture.with { fixture in + let name = "\(fixture.testID)-clean-volume-reclaim" + let volumeName = "\(fixture.testID)-clean-reclaim-data" + + try fixture.doVolumeCreate(volumeName) + fixture.addCleanup { fixture.doVolumeDeleteIfExists(volumeName) } + + try fixture.doCreate( + name: name, + volumes: ["\(volumeName):/mnt/reclaim-data"]) + fixture.addCleanup { try? fixture.doRemove(name, force: true) } + try fixture.doStart(name) + try await fixture.waitForContainerRunning(name) + + let volumeBlockURL = try volumeBlockURL(fixture, name: volumeName) + try fixture.doClean(name) + let beforeWrite = try await waitForStableAllocatedSpace(at: volumeBlockURL) + + try fixture.doExec( + name, + cmd: ["sh", "-c", "dd if=/dev/urandom of=/mnt/reclaim-data/volume-reclaim.dat bs=1M count=256"]) + try fixture.doExec(name, cmd: ["sync"]) + let afterWrite = try await waitForAllocatedSpace(after: beforeWrite, at: volumeBlockURL) + + try fixture.doExec(name, cmd: ["rm", "/mnt/reclaim-data/volume-reclaim.dat"]) + try fixture.doExec(name, cmd: ["sync"]) + try fixture.doClean(name) + let afterClean = try await waitForReclaimedSpace( + beforeWrite: beforeWrite, + afterWrite: afterWrite, + at: volumeBlockURL) + print("volume allocated bytes before=\(beforeWrite) afterWrite=\(afterWrite) afterClean=\(afterClean)") + + expectReclaimedSpace( + beforeWrite: beforeWrite, + afterWrite: afterWrite, + afterClean: afterClean) + #expect(try fixture.getContainerStatus(name) == "running") + } + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 6f80ca995..f6677b41e 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -409,6 +409,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.