Skip to content
Closed
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
5 changes: 4 additions & 1 deletion Sources/ContainerCommands/Volume/VolumeCreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ extension Application.VolumeCommand {
abstract: "Create a new volume"
)

@Option(name: .customLong("driver"), help: "Volume driver (default: local)")
var driver: String = "local"

@Option(name: .customLong("label"), help: "Set metadata for a volume")
var labels: [String] = []

Expand Down Expand Up @@ -53,7 +56,7 @@ extension Application.VolumeCommand {

let volume = try await ClientVolume.create(
name: name,
driver: "local",
driver: driver,
driverOpts: parsedDriverOpts,
labels: parsedLabels
)
Expand Down
38 changes: 38 additions & 0 deletions Sources/ContainerResource/Container/Filesystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ public struct Filesystem: Sendable, Codable {

case block(format: String, cache: CacheMode, sync: SyncMode)
case volume(name: String, format: String, cache: CacheMode, sync: SyncMode)
case smb(share: String, mountOptions: [String: String])
case nfs(share: String, mountOptions: [String: String])
case virtiofs
case tmpfs
}
Expand Down Expand Up @@ -114,6 +116,26 @@ public struct Filesystem: Sendable, Codable {
)
}

/// An SMB share mounted directly inside the guest via CIFS.
public static func smb(share: String, mountOptions: [String: String], destination: String, options: MountOptions) -> Filesystem {
.init(
type: .smb(share: share, mountOptions: mountOptions),
source: share,
destination: destination,
options: options
)
}

/// An NFS share mounted directly inside the guest.
public static func nfs(share: String, mountOptions: [String: String], destination: String, options: MountOptions) -> Filesystem {
.init(
type: .nfs(share: share, mountOptions: mountOptions),
source: share,
destination: destination,
options: options
)
}

/// A vritiofs backed filesystem providing a directory.
public static func virtiofs(source: String, destination: String, options: MountOptions) -> Filesystem {
.init(
Expand All @@ -133,6 +155,22 @@ public struct Filesystem: Sendable, Codable {
)
}

/// Returns true if the Filesystem is an SMB volume.
public var isSMB: Bool {
switch type {
case .smb(_, _): true
default: false
}
}

/// Returns true if the Filesystem is an NFS volume.
public var isNFS: Bool {
switch type {
case .nfs(_, _): true
default: false
}
}

/// Returns true if the Filesystem is backed by a block device.
public var isBlock: Bool {
switch type {
Expand Down
31 changes: 24 additions & 7 deletions Sources/Services/ContainerAPIService/Client/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,30 @@ public struct Utility {
resolvedMounts.append(fs)
case .volume(let parsed):
let volume = try await getOrCreateVolume(parsed: parsed, log: log)
let volumeMount = Filesystem.volume(
name: parsed.name,
format: volume.format,
source: volume.source,
destination: parsed.destination,
options: parsed.options
)
let volumeMount: Filesystem
if volume.driver == "smb" {
volumeMount = Filesystem.smb(
share: volume.source,
mountOptions: volume.options,
destination: parsed.destination,
options: parsed.options
)
} else if volume.driver == "nfs" {
volumeMount = Filesystem.nfs(
share: volume.source,
mountOptions: volume.options,
destination: parsed.destination,
options: parsed.options
)
} else {
volumeMount = Filesystem.volume(
name: parsed.name,
format: volume.format,
source: volume.source,
destination: parsed.destination,
options: parsed.options
)
}
resolvedMounts.append(volumeMount)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,27 +313,59 @@ public actor VolumesService {
throw VolumeError.volumeAlreadyExists(name)
}

try createVolumeDirectory(for: name)

// Parse size from driver options (default 512GB)
let sizeInBytes: UInt64
if let sizeString = driverOpts["size"] {
sizeInBytes = try parseSize(sizeString)
} else {
sizeInBytes = VolumeStorage.defaultVolumeSizeBytes
}
let volume: Volume
switch driver {
case "smb":
guard let share = driverOpts["share"] else {
throw VolumeError.storageError("smb driver requires --opt share=//server/share")
}
volume = Volume(
name: name,
driver: "smb",
format: "cifs",
source: share,
labels: labels,
options: driverOpts,
sizeInBytes: nil
)
case "nfs":
guard let share = driverOpts["share"] else {
throw VolumeError.storageError("nfs driver requires --opt share=server:/export/path")
}
volume = Volume(
name: name,
driver: "nfs",
format: "nfs",
source: share,
labels: labels,
options: driverOpts,
sizeInBytes: nil
)
case "local":
try createVolumeDirectory(for: name)

// Parse size from driver options (default 512GB)
let sizeInBytes: UInt64
if let sizeString = driverOpts["size"] {
sizeInBytes = try parseSize(sizeString)
} else {
sizeInBytes = VolumeStorage.defaultVolumeSizeBytes
}

try createVolumeImage(for: name, sizeInBytes: sizeInBytes)
try createVolumeImage(for: name, sizeInBytes: sizeInBytes)

let volume = Volume(
name: name,
driver: driver,
format: "ext4",
source: blockPath(for: name),
labels: labels,
options: driverOpts,
sizeInBytes: sizeInBytes
)
volume = Volume(
name: name,
driver: "local",
format: "ext4",
source: blockPath(for: name),
labels: labels,
options: driverOpts,
sizeInBytes: sizeInBytes
)
default:
throw VolumeError.driverNotSupported(driver)
}

try await store.create(volume)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,22 @@ extension Filesystem {
"\(Filesystem.SyncMode.vzRuntimeOptionKey)=\(syncMode.asVZRuntimeOption)",
],
)
case .smb(let share, let mountOptions):
let opts = mountOptions.filter { $0.key != "share" }.map { $0.value.isEmpty ? $0.key : "\($0.key)=\($0.value)" } + self.options
return .any(
type: "cifs",
source: share,
destination: self.destination,
options: opts
)
case .nfs(let share, let mountOptions):
let opts = mountOptions.filter { $0.key != "share" }.map { $0.value.isEmpty ? $0.key : "\($0.key)=\($0.value)" } + self.options
return .any(
type: "nfs",
source: share,
destination: self.destination,
options: opts
)
}
}

Expand Down
111 changes: 111 additions & 0 deletions Tests/ContainerAPIClientTests/NFSVolumeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//===----------------------------------------------------------------------===//
// 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 ContainerResource
import ContainerizationError
import Foundation
import Testing

@testable import ContainerAPIClient

struct NFSVolumeTests {

// MARK: - Parser: named volume syntax still works for NFS volumes

@Test("Named volume syntax resolves as a volume, not a filesystem")
func testNamedVolumeIsParsedAsVolume() throws {
let result = try Parser.volume("myexport:/data")
guard case .volume(let parsed) = result else {
Issue.record("Expected .volume, got .filesystem")
return
}
#expect(parsed.name == "myexport")
#expect(parsed.destination == "/data")
#expect(!parsed.isAnonymous)
}

@Test("Named volume with options is parsed correctly")
func testNamedVolumeWithOptions() throws {
let result = try Parser.volume("myexport:/data:ro")
guard case .volume(let parsed) = result else {
Issue.record("Expected .volume")
return
}
#expect(parsed.name == "myexport")
#expect(parsed.destination == "/data")
#expect(parsed.options == ["ro"])
}

// MARK: - Volume model: NFS driver fields

@Test("NFS volume stores share path as source")
func testNFSVolumeSource() {
let volume = Volume(
name: "myexport",
driver: "nfs",
format: "nfs",
source: "nas.local:/exports/data",
labels: [:],
options: ["vers": "4"],
sizeInBytes: nil
)

#expect(volume.driver == "nfs")
#expect(volume.format == "nfs")
#expect(volume.source == "nas.local:/exports/data")
#expect(volume.sizeInBytes == nil)
#expect(volume.options["vers"] == "4")
}

@Test("NFS volume is not anonymous")
func testNFSVolumeIsNotAnonymous() {
let volume = Volume(
name: "myexport",
driver: "nfs",
format: "nfs",
source: "nas.local:/exports/data",
labels: [:]
)
#expect(!volume.isAnonymous)
}

@Test("Local volume driver defaults remain unchanged")
func testLocalVolumeDefaults() {
let volume = Volume(
name: "mydata",
source: "/var/lib/container/volumes/mydata/volume.img"
)
#expect(volume.driver == "local")
#expect(volume.format == "ext4")
}

// MARK: - Utility: parseKeyValuePairs for NFS driver opts

@Test("NFS driver opts are parsed from --opt flags")
func testNFSDriverOptsKeyValueParsing() {
let raw = ["share=nas.local:/exports/data", "vers=4"]
let parsed = Utility.parseKeyValuePairs(raw)

#expect(parsed["share"] == "nas.local:/exports/data")
#expect(parsed["vers"] == "4")
}

@Test("Missing share opt is detectable")
func testMissingNFSShareOpt() {
let opts = Utility.parseKeyValuePairs(["vers=4"])
#expect(opts["share"] == nil)
}
}
Loading