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
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ package.targets.append(
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
"Containerization",
],
path: "Sources/Integration"
Expand Down
8 changes: 4 additions & 4 deletions Sources/Containerization/AttachedFilesystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ public struct AttachedFilesystem: Sendable {
public var options: [String]

public init(mount: Mount, allocator: any AddressAllocator<Character>) throws {
switch mount.type {
case "virtiofs":
switch mount.runtimeOptions {
case .virtiofs:
let name = try hashMountSource(source: mount.source)
self.source = name
case "ext4":
case .virtioblk:
let char = try allocator.allocate()
self.source = "/dev/vd\(char)"
default:
case .any:
self.source = mount.source
}
self.type = mount.type
Expand Down
137 changes: 137 additions & 0 deletions Sources/Containerization/LinuxPod.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ public final class LinuxPod: Sendable {
/// The default hosts file configuration for all containers in the pod.
/// Individual containers can override this by setting their own `hosts` configuration.
public var hosts: Hosts?
/// Volumes attached to the pod. Can be shared with multiple containers.
public var volumes: [PodVolume] = []

public init() {}
}
Expand Down Expand Up @@ -86,10 +88,70 @@ public final class LinuxPod: Sendable {
/// Run the container with a minimal init process that handles signal
/// forwarding and zombie reaping.
public var useInit: Bool = false
/// The container mounts that references pod-level attached volumes.
public var volumeMounts: [VolumeMount] = []
Copy link
Copy Markdown
Member

@dcantah dcantah Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super happy with this in particular but I can't put my finger on it. This almost seems like a UX win really right? There's nothing they couldn't emulate by just asking for the same mount in every container in the pod?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we were to keep this I'd prefer sharedMounts or something.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dcantah How about this

  • remove the volumeMounts from ContainerConfiguration
  • Introduce a new Mount.sharedMount() constructor to indicate that this is a shared mount
  • Add a .shared RuntimeOptions to indicate that this is a shared mount between containers
  • PodContainer can do config.mounts.append(Mount.sharedMount(name: "shared-data", destination: "/data", options: ["ro"])) to add a shared mount to its configuration, no additional API surface needed

Does this work better?


public init() {}
}

/// A volume that is attached at the pod level and can be shared by multiple containers.
public struct PodVolume: Sendable {
/// Describes the backing storage for the volume.
public enum Source: Sendable {
/// A network block device (NBD) volume.
case nbd(url: URL, timeout: TimeInterval? = nil, readOnly: Bool = false)
}

/// The logical name of this volume. Containers reference this name in their `VolumeMount` entries.
public var name: String
/// The backing storage source for this volume.
public var source: Source
/// The filesystem format on the volume.
public var format: String

public init(name: String, source: Source, format: String) {
self.name = name
self.source = source
self.format = format
}

func toMount() -> Mount {
switch source {
case .nbd(let url, let timeout, let readOnly):
var runtimeOptions: [String] = []
if let timeout {
runtimeOptions.append("vzTimeout=\(timeout)")
}
if readOnly {
runtimeOptions.append("vzForcedReadOnly=true")
}
return Mount.block(
format: self.format,
source: url.absoluteString,
destination: LinuxPod.guestVolumePath(name),
options: readOnly ? ["ro"] : [],
runtimeOptions: runtimeOptions
)
}
}
}

/// A container mount that references a pod-level attached volume.
public struct VolumeMount: Sendable {
/// The name of the `PodVolume` to mount.
public var name: String
/// The destination path inside the container.
public var destination: String
/// Mount options (e.g. ["ro"]).
public var options: [String]

public init(name: String, destination: String, options: [String] = []) {
self.name = name
self.destination = destination
self.options = options
}
}

private struct PodContainer: Sendable {
let id: String
let rootfs: Mount
Expand Down Expand Up @@ -257,6 +319,10 @@ public final class LinuxPod: Sendable {
private static func guestSocketStagingPath(_ containerID: String, socketID: String) -> String {
"/run/container/\(containerID)/sockets/\(socketID).sock"
}

private static func guestVolumePath(_ volumeName: String) -> String {
"/run/volumes/\(volumeName)"
}
}

extension LinuxPod {
Expand Down Expand Up @@ -332,6 +398,33 @@ extension LinuxPod {
mountsByID[id] = [modifiedRootfs] + container.fileMountContext.transformedMounts
}

// Validate pod volume names are unique.
var volumeNames = Set<String>()
for volume in self.config.volumes {
guard volumeNames.insert(volume.name).inserted else {
throw ContainerizationError(
.invalidArgument,
message: "duplicate pod volume name \"\(volume.name)\""
)
}
}

// Validate that all container volumeMounts reference valid pod volume names.
for (id, container) in state.containers {
for volumeMount in container.config.volumeMounts {
guard volumeNames.contains(volumeMount.name) else {
throw ContainerizationError(
.invalidArgument,
message: "container \(id) references unknown pod volume \"\(volumeMount.name)\""
)
}
}
}
let podVolumeMounts = self.config.volumes.map { $0.toMount() }
if !podVolumeMounts.isEmpty {
mountsByID[self.id] = podVolumeMounts
}

let vmConfig = VMConfiguration(
cpus: self.config.cpus,
memoryInBytes: self.config.memoryInBytes,
Expand All @@ -347,6 +440,7 @@ extension LinuxPod {

do {
let containers = state.containers
let volumes = self.config.volumes
let shareProcessNamespace = self.config.shareProcessNamespace
let pauseProcessHolder = Mutex<LinuxProcess?>(nil)
let fileMountContextUpdates = Mutex<[String: FileMountContext]>([:])
Expand Down Expand Up @@ -429,6 +523,26 @@ extension LinuxPod {
}
}

// Mount pod-level volumes.
let podVolumeAttachments = vm.mounts[self.id] ?? []
for (index, volume) in volumes.enumerated() {
guard index < podVolumeAttachments.count else {
throw ContainerizationError(
.notFound,
message: "attached filesystem not found for pod volume \"\(volume.name)\""
)
}
let attachment = podVolumeAttachments[index]
let guestPath = Self.guestVolumePath(volume.name)
try await agent.mount(
ContainerizationOCI.Mount(
type: volume.format,
source: attachment.source,
destination: guestPath,
options: []
))
}

// Start up unix socket relays for each container
for (_, container) in containers {
for socket in container.config.sockets {
Expand Down Expand Up @@ -560,6 +674,17 @@ extension LinuxPod {
))
}

// Bind mount pod volumes into the container.
for volumeMount in container.config.volumeMounts {
mounts.append(
ContainerizationOCI.Mount(
type: "none",
source: Self.guestVolumePath(volumeMount.name),
destination: volumeMount.destination,
options: ["bind"] + volumeMount.options
))
}

spec.mounts = cleanAndSortMounts(mounts)

// Configure namespaces for the container
Expand Down Expand Up @@ -719,6 +844,18 @@ extension LinuxPod {
}
}

// Unmount pod-level volumes.
if createdState.vm.state != .stopped && !self.config.volumes.isEmpty {
try? await createdState.vm.withAgent { agent in
for volume in self.config.volumes {
try await agent.umount(
path: Self.guestVolumePath(volume.name),
flags: 0
)
}
}
}

try await createdState.vm.stop()
state.phase = .initialized
} catch {
Expand Down
83 changes: 82 additions & 1 deletion Sources/Containerization/Mount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,29 @@ public struct Mount: Sendable {
#if os(macOS)

extension Mount {
private enum StorageAttachmentType {
case diskImage
case networkBlockDevice
}

private var storageAttachmentType: StorageAttachmentType {
let nbdSchemes = ["nbd://", "nbds://", "nbd+unix://", "nbds+unix://"]
if nbdSchemes.contains(where: { self.source.hasPrefix($0) }) {
return .networkBlockDevice
}
return .diskImage
}

func configure(config: inout VZVirtualMachineConfiguration) throws {
switch self.runtimeOptions {
case .virtioblk(let options):
let device = try VZDiskImageStorageDeviceAttachment.mountToVZAttachment(mount: self, options: options)
let device: VZStorageDeviceAttachment
switch self.storageAttachmentType {
case .networkBlockDevice:
device = try VZNetworkBlockDeviceStorageDeviceAttachment.mountToVZAttachment(mount: self, options: options)
case .diskImage:
device = try VZDiskImageStorageDeviceAttachment.mountToVZAttachment(mount: self, options: options)
}
let attachment = VZVirtioBlockDeviceConfiguration(attachment: device)
config.storageDevices.append(attachment)
case .virtiofs(_):
Expand Down Expand Up @@ -221,6 +240,68 @@ extension VZDiskImageStorageDeviceAttachment {
}
}

extension VZNetworkBlockDeviceStorageDeviceAttachment {
static func mountToVZAttachment(mount: Mount, options: [String]) throws -> VZNetworkBlockDeviceStorageDeviceAttachment {
guard let url = URL(string: mount.source) else {
throw ContainerizationError(
.invalidArgument,
message: "invalid NBD URL: \(mount.source)"
)
}

var timeout: TimeInterval = 5
var synchronizationMode: VZDiskSynchronizationMode = .full
var forcedReadOnly = false

for option in options {
let split = option.split(separator: "=")
if split.count != 2 {
continue
}

let key = String(split[0])
let value = String(split[1])

switch key {
case "vzTimeout":
guard let t = TimeInterval(value) else {
throw ContainerizationError(
.invalidArgument,
message: "invalid vzTimeout value for NBD device: \(value)"
)
}
timeout = t
case "vzSynchronizationMode":
switch value {
case "full":
synchronizationMode = .full
case "none":
synchronizationMode = .none
default:
throw ContainerizationError(
.invalidArgument,
message: "unknown vzSynchronizationMode value for NBD device: \(value)"
)
}
case "vzForcedReadOnly":
forcedReadOnly = value == "true"
default:
throw ContainerizationError(
.invalidArgument,
message: "unknown vmm option encountered: \(key)"
)
}
}

return try VZNetworkBlockDeviceStorageDeviceAttachment(
url: url,
timeout: timeout,
isForcedReadOnly: forcedReadOnly,
synchronizationMode: synchronizationMode
)
}
}

#endif

extension Mount {
Expand Down
Loading
Loading