Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/APIServer/APIServer+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions Sources/ContainerCommands/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public struct Application: AsyncLoggableCommand {
CommandGroup(
name: "Container",
subcommands: [
ContainerClean.self,
ContainerCopy.self,
ContainerCreate.self,
ContainerDelete.self,
Expand Down
72 changes: 72 additions & 0 deletions Sources/ContainerCommands/Container/ContainerClean.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
15 changes: 15 additions & 0 deletions Sources/Services/ContainerAPIService/Client/ContainerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
}
1 change: 1 addition & 0 deletions Sources/Services/ContainerAPIService/Client/XPC+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ public enum XPCRoute: String {
case containerCopyIn
case containerCopyOut
case containerExport
case containerClean

case pluginLoad
case pluginGet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
40 changes: 40 additions & 0 deletions Sources/Services/RuntimeLinux/Server/RuntimeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
154 changes: 154 additions & 0 deletions Tests/IntegrationTests/Containers/TestCLIClean.swift
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
saehejkang marked this conversation as resolved.
Outdated
class TestCLIClean: CLITest {
private func getTestName() -> String {
Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased()
}

@Test func testCleanRunningContainer() throws {
Comment thread
saehejkang marked this conversation as resolved.
Outdated
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"])
Comment thread
saehejkang marked this conversation as resolved.
Outdated
_ = 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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading