Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
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
Loading
Loading