mirror of
https://github.com/apple/container.git
synced 2026-09-14 11:45:39 +00:00
Fixes #79 This adds support for single file (virtiofs based) mounts to the two main container types. This is transparent, and doesn't need to be setup by a user, so there is no visible API change. Virtiofs does not support single file mounts today, and it would be less than ideal from a security standpoint to just expose the parent directory of the file to the vm and then bind mount in the file, so we chose the following: 1. Create a temp directory the container types will manage. 2. Hardlink in the file we want. 3. Share that tempdir into the vm. 4. Finally bind mount in the file into the container. The main goal I wanted for this is to leave the logic out of the `VirtualMachineInstance`. I really want this to just be a little dance the container types do, and leave the vm out of it. Because of that, most of the logic is shoved in a new `FileMountContext` type that does the dirty tricks of rewriting what the user asked for to be a bind mount from a temporary holding spot we'll use in the guest for these temp directories. One of the downsides to this is today the tempdirs are solely on the main volume, so cross volume will need to copy the file to the tempdir, so writes won't get written back. Co-authored-by: Dean Coulstock <d_coulstock@apple.com> Co-authored-by: Jaewon Hur <hurjaewon@snu.ac.kr>
862 lines
33 KiB
Swift
862 lines
33 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
#if os(macOS)
|
|
import ContainerizationError
|
|
import ContainerizationExtras
|
|
import ContainerizationOCI
|
|
import Foundation
|
|
import Logging
|
|
import Synchronization
|
|
|
|
import struct ContainerizationOS.Terminal
|
|
|
|
/// NOTE: Experimental API
|
|
///
|
|
/// `LinuxPod` allows managing multiple Linux containers within a single
|
|
/// virtual machine. Each container has its own rootfs and process, but
|
|
/// shares the VM's resources (CPU, memory, network).
|
|
public final class LinuxPod: Sendable {
|
|
/// The identifier of the pod.
|
|
public let id: String
|
|
|
|
/// Configuration for the pod.
|
|
public let config: Configuration
|
|
|
|
/// The configuration for the LinuxPod.
|
|
public struct Configuration: Sendable {
|
|
/// The amount of cpus for the pod's VM.
|
|
public var cpus: Int = 4
|
|
/// The memory in bytes to give to the pod's VM.
|
|
public var memoryInBytes: UInt64 = 1024.mib()
|
|
/// The network interfaces for the pod.
|
|
public var interfaces: [any Interface] = []
|
|
/// The DNS configuration for the pod.
|
|
public var dns: DNS?
|
|
/// Whether nested virtualization should be turned on for the pod.
|
|
public var virtualization: Bool = false
|
|
/// Optional file path to store serial boot logs.
|
|
public var bootLog: BootLog?
|
|
/// Whether containers in the pod should share a PID namespace.
|
|
/// When enabled, all containers can see each other's processes.
|
|
public var shareProcessNamespace: Bool = false
|
|
|
|
public init() {}
|
|
}
|
|
|
|
/// Configuration for a container within the pod.
|
|
public struct ContainerConfiguration: Sendable {
|
|
/// Configuration for the init process of the container.
|
|
public var process = LinuxProcessConfiguration()
|
|
/// Optional per-container CPU limit (can exceed pod total for oversubscription).
|
|
public var cpus: Int?
|
|
/// Optional per-container memory limit in bytes (can exceed pod total for oversubscription).
|
|
public var memoryInBytes: UInt64?
|
|
/// The hostname for the container.
|
|
public var hostname: String = ""
|
|
/// The system control options for the container.
|
|
public var sysctl: [String: String] = [:]
|
|
/// The mounts for the container.
|
|
public var mounts: [Mount] = LinuxContainer.defaultMounts()
|
|
/// The Unix domain socket relays to setup for the container.
|
|
public var sockets: [UnixSocketConfiguration] = []
|
|
|
|
public init() {}
|
|
}
|
|
|
|
private struct PodContainer: Sendable {
|
|
let id: String
|
|
let rootfs: Mount
|
|
let config: ContainerConfiguration
|
|
var state: ContainerState
|
|
var process: LinuxProcess?
|
|
var fileMountContext: FileMountContext
|
|
|
|
enum ContainerState: Sendable {
|
|
case registered
|
|
case created
|
|
case started
|
|
case stopped
|
|
case errored
|
|
}
|
|
}
|
|
|
|
private let state: AsyncMutex<State>
|
|
|
|
// Ports to be allocated from for stdio and for
|
|
// unix socket relays that are sharing a guest
|
|
// uds to the host.
|
|
private let hostVsockPorts: Atomic<UInt32>
|
|
// Ports we request the guest to allocate for unix socket relays from
|
|
// the host.
|
|
private let guestVsockPorts: Atomic<UInt32>
|
|
|
|
private struct State: Sendable {
|
|
var phase: Phase
|
|
var containers: [String: PodContainer]
|
|
var pauseProcess: LinuxProcess?
|
|
}
|
|
|
|
private enum Phase: Sendable {
|
|
/// The pod has been created but no live resources are running.
|
|
case initialized
|
|
/// The pod's virtual machine has been setup and the runtime environment has been configured.
|
|
case created(CreatedState)
|
|
/// An error occurred during the lifetime of this class.
|
|
case errored(Swift.Error)
|
|
|
|
struct CreatedState: Sendable {
|
|
let vm: any VirtualMachineInstance
|
|
let relayManager: UnixSocketRelayManager
|
|
}
|
|
|
|
func createdState(_ operation: String) throws -> CreatedState {
|
|
switch self {
|
|
case .created(let state):
|
|
return state
|
|
case .errored(let err):
|
|
throw err
|
|
default:
|
|
throw ContainerizationError(
|
|
.invalidState,
|
|
message: "failed to \(operation): pod must be created"
|
|
)
|
|
}
|
|
}
|
|
|
|
mutating func validateForCreate() throws {
|
|
switch self {
|
|
case .initialized:
|
|
break
|
|
case .errored(let err):
|
|
throw err
|
|
default:
|
|
throw ContainerizationError(
|
|
.invalidState,
|
|
message: "pod must be in initialized state to create"
|
|
)
|
|
}
|
|
}
|
|
|
|
mutating func setErrored(error: Swift.Error) {
|
|
self = .errored(error)
|
|
}
|
|
}
|
|
|
|
private let vmm: VirtualMachineManager
|
|
private let logger: Logger?
|
|
|
|
/// Create a new `LinuxPod`. A `VirtualMachineManager` instance must be
|
|
/// provided that will handle launching the virtual machine the containers
|
|
/// will execute inside of.
|
|
public init(
|
|
_ id: String,
|
|
vmm: VirtualMachineManager,
|
|
logger: Logger? = nil,
|
|
configuration: (inout Configuration) throws -> Void
|
|
) throws {
|
|
self.id = id
|
|
self.vmm = vmm
|
|
self.hostVsockPorts = Atomic<UInt32>(0x1000_0000)
|
|
self.guestVsockPorts = Atomic<UInt32>(0x1000_0000)
|
|
self.logger = logger
|
|
|
|
var config = Configuration()
|
|
try configuration(&config)
|
|
|
|
self.config = config
|
|
self.state = AsyncMutex(State(phase: .initialized, containers: [:], pauseProcess: nil))
|
|
}
|
|
|
|
private static func createDefaultRuntimeSpec(_ containerID: String, podID: String) -> Spec {
|
|
.init(
|
|
process: .init(),
|
|
hostname: containerID,
|
|
root: .init(
|
|
path: Self.guestRootfsPath(containerID),
|
|
readonly: false
|
|
),
|
|
linux: .init(
|
|
resources: .init(),
|
|
cgroupsPath: "/container/pod/\(podID)/\(containerID)"
|
|
)
|
|
)
|
|
}
|
|
|
|
private func generateRuntimeSpec(containerID: String, config: ContainerConfiguration, rootfs: Mount) -> Spec {
|
|
var spec = Self.createDefaultRuntimeSpec(containerID, podID: self.id)
|
|
|
|
// Process configuration
|
|
spec.process = config.process.toOCI()
|
|
|
|
// General toggles
|
|
spec.hostname = config.hostname
|
|
|
|
// Linux toggles
|
|
spec.linux?.sysctl = config.sysctl
|
|
|
|
// If the rootfs was requested as read-only, set it in the OCI spec.
|
|
// We let the OCI runtime remount as ro, instead of doing it originally.
|
|
spec.root?.readonly = rootfs.options.contains("ro")
|
|
|
|
// Resource limits (if specified)
|
|
if let cpus = config.cpus, cpus > 0 {
|
|
spec.linux?.resources?.cpu = LinuxCPU(
|
|
quota: Int64(cpus * 100_000),
|
|
period: 100_000
|
|
)
|
|
}
|
|
if let memoryInBytes = config.memoryInBytes, memoryInBytes > 0 {
|
|
spec.linux?.resources?.memory = LinuxMemory(
|
|
limit: Int64(memoryInBytes)
|
|
)
|
|
}
|
|
|
|
return spec
|
|
}
|
|
|
|
private static func guestRootfsPath(_ containerID: String) -> String {
|
|
"/run/container/\(containerID)/rootfs"
|
|
}
|
|
}
|
|
|
|
extension LinuxPod {
|
|
/// Number of CPU cores allocated to the pod's VM.
|
|
public var cpus: Int {
|
|
config.cpus
|
|
}
|
|
|
|
/// Amount of memory in bytes allocated for the pod's VM.
|
|
public var memoryInBytes: UInt64 {
|
|
config.memoryInBytes
|
|
}
|
|
|
|
/// Network interfaces of the pod.
|
|
public var interfaces: [any Interface] {
|
|
config.interfaces
|
|
}
|
|
|
|
/// Add a container to the pod. This must be called before `create()`.
|
|
/// The container will be registered but not started.
|
|
public func addContainer(
|
|
_ id: String,
|
|
rootfs: Mount,
|
|
configuration: @Sendable @escaping (inout ContainerConfiguration) throws -> Void
|
|
) async throws {
|
|
try await self.state.withLock { state in
|
|
guard case .initialized = state.phase else {
|
|
throw ContainerizationError(
|
|
.invalidState,
|
|
message: "pod must be initialized to add container"
|
|
)
|
|
}
|
|
|
|
guard state.containers[id] == nil else {
|
|
throw ContainerizationError(
|
|
.invalidArgument,
|
|
message: "container with id \(id) already exists in pod"
|
|
)
|
|
}
|
|
|
|
var config = ContainerConfiguration()
|
|
try configuration(&config)
|
|
|
|
// Prepare file mounts - transforms single-file mounts into directory shares.
|
|
let fileMountContext = try FileMountContext.prepare(mounts: config.mounts)
|
|
|
|
state.containers[id] = PodContainer(
|
|
id: id,
|
|
rootfs: rootfs,
|
|
config: config,
|
|
state: .registered,
|
|
process: nil,
|
|
fileMountContext: fileMountContext
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Create and start the underlying pod's virtual machine and set up
|
|
/// the runtime environment. All registered containers will have their
|
|
/// rootfs mounted, but no init processes will be running.
|
|
public func create() async throws {
|
|
try await self.state.withLock { state in
|
|
try state.phase.validateForCreate()
|
|
|
|
// Build mountsByID for all containers.
|
|
// Strip "ro" from rootfs options - we handle readonly via the OCI spec's
|
|
// root.readonly field and remount in vmexec after setup is complete.
|
|
// Use transformedMounts from fileMountContext (file mounts become directory shares).
|
|
var mountsByID: [String: [Mount]] = [:]
|
|
for (id, container) in state.containers {
|
|
var modifiedRootfs = container.rootfs
|
|
modifiedRootfs.options.removeAll(where: { $0 == "ro" })
|
|
mountsByID[id] = [modifiedRootfs] + container.fileMountContext.transformedMounts
|
|
}
|
|
|
|
let vmConfig = VMConfiguration(
|
|
cpus: self.config.cpus,
|
|
memoryInBytes: self.config.memoryInBytes,
|
|
interfaces: self.config.interfaces,
|
|
mountsByID: mountsByID,
|
|
bootLog: self.config.bootLog,
|
|
nestedVirtualization: self.config.virtualization
|
|
)
|
|
let creationConfig = StandardVMConfig(configuration: vmConfig)
|
|
let vm = try await self.vmm.create(config: creationConfig)
|
|
let relayManager = UnixSocketRelayManager(vm: vm)
|
|
try await vm.start()
|
|
|
|
do {
|
|
let containers = state.containers
|
|
let shareProcessNamespace = self.config.shareProcessNamespace
|
|
let pauseProcessHolder = Mutex<LinuxProcess?>(nil)
|
|
let fileMountContextUpdates = Mutex<[String: FileMountContext]>([:])
|
|
|
|
try await vm.withAgent { agent in
|
|
try await agent.standardSetup()
|
|
|
|
// Create pause container if PID namespace sharing is enabled
|
|
if shareProcessNamespace {
|
|
let pauseID = "pause-\(self.id)"
|
|
let pauseRootfsPath = "/run/container/\(pauseID)/rootfs"
|
|
|
|
// Bind mount /sbin into the pause container rootfs.
|
|
// This is where the guest agent lives.
|
|
try await agent.mount(
|
|
ContainerizationOCI.Mount(
|
|
type: "",
|
|
source: "/sbin",
|
|
destination: "\(pauseRootfsPath)/sbin",
|
|
options: ["bind"]
|
|
))
|
|
|
|
var pauseSpec = Self.createDefaultRuntimeSpec(pauseID, podID: self.id)
|
|
pauseSpec.process?.args = ["/sbin/vminitd", "pause"]
|
|
pauseSpec.hostname = ""
|
|
pauseSpec.mounts = LinuxContainer.defaultMounts().map {
|
|
ContainerizationOCI.Mount(
|
|
type: $0.type,
|
|
source: $0.source,
|
|
destination: $0.destination,
|
|
options: $0.options
|
|
)
|
|
}
|
|
pauseSpec.linux?.namespaces = [
|
|
LinuxNamespace(type: .cgroup),
|
|
LinuxNamespace(type: .ipc),
|
|
LinuxNamespace(type: .mount),
|
|
LinuxNamespace(type: .pid),
|
|
LinuxNamespace(type: .uts),
|
|
]
|
|
|
|
// Create LinuxProcess for pause container
|
|
let process = LinuxProcess(
|
|
pauseID,
|
|
containerID: pauseID,
|
|
spec: pauseSpec,
|
|
io: LinuxProcess.Stdio(stdin: nil, stdout: nil, stderr: nil),
|
|
ociRuntimePath: nil,
|
|
agent: agent,
|
|
vm: vm,
|
|
logger: self.logger
|
|
)
|
|
|
|
try await process.start()
|
|
pauseProcessHolder.withLock { $0 = process }
|
|
|
|
self.logger?.debug("Pause container started", metadata: ["pid": "\(process.pid)"])
|
|
}
|
|
|
|
// Mount all container rootfs
|
|
for (_, container) in containers {
|
|
guard let attachments = vm.mounts[container.id], let rootfsAttachment = attachments.first else {
|
|
throw ContainerizationError(.notFound, message: "rootfs mount not found for container \(container.id)")
|
|
}
|
|
var rootfs = rootfsAttachment.to
|
|
rootfs.destination = Self.guestRootfsPath(container.id)
|
|
try await agent.mount(rootfs)
|
|
}
|
|
|
|
// Mount file mount holding directories under /run for each container.
|
|
for (id, container) in containers {
|
|
if container.fileMountContext.hasFileMounts {
|
|
var ctx = container.fileMountContext
|
|
let containerMounts = vm.mounts[id] ?? []
|
|
try await ctx.mountHoldingDirectories(
|
|
vmMounts: containerMounts,
|
|
agent: agent
|
|
)
|
|
fileMountContextUpdates.withLock { $0[id] = ctx }
|
|
}
|
|
}
|
|
|
|
// Start up unix socket relays for each container
|
|
for (_, container) in containers {
|
|
for socket in container.config.sockets {
|
|
try await self.relayUnixSocket(
|
|
socket: socket,
|
|
containerID: container.id,
|
|
relayManager: relayManager,
|
|
agent: agent
|
|
)
|
|
}
|
|
}
|
|
|
|
// For every interface asked for:
|
|
// 1. Add the address requested
|
|
// 2. Online the adapter
|
|
// 3. If a gateway IP address is present, add the default route.
|
|
for (index, i) in self.interfaces.enumerated() {
|
|
let name = "eth\(index)"
|
|
self.logger?.debug("setting up interface \(name) with address \(i.ipv4Address)")
|
|
try await agent.addressAdd(name: name, ipv4Address: i.ipv4Address)
|
|
try await agent.up(name: name, mtu: i.mtu)
|
|
if let ipv4Gateway = i.ipv4Gateway {
|
|
if !i.ipv4Address.contains(ipv4Gateway) {
|
|
self.logger?.debug("gateway \(ipv4Gateway) is outside subnet \(i.ipv4Address), adding a route first")
|
|
try await agent.routeAddLink(name: name, dstIPv4Addr: ipv4Gateway, srcIPv4Addr: nil)
|
|
}
|
|
try await agent.routeAddDefault(name: name, ipv4Gateway: ipv4Gateway)
|
|
}
|
|
}
|
|
|
|
// Setup /etc/resolv.conf if asked for
|
|
if let dns = self.config.dns {
|
|
// Configure DNS in each container's rootfs
|
|
for (_, container) in containers {
|
|
try await agent.configureDNS(
|
|
config: dns,
|
|
location: Self.guestRootfsPath(container.id)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
state.pauseProcess = pauseProcessHolder.withLock { $0 }
|
|
|
|
// Apply file mount context updates.
|
|
let updates = fileMountContextUpdates.withLock { $0 }
|
|
for (id, ctx) in updates {
|
|
state.containers[id]?.fileMountContext = ctx
|
|
}
|
|
|
|
// Transition all containers to created state
|
|
for id in state.containers.keys {
|
|
state.containers[id]?.state = .created
|
|
}
|
|
|
|
state.phase = .created(.init(vm: vm, relayManager: relayManager))
|
|
} catch {
|
|
try? await relayManager.stopAll()
|
|
try? await vm.stop()
|
|
state.phase.setErrored(error: error)
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Start a container's initial process.
|
|
public func startContainer(_ containerID: String) async throws {
|
|
try await self.state.withLock { state in
|
|
let createdState = try state.phase.createdState("startContainer")
|
|
|
|
guard var container = state.containers[containerID] else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "container \(containerID) not found in pod"
|
|
)
|
|
}
|
|
|
|
guard container.state == .created else {
|
|
throw ContainerizationError(
|
|
.invalidState,
|
|
message: "container \(containerID) must be in created state to start"
|
|
)
|
|
}
|
|
|
|
let agent = try await createdState.vm.dialAgent()
|
|
do {
|
|
var spec = self.generateRuntimeSpec(containerID: containerID, config: container.config, rootfs: container.rootfs)
|
|
// We don't need the rootfs, nor do OCI runtimes want it included.
|
|
// Also filter out file mount holding directories - we mount those separately under /run.
|
|
let containerMounts = createdState.vm.mounts[containerID] ?? []
|
|
let holdingTags = container.fileMountContext.holdingDirectoryTags
|
|
spec.mounts =
|
|
containerMounts.dropFirst()
|
|
.filter { !holdingTags.contains($0.source) }
|
|
.map { $0.to }
|
|
+ container.fileMountContext.ociBindMounts()
|
|
|
|
// Configure namespaces for the container
|
|
var namespaces: [LinuxNamespace] = [
|
|
LinuxNamespace(type: .cgroup),
|
|
LinuxNamespace(type: .ipc),
|
|
LinuxNamespace(type: .mount),
|
|
LinuxNamespace(type: .uts),
|
|
]
|
|
|
|
// Either join pause container's pid ns or create a new one
|
|
if self.config.shareProcessNamespace, let pausePID = state.pauseProcess?.pid {
|
|
let nsPath = "/proc/\(pausePID)/ns/pid"
|
|
|
|
self.logger?.debug(
|
|
"Container joining pause PID namespace",
|
|
metadata: [
|
|
"container": "\(containerID)",
|
|
"pausePID": "\(pausePID)",
|
|
"nsPath": "\(nsPath)",
|
|
])
|
|
|
|
namespaces.append(LinuxNamespace(type: .pid, path: nsPath))
|
|
} else {
|
|
namespaces.append(LinuxNamespace(type: .pid))
|
|
}
|
|
|
|
spec.linux?.namespaces = namespaces
|
|
|
|
let stdio = IOUtil.setup(
|
|
portAllocator: self.hostVsockPorts,
|
|
stdin: container.config.process.stdin,
|
|
stdout: container.config.process.stdout,
|
|
stderr: container.config.process.stderr
|
|
)
|
|
|
|
let process = LinuxProcess(
|
|
containerID,
|
|
containerID: containerID,
|
|
spec: spec,
|
|
io: stdio,
|
|
ociRuntimePath: nil,
|
|
agent: agent,
|
|
vm: createdState.vm,
|
|
logger: self.logger
|
|
)
|
|
try await process.start()
|
|
|
|
container.process = process
|
|
container.state = .started
|
|
state.containers[containerID] = container
|
|
} catch {
|
|
try? await agent.close()
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stop a container from executing.
|
|
public func stopContainer(_ containerID: String) async throws {
|
|
try await self.state.withLock { state in
|
|
let createdState = try state.phase.createdState("stopContainer")
|
|
|
|
guard var container = state.containers[containerID] else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "container \(containerID) not found in pod"
|
|
)
|
|
}
|
|
|
|
// Allow stop to be called multiple times
|
|
if container.state == .stopped {
|
|
return
|
|
}
|
|
|
|
guard container.state == .started, let process = container.process else {
|
|
throw ContainerizationError(
|
|
.invalidState,
|
|
message: "container \(containerID) must be in started state to stop"
|
|
)
|
|
}
|
|
|
|
do {
|
|
// Check if the vm is even still running
|
|
if createdState.vm.state == .stopped {
|
|
container.state = .stopped
|
|
state.containers[containerID] = container
|
|
return
|
|
}
|
|
|
|
try await process.kill(SIGKILL)
|
|
try await process.wait(timeoutInSeconds: 3)
|
|
|
|
try await createdState.vm.withAgent { agent in
|
|
// Unmount the rootfs
|
|
try await agent.umount(
|
|
path: Self.guestRootfsPath(containerID),
|
|
flags: 0
|
|
)
|
|
}
|
|
|
|
// Clean up the process resources
|
|
try await process.delete()
|
|
|
|
container.process = nil
|
|
container.state = .stopped
|
|
state.containers[containerID] = container
|
|
} catch {
|
|
container.state = .errored
|
|
container.process = nil
|
|
state.containers[containerID] = container
|
|
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stop the pod's VM and all containers.
|
|
public func stop() async throws {
|
|
try await self.state.withLock { state in
|
|
let createdState = try state.phase.createdState("stop")
|
|
|
|
do {
|
|
try await createdState.relayManager.stopAll()
|
|
|
|
// Stop all containers
|
|
let containerIDs = Array(state.containers.keys)
|
|
|
|
for containerID in containerIDs {
|
|
// Stop the container inline
|
|
guard var container = state.containers[containerID] else {
|
|
continue
|
|
}
|
|
|
|
if container.state == .stopped {
|
|
continue
|
|
}
|
|
|
|
if let process = container.process, container.state == .started {
|
|
if createdState.vm.state != .stopped {
|
|
try? await process.kill(SIGKILL)
|
|
_ = try? await process.wait(timeoutInSeconds: 3)
|
|
|
|
try? await createdState.vm.withAgent { agent in
|
|
try await agent.umount(
|
|
path: Self.guestRootfsPath(containerID),
|
|
flags: 0
|
|
)
|
|
}
|
|
}
|
|
|
|
try? await process.delete()
|
|
container.process = nil
|
|
container.state = .stopped
|
|
|
|
// Clean up file mount temporary directories.
|
|
container.fileMountContext.cleanup()
|
|
|
|
state.containers[containerID] = container
|
|
}
|
|
}
|
|
|
|
try await createdState.vm.stop()
|
|
state.phase = .initialized
|
|
} catch {
|
|
try? await createdState.vm.stop()
|
|
state.phase.setErrored(error: error)
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send a signal to a container.
|
|
public func killContainer(_ containerID: String, signal: Int32) async throws {
|
|
try await self.state.withLock { state in
|
|
guard let container = state.containers[containerID], let process = container.process else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "container \(containerID) not found or not started"
|
|
)
|
|
}
|
|
try await process.kill(signal)
|
|
}
|
|
}
|
|
|
|
/// Wait for a container to exit. Returns the exit code.
|
|
@discardableResult
|
|
public func waitContainer(_ containerID: String, timeoutInSeconds: Int64? = nil) async throws -> ExitStatus {
|
|
let process = try await self.state.withLock { state in
|
|
guard let container = state.containers[containerID], let process = container.process else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "container \(containerID) not found or not started"
|
|
)
|
|
}
|
|
return process
|
|
}
|
|
return try await process.wait(timeoutInSeconds: timeoutInSeconds)
|
|
}
|
|
|
|
/// Resize a container's terminal (if one was requested).
|
|
public func resizeContainer(_ containerID: String, to: Terminal.Size) async throws {
|
|
try await self.state.withLock { state in
|
|
guard let container = state.containers[containerID], let process = container.process else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "container \(containerID) not found or not started"
|
|
)
|
|
}
|
|
try await process.resize(to: to)
|
|
}
|
|
}
|
|
|
|
/// Execute a new process in a container.
|
|
public func execInContainer(
|
|
_ containerID: String,
|
|
processID: String,
|
|
configuration: @Sendable @escaping (inout LinuxProcessConfiguration) throws -> Void
|
|
) async throws -> LinuxProcess {
|
|
try await self.state.withLock { state in
|
|
let createdState = try state.phase.createdState("execInContainer")
|
|
|
|
guard let container = state.containers[containerID] else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "container \(containerID) not found in pod"
|
|
)
|
|
}
|
|
|
|
guard container.state == .started else {
|
|
throw ContainerizationError(
|
|
.invalidState,
|
|
message: "container \(containerID) must be started to exec"
|
|
)
|
|
}
|
|
|
|
var spec = self.generateRuntimeSpec(containerID: containerID, config: container.config, rootfs: container.rootfs)
|
|
var config = LinuxProcessConfiguration()
|
|
try configuration(&config)
|
|
spec.process = config.toOCI()
|
|
|
|
let stdio = IOUtil.setup(
|
|
portAllocator: self.hostVsockPorts,
|
|
stdin: config.stdin,
|
|
stdout: config.stdout,
|
|
stderr: config.stderr
|
|
)
|
|
let agent = try await createdState.vm.dialAgent()
|
|
let process = LinuxProcess(
|
|
processID,
|
|
containerID: containerID,
|
|
spec: spec,
|
|
io: stdio,
|
|
ociRuntimePath: nil,
|
|
agent: agent,
|
|
vm: createdState.vm,
|
|
logger: self.logger
|
|
)
|
|
return process
|
|
}
|
|
}
|
|
|
|
/// List all container IDs in the pod.
|
|
public func listContainers() async -> [String] {
|
|
await self.state.withLock { state in
|
|
Array(state.containers.keys)
|
|
}
|
|
}
|
|
|
|
/// Get statistics for containers in the pod.
|
|
public func statistics(containerIDs: [String]? = nil, categories: StatCategory = .all) async throws -> [ContainerStatistics] {
|
|
let (createdState, ids) = try await self.state.withLock { state in
|
|
let createdState = try state.phase.createdState("statistics")
|
|
let ids = containerIDs ?? Array(state.containers.keys)
|
|
return (createdState, ids)
|
|
}
|
|
|
|
let stats = try await createdState.vm.withAgent { agent in
|
|
try await agent.containerStatistics(containerIDs: ids, categories: categories)
|
|
}
|
|
|
|
return stats
|
|
}
|
|
|
|
/// Dial a vsock port in the pod's VM.
|
|
public func dialVsock(port: UInt32) async throws -> FileHandle {
|
|
try await self.state.withLock { state in
|
|
let createdState = try state.phase.createdState("dialVsock")
|
|
return try await createdState.vm.dial(port)
|
|
}
|
|
}
|
|
|
|
/// Close a container's standard input to signal no more input is arriving.
|
|
public func closeContainerStdin(_ containerID: String) async throws {
|
|
try await self.state.withLock { state in
|
|
guard let container = state.containers[containerID], let process = container.process else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "container \(containerID) not found or not started"
|
|
)
|
|
}
|
|
try await process.closeStdin()
|
|
}
|
|
}
|
|
|
|
/// Relay a unix socket for a container.
|
|
public func relayUnixSocket(_ containerID: String, socket: UnixSocketConfiguration) async throws {
|
|
try await self.state.withLock { state in
|
|
let createdState = try state.phase.createdState("relayUnixSocket")
|
|
|
|
guard let _ = state.containers[containerID] else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "container \(containerID) not found in pod"
|
|
)
|
|
}
|
|
|
|
try await createdState.vm.withAgent { agent in
|
|
try await self.relayUnixSocket(
|
|
socket: socket,
|
|
containerID: containerID,
|
|
relayManager: createdState.relayManager,
|
|
agent: agent
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func relayUnixSocket(
|
|
socket: UnixSocketConfiguration,
|
|
containerID: String,
|
|
relayManager: UnixSocketRelayManager,
|
|
agent: any VirtualMachineAgent
|
|
) async throws {
|
|
guard let relayAgent = agent as? SocketRelayAgent else {
|
|
throw ContainerizationError(
|
|
.unsupported,
|
|
message: "VirtualMachineAgent does not support relaySocket surface"
|
|
)
|
|
}
|
|
|
|
var socket = socket
|
|
|
|
// Adjust paths to be relative to the container's rootfs
|
|
let rootInGuest = URL(filePath: Self.guestRootfsPath(containerID))
|
|
|
|
let port: UInt32
|
|
if socket.direction == .into {
|
|
port = self.hostVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue
|
|
socket.destination = rootInGuest.appending(path: socket.destination.path)
|
|
} else {
|
|
port = self.guestVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue
|
|
socket.source = rootInGuest.appending(path: socket.source.path)
|
|
}
|
|
|
|
try await relayManager.start(port: port, socket: socket)
|
|
try await relayAgent.relaySocket(port: port, configuration: socket)
|
|
}
|
|
}
|
|
|
|
#endif
|