1197 lines
43 KiB
Swift
1197 lines
43 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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 CVersion
|
|
import ContainerAPIClient
|
|
import ContainerPersistence
|
|
import ContainerPlugin
|
|
import ContainerResource
|
|
import ContainerRuntimeClient
|
|
import ContainerXPC
|
|
import Containerization
|
|
import ContainerizationEXT4
|
|
import ContainerizationError
|
|
import ContainerizationExtras
|
|
import ContainerizationOCI
|
|
import ContainerizationOS
|
|
import Foundation
|
|
import Logging
|
|
import SystemPackage
|
|
|
|
public actor ContainersService {
|
|
struct ContainerState {
|
|
var snapshot: ContainerSnapshot
|
|
var client: RuntimeClient? = nil
|
|
|
|
func getClient() throws -> RuntimeClient {
|
|
guard let client else {
|
|
var message = "no runtime client exists"
|
|
if snapshot.status == .stopped {
|
|
message += ": container is stopped"
|
|
}
|
|
throw ContainerizationError(.invalidState, message: message)
|
|
}
|
|
return client
|
|
}
|
|
}
|
|
|
|
private static let machServicePrefix = "com.apple.container"
|
|
private static let launchdDomainString = try! ServiceManager.getDomainString()
|
|
|
|
private let log: Logger
|
|
private let debugHelpers: Bool
|
|
private let containerRoot: URL
|
|
private let pluginLoader: PluginLoader
|
|
private let runtimePlugins: [Plugin]
|
|
private let exitMonitor: ExitMonitor
|
|
private let containerSystemConfig: ContainerSystemConfig
|
|
|
|
private let lock: AsyncLock
|
|
private var containers: [String: ContainerState]
|
|
|
|
// FIXME: Find a better mechanism for services running on the APIServer to work with each other
|
|
private weak var networksService: NetworksService?
|
|
|
|
public init(
|
|
appRoot: URL,
|
|
pluginLoader: PluginLoader,
|
|
containerSystemConfig: ContainerSystemConfig,
|
|
log: Logger,
|
|
debugHelpers: Bool = false
|
|
) throws {
|
|
let containerRoot = appRoot.appendingPathComponent("containers")
|
|
try FileManager.default.createDirectory(at: containerRoot, withIntermediateDirectories: true)
|
|
self.exitMonitor = ExitMonitor(log: log)
|
|
self.lock = AsyncLock(log: log)
|
|
self.containerRoot = containerRoot
|
|
self.pluginLoader = pluginLoader
|
|
self.containerSystemConfig = containerSystemConfig
|
|
self.log = log
|
|
self.debugHelpers = debugHelpers
|
|
self.runtimePlugins = pluginLoader.findPlugins().filter { $0.hasType(.runtime) }
|
|
self.containers = try Self.loadAtBoot(root: containerRoot, loader: pluginLoader, log: log)
|
|
}
|
|
|
|
public func setNetworksService(_ service: NetworksService) async {
|
|
self.networksService = service
|
|
}
|
|
|
|
static func loadAtBoot(root: URL, loader: PluginLoader, log: Logger) throws -> [String: ContainerState] {
|
|
var directories = try FileManager.default.contentsOfDirectory(
|
|
at: root,
|
|
includingPropertiesForKeys: [.isDirectoryKey]
|
|
)
|
|
directories = directories.filter {
|
|
$0.isDirectory
|
|
}
|
|
|
|
let runtimePlugins = loader.findPlugins().filter { $0.hasType(.runtime) }
|
|
var results = [String: ContainerState]()
|
|
for dir in directories {
|
|
do {
|
|
let (config, options) = try Self.getContainerConfiguration(at: dir)
|
|
if options?.autoRemove ?? false {
|
|
log.info(
|
|
"reap auto-remove container",
|
|
metadata: [
|
|
"id": "\(config.id)"
|
|
])
|
|
|
|
let label = Self.fullLaunchdServiceLabel(
|
|
runtimeName: config.runtimeHandler,
|
|
instanceId: config.id)
|
|
|
|
var status: Int32 = -1
|
|
try? ServiceManager.deregister(fullServiceLabel: label, status: &status)
|
|
if status != 0 {
|
|
log.warning(
|
|
"failed to deregister service",
|
|
metadata: [
|
|
"id": "\(config.id)",
|
|
"service": "\(label)",
|
|
"status": "\(status)",
|
|
]
|
|
)
|
|
}
|
|
|
|
let bundle = ContainerResource.Bundle(path: dir)
|
|
try? bundle.delete()
|
|
continue
|
|
}
|
|
|
|
let state = ContainerState(
|
|
snapshot: .init(
|
|
configuration: config,
|
|
status: .stopped,
|
|
networks: [],
|
|
startedDate: nil
|
|
),
|
|
)
|
|
results[config.id] = state
|
|
guard runtimePlugins.first(where: { $0.name == config.runtimeHandler }) != nil else {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "failed to find runtime plugin \(config.runtimeHandler)"
|
|
)
|
|
}
|
|
} catch {
|
|
try? FileManager.default.removeItem(at: dir)
|
|
log.warning(
|
|
"failed to load container",
|
|
metadata: [
|
|
"path": "\(dir.path)",
|
|
"error": "\(error)",
|
|
])
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
/// List containers matching the given filters.
|
|
public func list(filters: ContainerListFilters = .all) async throws -> [ContainerSnapshot] {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)"
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)"
|
|
]
|
|
)
|
|
}
|
|
|
|
let labelPatterns: [(key: String, regex: Regex<AnyRegexOutput>)] = try filters.labels.map { key, pattern in
|
|
do {
|
|
return (key: key, regex: try Regex(pattern))
|
|
} catch {
|
|
throw ContainerizationError(
|
|
.invalidArgument, message: "failed to compile regex '\(pattern)' for \(key)",
|
|
cause: error)
|
|
}
|
|
}
|
|
|
|
return self.containers.values.compactMap { state -> ContainerSnapshot? in
|
|
let snapshot = state.snapshot
|
|
|
|
if !filters.ids.isEmpty {
|
|
guard filters.ids.contains(snapshot.id) else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if let status = filters.status {
|
|
guard snapshot.status == status else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
for (key, regex) in labelPatterns {
|
|
let label = snapshot.configuration.labels[key] ?? ""
|
|
|
|
guard label.contains(regex) else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return snapshot
|
|
}
|
|
}
|
|
|
|
/// Execute an operation with the current container list while maintaining atomicity
|
|
/// This prevents race conditions where containers are created during the operation
|
|
public func withContainerList<T: Sendable>(
|
|
logMetadata: Logger.Metadata? = nil,
|
|
_ operation: @Sendable @escaping ([ContainerSnapshot]) async throws -> T
|
|
) async throws -> T {
|
|
try await lock.withLock(logMetadata: logMetadata) { context in
|
|
let snapshots = await self.containers.values.map { $0.snapshot }
|
|
return try await operation(snapshots)
|
|
}
|
|
}
|
|
|
|
/// Calculate disk usage for containers
|
|
/// - Returns: Tuple of (total count, active count, total size, reclaimable size)
|
|
public func calculateDiskUsage() async -> (Int, Int, UInt64, UInt64) {
|
|
await lock.withLock(logMetadata: ["acquirer": "\(#function)"]) { _ in
|
|
var totalSize: UInt64 = 0
|
|
var reclaimableSize: UInt64 = 0
|
|
var activeCount = 0
|
|
|
|
for (id, state) in await self.containers {
|
|
let bundlePath = self.containerRoot.appendingPathComponent(id)
|
|
let containerSize = FileManager.default.allocatedSize(of: bundlePath)
|
|
totalSize += containerSize
|
|
|
|
if state.snapshot.status == .running {
|
|
activeCount += 1
|
|
} else {
|
|
// Stopped containers are reclaimable
|
|
reclaimableSize += containerSize
|
|
}
|
|
}
|
|
|
|
return (await self.containers.count, activeCount, totalSize, reclaimableSize)
|
|
}
|
|
}
|
|
|
|
/// Get set of image references used by containers (for disk usage calculation)
|
|
/// - Returns: Set of image references currently in use
|
|
public func getActiveImageReferences() async -> Set<String> {
|
|
await lock.withLock(logMetadata: ["acquirer": "\(#function)"]) { _ in
|
|
var imageRefs = Set<String>()
|
|
for (_, state) in await self.containers {
|
|
imageRefs.insert(state.snapshot.configuration.image.reference)
|
|
}
|
|
return imageRefs
|
|
}
|
|
}
|
|
|
|
/// Create a new container from the provided id and configuration.
|
|
public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions, initImage: String? = nil, runtimeData: Data? = nil) async throws {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(configuration.id)",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(configuration.id)",
|
|
]
|
|
)
|
|
}
|
|
|
|
try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(configuration.id)"]) { context in
|
|
guard await self.containers[configuration.id] == nil else {
|
|
throw ContainerizationError(
|
|
.exists,
|
|
message: "container already exists: \(configuration.id)"
|
|
)
|
|
}
|
|
|
|
var allHostnames = Set<String>()
|
|
for container in await self.containers.values {
|
|
for attachmentConfiguration in container.snapshot.configuration.networks {
|
|
allHostnames.insert(attachmentConfiguration.options.hostname)
|
|
}
|
|
}
|
|
|
|
var conflictingHostnames = [String]()
|
|
for attachmentConfiguration in configuration.networks {
|
|
if allHostnames.contains(attachmentConfiguration.options.hostname) {
|
|
conflictingHostnames.append(attachmentConfiguration.options.hostname)
|
|
}
|
|
}
|
|
|
|
guard conflictingHostnames.isEmpty else {
|
|
throw ContainerizationError(
|
|
.exists,
|
|
message: "hostname(s) already exist: \(conflictingHostnames)"
|
|
)
|
|
}
|
|
|
|
guard self.runtimePlugins.first(where: { $0.name == configuration.runtimeHandler }) != nil else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "unable to locate runtime plugin \(configuration.runtimeHandler)"
|
|
)
|
|
}
|
|
|
|
// Protect against a user providing a memory amount that will cause us to not be able
|
|
// to boot. We can go lower, but this is a somewhat safe threshold. Containerization
|
|
// also gives a little bit extra than the user asked for to account for guest agent overhead.
|
|
//
|
|
// NOTE: We could potentially leave this validation to the runtime service(s), as
|
|
// it's possible there could be an implementation that can get away with a lower
|
|
// amount and be perfectly safe.
|
|
let minimumMemory: UInt64 = 200.mib()
|
|
guard configuration.resources.memoryInBytes >= minimumMemory else {
|
|
throw ContainerizationError(
|
|
.invalidArgument,
|
|
message: "minimum memory amount allowed is 200 MiB (got \(configuration.resources.memoryInBytes) bytes)"
|
|
)
|
|
}
|
|
|
|
let path = self.containerRoot.appendingPathComponent(configuration.id)
|
|
let systemPlatform = kernel.platform
|
|
|
|
// Fetch init image (custom or default)
|
|
self.log.debug(
|
|
"ContainersService: get init block",
|
|
metadata: [
|
|
"id": "\(configuration.id)"
|
|
]
|
|
)
|
|
let initFilesystem = try await self.getInitBlock(for: systemPlatform.ociPlatform(), imageRef: initImage)
|
|
|
|
do {
|
|
self.log.debug(
|
|
"create snapshot",
|
|
metadata: [
|
|
"id": "\(configuration.id)",
|
|
"ref": "\(configuration.image.reference)",
|
|
])
|
|
let containerImage = ClientImage(description: configuration.image)
|
|
let imageFs = try await options.rootFsOverride == nil ? containerImage.getCreateSnapshot(platform: configuration.platform) : nil
|
|
|
|
self.log.debug(
|
|
"configure runtime",
|
|
metadata: [
|
|
"id": "\(configuration.id)",
|
|
"kernel": "\(kernel.path)",
|
|
"initfs": "\(initImage ?? self.containerSystemConfig.vminit.image)",
|
|
])
|
|
let runtimeConfig = RuntimeConfiguration(
|
|
path: path,
|
|
initialFilesystem: initFilesystem,
|
|
kernel: kernel,
|
|
containerConfiguration: configuration,
|
|
containerRootFilesystem: imageFs,
|
|
options: options,
|
|
runtimeData: runtimeData
|
|
)
|
|
|
|
try runtimeConfig.writeRuntimeConfiguration()
|
|
|
|
let snapshot = ContainerSnapshot(
|
|
configuration: configuration,
|
|
status: .stopped,
|
|
networks: [],
|
|
startedDate: nil
|
|
)
|
|
await self.setContainerState(configuration.id, ContainerState(snapshot: snapshot), context: context)
|
|
} catch {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Bootstrap the init process of the container.
|
|
public func bootstrap(id: String, stdio: [FileHandle?], dynamicEnv: [String: String]) async throws {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"env": "\(dynamicEnv)",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
}
|
|
|
|
try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in
|
|
var state = try await self.getContainerState(id: id, context: context)
|
|
|
|
// We've already bootstrapped this container. Ideally we should be able to
|
|
// return some sort of error code from the sandbox svc to check here, but this
|
|
// is also a very simple check and faster than doing an rpc to get the same result.
|
|
if state.client != nil {
|
|
return
|
|
}
|
|
|
|
let path = self.containerRoot.appendingPathComponent(id)
|
|
let (config, _) = try Self.getContainerConfiguration(at: path)
|
|
|
|
var networkBootstrapInfos = [NetworkBootstrapInfo]()
|
|
for n in config.networks {
|
|
guard let plugin = try await self.networksService?.plugin(for: n.network) else {
|
|
throw ContainerizationError(.internalError, message: "failed to get plugin for network \(n.network)")
|
|
}
|
|
networkBootstrapInfos.append(NetworkBootstrapInfo(plugin: plugin))
|
|
}
|
|
|
|
do {
|
|
try Self.registerService(
|
|
plugin: self.runtimePlugins.first { $0.name == config.runtimeHandler }!,
|
|
loader: self.pluginLoader,
|
|
configuration: config,
|
|
path: path,
|
|
debug: self.debugHelpers
|
|
)
|
|
|
|
let runtime = state.snapshot.configuration.runtimeHandler
|
|
let runtimeClient = try await RuntimeClient.create(
|
|
id: id,
|
|
runtime: runtime
|
|
)
|
|
try await runtimeClient.bootstrap(stdio: stdio, networkBootstrapInfos: networkBootstrapInfos, dynamicEnv: dynamicEnv)
|
|
|
|
try await self.exitMonitor.registerProcess(
|
|
id: id,
|
|
onExit: self.handleContainerExit
|
|
)
|
|
|
|
state.client = runtimeClient
|
|
await self.setContainerState(id, state, context: context)
|
|
} catch {
|
|
let label = Self.fullLaunchdServiceLabel(
|
|
runtimeName: config.runtimeHandler,
|
|
instanceId: id
|
|
)
|
|
|
|
await self.exitMonitor.stopTracking(id: id)
|
|
try? ServiceManager.deregister(fullServiceLabel: label)
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a new process in the container.
|
|
public func createProcess(
|
|
id: String,
|
|
processID: String,
|
|
config: ProcessConfiguration,
|
|
stdio: [FileHandle?]
|
|
) async throws {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"processId": "\(processID)",
|
|
"command": "\(config.arguments.isEmpty ? "" : config.arguments[0])",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
}
|
|
|
|
let state = try self._getContainerState(id: id)
|
|
let client = try state.getClient()
|
|
try await client.createProcess(
|
|
processID,
|
|
config: config,
|
|
stdio: stdio
|
|
)
|
|
}
|
|
|
|
/// Start a process in a container. This can either be a process created via
|
|
/// createProcess, or the init process of the container which requires
|
|
/// id == processID.
|
|
public func startProcess(id: String, processID: String) async throws {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"processId": "\(processID)",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"processId": "\(processID)",
|
|
]
|
|
)
|
|
}
|
|
|
|
try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)", "processId": "\(processID)"]) { context in
|
|
var state = try await self.getContainerState(id: id, context: context)
|
|
|
|
let isInit = Self.isInitProcess(id: id, processID: processID)
|
|
if state.snapshot.status == .running && isInit {
|
|
return
|
|
}
|
|
|
|
let client = try state.getClient()
|
|
try await client.startProcess(processID)
|
|
|
|
guard isInit else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
let log = self.log
|
|
let waitFunc: ExitMonitor.WaitHandler = {
|
|
log.info("registering container with exit monitor")
|
|
let code = try await client.wait(id)
|
|
log.info(
|
|
"container finished in exit monitor",
|
|
metadata: [
|
|
"id": "\(id)",
|
|
"rc": "\(code)",
|
|
])
|
|
|
|
return code
|
|
}
|
|
try await self.exitMonitor.track(id: id, waitingOn: waitFunc)
|
|
|
|
let sandboxSnapshot = try await client.state()
|
|
state.snapshot.status = .running
|
|
state.snapshot.networks = sandboxSnapshot.networks
|
|
state.snapshot.startedDate = Date()
|
|
await self.setContainerState(id, state, context: context)
|
|
} catch {
|
|
await self.exitMonitor.stopTracking(id: id)
|
|
try? await client.stop(options: ContainerStopOptions.default)
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send a signal to the container.
|
|
public func kill(id: String, processID: String, signal: String) async throws {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"processId": "\(processID)",
|
|
"signal": "\(signal)",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"processId": "\(processID)",
|
|
]
|
|
)
|
|
}
|
|
|
|
let state = try self._getContainerState(id: id)
|
|
let client = try state.getClient()
|
|
try await client.kill(processID, signal: signal)
|
|
|
|
// SIGKILL is guaranteed to terminate the target. When directed at the
|
|
// container's init process, follow up with the same API-server cleanup
|
|
// that `stop` performs.
|
|
if processID == id, (try? Signal(signal)) == .kill {
|
|
try await handleContainerExit(id: id)
|
|
}
|
|
}
|
|
|
|
/// Stop all containers inside the sandbox, aborting any processes currently
|
|
/// executing inside the container, before stopping the underlying sandbox.
|
|
public func stop(id: String, options: ContainerStopOptions) async throws {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
}
|
|
|
|
let state = try self._getContainerState(id: id)
|
|
|
|
// Stop should be idempotent.
|
|
let client: RuntimeClient
|
|
do {
|
|
client = try state.getClient()
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
var resolvedOptions = options
|
|
if resolvedOptions.signal == nil, let stopSignal = state.snapshot.configuration.stopSignal {
|
|
resolvedOptions.signal = stopSignal
|
|
}
|
|
|
|
do {
|
|
try await client.stop(options: resolvedOptions)
|
|
} catch let err as ContainerizationError {
|
|
if err.code != .interrupted {
|
|
throw err
|
|
}
|
|
}
|
|
try await handleContainerExit(id: id)
|
|
}
|
|
|
|
public func dial(id: String, port: UInt32) async throws -> FileHandle {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"port": "\(port)",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"port": "\(port)",
|
|
]
|
|
)
|
|
}
|
|
|
|
let state = try self._getContainerState(id: id)
|
|
let client = try state.getClient()
|
|
return try await client.dial(port)
|
|
}
|
|
|
|
/// Wait waits for the container's init process or exec to exit and returns the
|
|
/// exit status.
|
|
public func wait(id: String, processID: String) async throws -> ExitStatus {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"processId": "\(processID)",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"processId": "\(processID)",
|
|
]
|
|
)
|
|
}
|
|
|
|
let state = try self._getContainerState(id: id)
|
|
let client = try state.getClient()
|
|
return try await client.wait(processID)
|
|
}
|
|
|
|
/// Resize resizes the container's PTY if one exists.
|
|
public func resize(id: String, processID: String, size: Terminal.Size) async throws {
|
|
log.trace(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"processId": "\(processID)",
|
|
]
|
|
)
|
|
defer {
|
|
log.trace(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"processId": "\(processID)",
|
|
]
|
|
)
|
|
}
|
|
|
|
let state = try self._getContainerState(id: id)
|
|
let client = try state.getClient()
|
|
try await client.resize(processID, size: size)
|
|
}
|
|
|
|
// Get the logs for the container.
|
|
public func logs(id: String) async throws -> [FileHandle] {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
}
|
|
|
|
// Logs doesn't care if the container is running or not, just that
|
|
// the bundle is there, and that the files actually exist. We do
|
|
// first try and get the container state so we get a nicer error message
|
|
// (container foo not found) however.
|
|
do {
|
|
_ = try _getContainerState(id: id)
|
|
let path = self.containerRoot.appendingPathComponent(id)
|
|
let bundle = ContainerResource.Bundle(path: path)
|
|
return [
|
|
try FileHandle(forReadingFrom: bundle.containerLog),
|
|
try FileHandle(forReadingFrom: bundle.bootlog),
|
|
]
|
|
} catch {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "failed to open container logs: \(error)"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Copy a file or directory from the host into the container.
|
|
public func copyIn(id: String, source: String, destination: String, mode: UInt32, createParents: Bool = true) async throws {
|
|
self.log.debug("\(#function)")
|
|
|
|
let state = try self._getContainerState(id: id)
|
|
guard state.snapshot.status == .running else {
|
|
throw ContainerizationError(.invalidState, message: "container \(id) is not running")
|
|
}
|
|
let client = try state.getClient()
|
|
try await client.copyIn(source: source, destination: destination, mode: mode, createParents: createParents)
|
|
}
|
|
|
|
/// Copy a file or directory from the container to the host.
|
|
public func copyOut(id: String, source: String, destination: String, createParents: Bool = true) async throws {
|
|
self.log.debug("\(#function)")
|
|
|
|
let state = try self._getContainerState(id: id)
|
|
guard state.snapshot.status == .running else {
|
|
throw ContainerizationError(.invalidState, message: "container \(id) is not running")
|
|
}
|
|
let client = try state.getClient()
|
|
try await client.copyOut(source: source, destination: destination, createParents: createParents)
|
|
}
|
|
|
|
/// Get statistics for the container.
|
|
public func stats(id: String) async throws -> ContainerStats {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
}
|
|
|
|
let state = try self._getContainerState(id: id)
|
|
let client = try state.getClient()
|
|
return try await client.statistics()
|
|
}
|
|
|
|
/// Delete a container and its resources.
|
|
public func delete(id: String, force: Bool) async throws {
|
|
log.info(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
"force": "\(force)",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
}
|
|
|
|
let state = try self._getContainerState(id: id)
|
|
switch state.snapshot.status {
|
|
case .running:
|
|
if !force {
|
|
throw ContainerizationError(
|
|
.invalidState,
|
|
message: "container \(id) is \(state.snapshot.status) and can not be deleted"
|
|
)
|
|
}
|
|
let opts = ContainerStopOptions(
|
|
timeoutInSeconds: 5,
|
|
signal: "SIGKILL"
|
|
)
|
|
let client = try state.getClient()
|
|
try await client.stop(options: opts)
|
|
try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in
|
|
self.log.info(
|
|
"ContainersService: attempt cleanup",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
try await self.cleanUp(id: id, context: context)
|
|
self.log.info(
|
|
"ContainersService: successful cleanup",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
}
|
|
case .stopping:
|
|
throw ContainerizationError(
|
|
.invalidState,
|
|
message: "container \(id) is \(state.snapshot.status) and can not be deleted"
|
|
)
|
|
default:
|
|
try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in
|
|
try await self.cleanUp(id: id, context: context)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func containerDiskUsage(id: String) async throws -> UInt64 {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
}
|
|
|
|
let containerPath = self.containerRoot.appendingPathComponent(id).path
|
|
|
|
return FileManager.default.allocatedSize(of: URL(fileURLWithPath: containerPath))
|
|
}
|
|
|
|
public func exportRootfs(id: String, archive: URL) async throws {
|
|
self.log.debug("\(#function)")
|
|
|
|
let state = try self._getContainerState(id: id)
|
|
guard state.snapshot.status == .stopped else {
|
|
throw ContainerizationError(.invalidState, message: "container is not stopped")
|
|
}
|
|
|
|
let path = self.containerRoot.appendingPathComponent(id)
|
|
let bundle = ContainerResource.Bundle(path: path)
|
|
let rootfs = bundle.containerRootfsBlock
|
|
try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
private func handleContainerExit(id: String, code: ExitStatus?, context: AsyncLock.Context) async throws {
|
|
if let code {
|
|
self.log.info(
|
|
"handling container exit",
|
|
metadata: [
|
|
"id": "\(id)",
|
|
"rc": "\(code)",
|
|
])
|
|
}
|
|
|
|
var state: ContainerState
|
|
do {
|
|
state = try self.getContainerState(id: id, context: context)
|
|
if state.snapshot.status == .stopped {
|
|
return
|
|
}
|
|
} catch {
|
|
// Was auto removed by the background thread, nothing for us to do.
|
|
return
|
|
}
|
|
|
|
await self.exitMonitor.stopTracking(id: id)
|
|
|
|
// Shutdown and deregister the runtime service
|
|
self.log.info("shutting down runtime service", metadata: ["id": "\(id)"])
|
|
|
|
let path = self.containerRoot.appendingPathComponent(id)
|
|
let bundle = ContainerResource.Bundle(path: path)
|
|
let config = try bundle.configuration
|
|
let label = Self.fullLaunchdServiceLabel(
|
|
runtimeName: config.runtimeHandler,
|
|
instanceId: id
|
|
)
|
|
|
|
// Try to shutdown the client gracefully, but if the runtime service
|
|
// is already dead (e.g., killed externally), we should still continue
|
|
// with state cleanup.
|
|
if let client = state.client {
|
|
do {
|
|
try await client.shutdown()
|
|
} catch {
|
|
self.log.error(
|
|
"failed to shutdown runtime service",
|
|
metadata: [
|
|
"id": "\(id)",
|
|
"error": "\(error)",
|
|
])
|
|
}
|
|
}
|
|
|
|
// Deregister the service, launchd will terminate the process.
|
|
// This may also fail if the service was already deregistered or
|
|
// the process was killed externally.
|
|
do {
|
|
try ServiceManager.deregister(fullServiceLabel: label)
|
|
self.log.info("deregistered runtime service", metadata: ["id": "\(id)"])
|
|
} catch {
|
|
self.log.error(
|
|
"failed to deregister runtime service",
|
|
metadata: [
|
|
"id": "\(id)",
|
|
"error": "\(error)",
|
|
])
|
|
}
|
|
|
|
state.snapshot.status = .stopped
|
|
state.snapshot.networks = []
|
|
state.client = nil
|
|
await self.setContainerState(id, state, context: context)
|
|
|
|
let options = try getContainerCreationOptions(id: id)
|
|
if options.autoRemove {
|
|
try await self.cleanUp(id: id, context: context)
|
|
}
|
|
}
|
|
|
|
private static func fullLaunchdServiceLabel(runtimeName: String, instanceId: String) -> String {
|
|
"\(Self.launchdDomainString)/\(Self.machServicePrefix).\(runtimeName).\(instanceId)"
|
|
}
|
|
|
|
private func _cleanUp(id: String) async throws {
|
|
log.debug(
|
|
"ContainersService: enter",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
defer {
|
|
log.debug(
|
|
"ContainersService: exit",
|
|
metadata: [
|
|
"func": "\(#function)",
|
|
"id": "\(id)",
|
|
]
|
|
)
|
|
}
|
|
|
|
// Did the exit container handler win?
|
|
if self.containers[id] == nil {
|
|
return
|
|
}
|
|
|
|
// To be pedantic. This is only needed if something in the "launch
|
|
// the init process" lifecycle fails before actually fork+exec'ing
|
|
// the OCI runtime.
|
|
await self.exitMonitor.stopTracking(id: id)
|
|
let path = self.containerRoot.appendingPathComponent(id)
|
|
|
|
// Try to get config for service deregistration
|
|
// Don't fail if bundle is incomplete
|
|
var config: ContainerConfiguration?
|
|
let bundle = ContainerResource.Bundle(path: path)
|
|
do {
|
|
config = try bundle.configuration
|
|
} catch {
|
|
self.log.warning(
|
|
"failed to read bundle configuration during cleanup for container",
|
|
metadata: [
|
|
"id": "\(id)",
|
|
"error": "\(error)",
|
|
])
|
|
}
|
|
|
|
// Only try to deregister service if we have a valid config
|
|
// TODO: Change this so we don't have to reread the config
|
|
// possibly store the container ID to service label mapping
|
|
if let config = config {
|
|
let label = Self.fullLaunchdServiceLabel(
|
|
runtimeName: config.runtimeHandler,
|
|
instanceId: id
|
|
)
|
|
try? ServiceManager.deregister(fullServiceLabel: label)
|
|
}
|
|
|
|
// Always try to delete the bundle directory, even if it's incomplete
|
|
do {
|
|
try bundle.delete()
|
|
} catch {
|
|
self.log.warning(
|
|
"failed to delete bundle for container",
|
|
metadata: [
|
|
"id": "\(id)",
|
|
"error": "\(error)",
|
|
])
|
|
}
|
|
|
|
self.containers.removeValue(forKey: id)
|
|
}
|
|
|
|
private func cleanUp(id: String, context: AsyncLock.Context) async throws {
|
|
try await self._cleanUp(id: id)
|
|
}
|
|
|
|
private func getContainerCreationOptions(id: String) throws -> ContainerCreateOptions {
|
|
let path = self.containerRoot.appendingPathComponent(id)
|
|
let bundle = ContainerResource.Bundle(path: path)
|
|
let options: ContainerCreateOptions = try bundle.load(filename: "options.json")
|
|
return options
|
|
}
|
|
|
|
private func getInitBlock(for platform: Platform, imageRef: String? = nil) async throws -> Filesystem {
|
|
let ref = imageRef ?? containerSystemConfig.vminit.image
|
|
let initImage = try await ClientImage.fetch(reference: ref, platform: platform, containerSystemConfig: containerSystemConfig)
|
|
var fs = try await initImage.getCreateSnapshot(platform: platform)
|
|
fs.options = ["ro"]
|
|
return fs
|
|
}
|
|
|
|
private static func registerService(
|
|
plugin: Plugin,
|
|
loader: PluginLoader,
|
|
configuration: ContainerConfiguration,
|
|
path: URL,
|
|
debug: Bool
|
|
) throws {
|
|
let args = [
|
|
"start",
|
|
"--root", path.path,
|
|
"--uuid", configuration.id,
|
|
debug ? "--debug" : nil,
|
|
].compactMap { $0 }
|
|
try loader.registerWithLaunchd(
|
|
plugin: plugin,
|
|
pluginStateRoot: path,
|
|
args: args,
|
|
instanceId: configuration.id
|
|
)
|
|
}
|
|
|
|
private func setContainerState(_ id: String, _ state: ContainerState, context: AsyncLock.Context) async {
|
|
self.containers[id] = state
|
|
}
|
|
|
|
private func getContainerState(id: String, context: AsyncLock.Context) throws -> ContainerState {
|
|
try self._getContainerState(id: id)
|
|
}
|
|
|
|
private func _getContainerState(id: String) throws -> ContainerState {
|
|
let state = self.containers[id]
|
|
guard let state else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "container with ID \(id) not found"
|
|
)
|
|
}
|
|
return state
|
|
}
|
|
|
|
private static func isInitProcess(id: String, processID: String) -> Bool {
|
|
id == processID
|
|
}
|
|
|
|
/// Get container configuration, either from existing bundle or from RuntimeConfiguration
|
|
private static func getContainerConfiguration(at path: URL) throws -> (ContainerConfiguration, ContainerCreateOptions?) {
|
|
let bundle = ContainerResource.Bundle(path: path)
|
|
do {
|
|
let config = try bundle.configuration
|
|
let options: ContainerCreateOptions? = try? bundle.load(filename: "options.json")
|
|
return (config, options)
|
|
} catch {
|
|
// Bundle doesn't exist or incomplete, try runtime configuration
|
|
// This handles containers that were created but not started yet
|
|
let runtimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: path)
|
|
guard let config = runtimeConfig.containerConfiguration else {
|
|
throw ContainerizationError(.internalError, message: "runtime configuration missing container configuration")
|
|
}
|
|
return (config, runtimeConfig.options)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension XPCMessage {
|
|
func signal() throws -> String {
|
|
guard let signal = self.string(key: .signal) else {
|
|
throw ContainerizationError(.invalidArgument, message: "missing signal in xpc message")
|
|
}
|
|
return signal
|
|
}
|
|
|
|
func stopOptions() throws -> ContainerStopOptions {
|
|
guard let data = self.dataNoCopy(key: .stopOptions) else {
|
|
throw ContainerizationError(.invalidArgument, message: "empty StopOptions")
|
|
}
|
|
return try JSONDecoder().decode(ContainerStopOptions.self, from: data)
|
|
}
|
|
|
|
func setState(_ state: SandboxSnapshot) throws {
|
|
let data = try JSONEncoder().encode(state)
|
|
self.set(key: .snapshot, value: data)
|
|
}
|
|
|
|
func stdio() -> [FileHandle?] {
|
|
var handles = [FileHandle?](repeating: nil, count: 3)
|
|
if let stdin = self.fileHandle(key: .stdin) {
|
|
handles[0] = stdin
|
|
}
|
|
if let stdout = self.fileHandle(key: .stdout) {
|
|
handles[1] = stdout
|
|
}
|
|
if let stderr = self.fileHandle(key: .stderr) {
|
|
handles[2] = stderr
|
|
}
|
|
return handles
|
|
}
|
|
|
|
func setFileHandle(_ handle: FileHandle) {
|
|
self.set(key: .fd, value: handle)
|
|
}
|
|
|
|
func processConfig() throws -> ProcessConfiguration {
|
|
guard let data = self.dataNoCopy(key: .processConfig) else {
|
|
throw ContainerizationError(.invalidArgument, message: "empty process configuration")
|
|
}
|
|
return try JSONDecoder().decode(ProcessConfiguration.self, from: data)
|
|
}
|
|
}
|