From 1837f80f30d7d77ff3b9c23cd7a2a4b725e8d6f3 Mon Sep 17 00:00:00 2001 From: Tony Coconate Date: Sat, 11 Apr 2026 23:55:04 -0500 Subject: [PATCH 1/3] Add NFS and SMB volume driver support Add support for 'nfs' and 'smb' volume drivers in 'container volume create', allowing network shares to be mounted directly inside the guest Linux VM via kernel NFS and CIFS mounts without creating a host-side ext4 disk image. - Extend Filesystem.FSType with .nfs and .smb cases (including optional volume name for in-use volume tracking across delete, prune, and disk usage) and deterministic mount option resolution - Map .nfs to Mount.any(type: "nfs") and .smb to Mount.any(type: "cifs") in RuntimeService - Handle 'nfs' and 'smb' drivers in VolumesService._create, validating non-empty share options and rejecting unsupported size options - Redact password options in 'container volume list' table output - Add unit tests and update volume and command reference documentation Closes #1911 Closes #1413 --- .../Volume/VolumeCreate.swift | 5 +- .../VolumeResource+ListDisplayable.swift | 6 +- .../Container/Filesystem.swift | 63 ++++++- .../ContainerAPIService/Client/Utility.swift | 33 +++- .../Server/Volumes/VolumesService.swift | 79 +++++--- .../RuntimeLinux/Server/RuntimeService.swift | 14 ++ .../NFSVolumeTests.swift | 112 +++++++++++ .../SMBVolumeTests.swift | 115 ++++++++++++ .../VolumesServiceTests.swift | 174 ++++++++++++++++++ .../ListFormattingTests.swift | 26 +++ .../FilesystemNFSTests.swift | 149 +++++++++++++++ .../FilesystemSMBTests.swift | 152 +++++++++++++++ docs/command-reference.md | 64 +++++-- docs/volumes.md | 86 ++++++++- 14 files changed, 1025 insertions(+), 53 deletions(-) create mode 100644 Tests/ContainerAPIClientTests/NFSVolumeTests.swift create mode 100644 Tests/ContainerAPIClientTests/SMBVolumeTests.swift create mode 100644 Tests/ContainerAPIServiceTests/VolumesServiceTests.swift create mode 100644 Tests/ContainerResourceTests/FilesystemNFSTests.swift create mode 100644 Tests/ContainerResourceTests/FilesystemSMBTests.swift diff --git a/Sources/ContainerCommands/Volume/VolumeCreate.swift b/Sources/ContainerCommands/Volume/VolumeCreate.swift index cc41fd215..f57966843 100644 --- a/Sources/ContainerCommands/Volume/VolumeCreate.swift +++ b/Sources/ContainerCommands/Volume/VolumeCreate.swift @@ -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] = [] @@ -53,7 +56,7 @@ extension Application.VolumeCommand { let volume = try await ClientVolume.create( name: name, - driver: "local", + driver: driver, driverOpts: parsedDriverOpts, labels: parsedLabels ) diff --git a/Sources/ContainerCommands/Volume/VolumeResource+ListDisplayable.swift b/Sources/ContainerCommands/Volume/VolumeResource+ListDisplayable.swift index 70cc5a1bd..500250315 100644 --- a/Sources/ContainerCommands/Volume/VolumeResource+ListDisplayable.swift +++ b/Sources/ContainerCommands/Volume/VolumeResource+ListDisplayable.swift @@ -26,7 +26,11 @@ extension VolumeResource: ListDisplayable { name, isAnonymous ? "anonymous" : "named", configuration.driver, - configuration.options.isEmpty ? "" : configuration.options.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: ","), + configuration.options.isEmpty + ? "" + : configuration.options.sorted(by: { $0.key < $1.key }) + .map { "\($0.key)=\($0.key == "password" ? "***" : $0.value)" } + .joined(separator: ","), ] } diff --git a/Sources/ContainerResource/Container/Filesystem.swift b/Sources/ContainerResource/Container/Filesystem.swift index 445a87621..b1ae1f94d 100644 --- a/Sources/ContainerResource/Container/Filesystem.swift +++ b/Sources/ContainerResource/Container/Filesystem.swift @@ -58,6 +58,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(name: String? = nil, share: String, mountOptions: [String: String]) + case nfs(name: String? = nil, share: String, mountOptions: [String: String]) case virtiofs case tmpfs } @@ -115,6 +117,30 @@ public struct Filesystem: Sendable, Codable { ) } + /// An SMB share mounted directly inside the guest via CIFS. + public static func smb( + name: String? = nil, share: String, mountOptions: [String: String], destination: String, options: MountOptions + ) -> Filesystem { + .init( + type: .smb(name: name, share: share, mountOptions: mountOptions), + source: share, + destination: destination, + options: options + ) + } + + /// An NFS share mounted directly inside the guest. + public static func nfs( + name: String? = nil, share: String, mountOptions: [String: String], destination: String, options: MountOptions + ) -> Filesystem { + .init( + type: .nfs(name: name, 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( @@ -134,6 +160,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 { @@ -145,20 +187,33 @@ public struct Filesystem: Sendable, Codable { /// Returns true if the Filesystem is a named volume. public var isVolume: Bool { - switch type { - case .volume(_, _, _, _): true - default: false - } + volumeName != nil } /// Returns the volume name if this is a volume filesystem, nil otherwise. public var volumeName: String? { switch type { case .volume(let name, _, _, _): name + case .smb(let name, _, _): name + case .nfs(let name, _, _): name default: nil } } + /// Returns the combined mount options for this filesystem, merging any + /// driver-specific mount options (excluding `share`) in deterministic key order + /// with the general mount options. + package var resolvedMountOptions: MountOptions { + switch type { + case .smb(_, _, let mountOptions), .nfs(_, _, let mountOptions): + return mountOptions.filter { $0.key != "share" } + .sorted { $0.key < $1.key } + .map { $0.value.isEmpty ? $0.key : "\($0.key)=\($0.value)" } + self.options + default: + return self.options + } + } + /// Returns true if the Filesystem is backed by a in-memory mount type. public var isTmpfs: Bool { switch type { diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index bc503ef32..ac9ba05db 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -175,13 +175,32 @@ 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( + name: parsed.name, + share: volume.source, + mountOptions: volume.options, + destination: parsed.destination, + options: parsed.options + ) + } else if volume.driver == "nfs" { + volumeMount = Filesystem.nfs( + name: parsed.name, + 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) } } diff --git a/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift b/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift index fe607e762..c929b6eb6 100644 --- a/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift +++ b/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift @@ -325,30 +325,67 @@ 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 journalConfig = try driverOpts["journal"].map { try Self.parseJournalConfig($0) } + let volume: VolumeConfiguration + switch driver { + case "smb": + guard let share = driverOpts["share"], !share.isEmpty else { + throw VolumeError.storageError("smb driver requires --opt share=//server/share") + } + if driverOpts["size"] != nil { + throw VolumeError.storageError("size option is not supported for smb driver") + } + volume = VolumeConfiguration( + name: name, + driver: "smb", + format: "cifs", + source: share, + labels: labels, + options: driverOpts, + sizeInBytes: nil + ) + case "nfs": + guard let share = driverOpts["share"], !share.isEmpty else { + throw VolumeError.storageError("nfs driver requires --opt share=server:/export/path") + } + if driverOpts["size"] != nil { + throw VolumeError.storageError("size option is not supported for nfs driver") + } + volume = VolumeConfiguration( + 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, journal: journalConfig) + let journalConfig = try driverOpts["journal"].map { try Self.parseJournalConfig($0) } - let volume = VolumeConfiguration( - name: name, - driver: driver, - format: "ext4", - source: blockPath(for: name), - labels: labels, - options: driverOpts, - sizeInBytes: sizeInBytes - ) + try createVolumeImage(for: name, sizeInBytes: sizeInBytes, journal: journalConfig) + volume = VolumeConfiguration( + 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) log.info( diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 8ecc932d4..64b569ef3 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -1524,6 +1524,20 @@ extension Filesystem { "\(Filesystem.SyncMode.vzRuntimeOptionKey)=\(syncMode.asVZRuntimeOption)", ], ) + case .smb(_, let share, _): + return .any( + type: "cifs", + source: share, + destination: self.destination, + options: self.resolvedMountOptions + ) + case .nfs(_, let share, _): + return .any( + type: "nfs", + source: share, + destination: self.destination, + options: self.resolvedMountOptions + ) } } diff --git a/Tests/ContainerAPIClientTests/NFSVolumeTests.swift b/Tests/ContainerAPIClientTests/NFSVolumeTests.swift new file mode 100644 index 000000000..3363cb017 --- /dev/null +++ b/Tests/ContainerAPIClientTests/NFSVolumeTests.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// 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 = VolumeConfiguration( + 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 = VolumeConfiguration( + 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 = VolumeConfiguration( + 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 including bare flags") + func testNFSDriverOptsKeyValueParsing() { + let raw = ["share=nas.local:/exports/data", "vers=4", "nolock"] + let parsed = Utility.parseKeyValuePairs(raw) + + #expect(parsed["share"] == "nas.local:/exports/data") + #expect(parsed["vers"] == "4") + #expect(parsed["nolock"] == "") + } + + @Test("Missing share opt is detectable") + func testMissingNFSShareOpt() { + let opts = Utility.parseKeyValuePairs(["vers=4"]) + #expect(opts["share"] == nil) + } +} diff --git a/Tests/ContainerAPIClientTests/SMBVolumeTests.swift b/Tests/ContainerAPIClientTests/SMBVolumeTests.swift new file mode 100644 index 000000000..c9bbdcd1e --- /dev/null +++ b/Tests/ContainerAPIClientTests/SMBVolumeTests.swift @@ -0,0 +1,115 @@ +//===----------------------------------------------------------------------===// +// 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 SMBVolumeTests { + + // MARK: - Parser: named volume syntax still works for SMB volumes + + @Test("Named volume syntax resolves as a volume, not a filesystem") + func testNamedVolumeIsParsedAsVolume() throws { + let result = try Parser.volume("myshare:/data") + guard case .volume(let parsed) = result else { + Issue.record("Expected .volume, got .filesystem") + return + } + #expect(parsed.name == "myshare") + #expect(parsed.destination == "/data") + #expect(!parsed.isAnonymous) + } + + @Test("Named volume with options is parsed correctly") + func testNamedVolumeWithOptions() throws { + let result = try Parser.volume("myshare:/data:ro") + guard case .volume(let parsed) = result else { + Issue.record("Expected .volume") + return + } + #expect(parsed.name == "myshare") + #expect(parsed.destination == "/data") + #expect(parsed.options == ["ro"]) + } + + // MARK: - Volume model: SMB driver fields + + @Test("SMB volume stores share path as source") + func testSMBVolumeSource() { + let volume = VolumeConfiguration( + name: "myshare", + driver: "smb", + format: "cifs", + source: "//server/share", + labels: [:], + options: ["username": "user", "password": "secret"], + sizeInBytes: nil + ) + + #expect(volume.driver == "smb") + #expect(volume.format == "cifs") + #expect(volume.source == "//server/share") + #expect(volume.sizeInBytes == nil) + #expect(volume.options["username"] == "user") + #expect(volume.options["password"] == "secret") + } + + @Test("SMB volume is not anonymous") + func testSMBVolumeIsNotAnonymous() { + let volume = VolumeConfiguration( + name: "myshare", + driver: "smb", + format: "cifs", + source: "//server/share", + labels: [:] + ) + #expect(!volume.isAnonymous) + } + + @Test("Local volume driver defaults remain unchanged") + func testLocalVolumeDefaults() { + let volume = VolumeConfiguration( + name: "mydata", + source: "/var/lib/container/volumes/mydata/volume.img" + ) + #expect(volume.driver == "local") + #expect(volume.format == "ext4") + } + + // MARK: - Utility: parseKeyValuePairs for SMB driver opts + + @Test("SMB driver opts are parsed from --opt flags including bare flags") + func testSMBDriverOptsKeyValueParsing() { + let raw = ["share=//server/share", "username=user", "password=secret", "domain=corp", "guest"] + let parsed = Utility.parseKeyValuePairs(raw) + + #expect(parsed["share"] == "//server/share") + #expect(parsed["username"] == "user") + #expect(parsed["password"] == "secret") + #expect(parsed["domain"] == "corp") + #expect(parsed["guest"] == "") + } + + @Test("Missing share opt is detectable") + func testMissingSMBShareOpt() { + let opts = Utility.parseKeyValuePairs(["username=user"]) + #expect(opts["share"] == nil) + } +} diff --git a/Tests/ContainerAPIServiceTests/VolumesServiceTests.swift b/Tests/ContainerAPIServiceTests/VolumesServiceTests.swift new file mode 100644 index 000000000..ef28f2895 --- /dev/null +++ b/Tests/ContainerAPIServiceTests/VolumesServiceTests.swift @@ -0,0 +1,174 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerAPIClient +import ContainerAPIService +import ContainerPersistence +import ContainerPlugin +import ContainerResource +import ContainerTestSupport +import ContainerizationError +import Foundation +import Logging +import SystemPackage +import Testing + +struct VolumesServiceTests { + private let log = Logger(label: "test") + + private func makeVolumesService(appRoot: FilePath) async throws -> VolumesService { + let appRootURL = URL(fileURLWithPath: appRoot.string) + let pluginLoader = try PluginLoader( + appRoot: appRootURL, + installRoot: appRootURL, + logRoot: nil, + pluginDirectories: [], + pluginFactories: [] + ) + let containersService = try ContainersService( + appRoot: appRootURL, + pluginLoader: pluginLoader, + containerSystemConfig: ContainerSystemConfig(), + log: log + ) + return try await VolumesService( + resourceRoot: appRoot.appending("volumes"), + containersService: containersService, + log: log + ) + } + + @Test("VolumesService creates, inspects, and deletes an SMB volume without a disk image") + func testCreateInspectDeleteSMBVolume() async throws { + try await TemporaryStorage.withTempDir { appRoot in + let service = try await makeVolumesService(appRoot: appRoot) + + let created = try await service.create( + name: "myshare", + driver: "smb", + driverOpts: ["share": "//server/share", "username": "user", "password": "secret"], + labels: ["env": "test"] + ) + + #expect(created.name == "myshare") + #expect(created.driver == "smb") + #expect(created.format == "cifs") + #expect(created.source == "//server/share") + #expect(created.sizeInBytes == nil) + #expect(created.options["username"] == "user") + #expect(created.labels["env"] == "test") + + let blockImagePath = appRoot.appending("volumes/myshare/volume.img").string + #expect(!FileManager.default.fileExists(atPath: blockImagePath)) + + let inspected = try await service.inspect("myshare") + #expect(inspected.name == "myshare") + #expect(inspected.driver == "smb") + #expect(inspected.source == "//server/share") + + try await service.delete(name: "myshare") + await #expect(throws: VolumeError.self) { + _ = try await service.inspect("myshare") + } + } + } + + @Test("VolumesService creates, inspects, and deletes an NFS volume without a disk image") + func testCreateInspectDeleteNFSVolume() async throws { + try await TemporaryStorage.withTempDir { appRoot in + let service = try await makeVolumesService(appRoot: appRoot) + + let created = try await service.create( + name: "myexport", + driver: "nfs", + driverOpts: ["share": "nas.local:/exports/data", "vers": "4", "nolock": ""], + labels: ["env": "test"] + ) + + #expect(created.name == "myexport") + #expect(created.driver == "nfs") + #expect(created.format == "nfs") + #expect(created.source == "nas.local:/exports/data") + #expect(created.sizeInBytes == nil) + #expect(created.options["vers"] == "4") + #expect(created.options["nolock"] == "") + #expect(created.labels["env"] == "test") + + let blockImagePath = appRoot.appending("volumes/myexport/volume.img").string + #expect(!FileManager.default.fileExists(atPath: blockImagePath)) + + let inspected = try await service.inspect("myexport") + #expect(inspected.name == "myexport") + #expect(inspected.driver == "nfs") + #expect(inspected.source == "nas.local:/exports/data") + + try await service.delete(name: "myexport") + await #expect(throws: VolumeError.self) { + _ = try await service.inspect("myexport") + } + } + } + + @Test( + "VolumesService rejects SMB and NFS volumes with missing or empty share option", + arguments: ["smb", "nfs"] + ) + func testRejectsMissingOrEmptyShare(_ driver: String) async throws { + try await TemporaryStorage.withTempDir { appRoot in + let service = try await makeVolumesService(appRoot: appRoot) + + await #expect(throws: VolumeError.self) { + _ = try await service.create(name: "vol1", driver: driver, driverOpts: [:]) + } + + await #expect(throws: VolumeError.self) { + _ = try await service.create(name: "vol2", driver: driver, driverOpts: ["share": ""]) + } + } + } + + @Test( + "VolumesService rejects unsupported size option for SMB and NFS volumes", + arguments: [ + ("smb", "//server/share"), + ("nfs", "nas.local:/exports/data"), + ] + ) + func testRejectsSizeOption(_ driver: String, _ share: String) async throws { + try await TemporaryStorage.withTempDir { appRoot in + let service = try await makeVolumesService(appRoot: appRoot) + + await #expect(throws: VolumeError.self) { + _ = try await service.create( + name: "vol1", + driver: driver, + driverOpts: ["share": share, "size": "10G"] + ) + } + } + } + + @Test("VolumesService rejects unsupported volume driver") + func testRejectsUnsupportedDriver() async throws { + try await TemporaryStorage.withTempDir { appRoot in + let service = try await makeVolumesService(appRoot: appRoot) + + await #expect(throws: VolumeError.self) { + _ = try await service.create(name: "vol1", driver: "ceph", driverOpts: [:]) + } + } + } +} diff --git a/Tests/ContainerCommandsTests/ListFormattingTests.swift b/Tests/ContainerCommandsTests/ListFormattingTests.swift index 5709a685a..41e7926f6 100644 --- a/Tests/ContainerCommandsTests/ListFormattingTests.swift +++ b/Tests/ContainerCommandsTests/ListFormattingTests.swift @@ -39,3 +39,29 @@ struct NetworkResourceDisplayTests { #expect(NetworkResource.tableHeader == ["NETWORK", "SUBNET"]) } } + +// MARK: - VolumeResource ListDisplayable conformance tests + +struct VolumeResourceDisplayTests { + @Test + func tableRowRedactsPasswordOption() { + let config = VolumeConfiguration( + name: "myshare", + driver: "smb", + format: "cifs", + source: "//server/share", + labels: [:], + options: [ + "share": "//server/share", + "username": "user", + "password": "supersecretpassword", + ], + sizeInBytes: nil + ) + let resource = VolumeResource(configuration: config) + let row = resource.tableRow + + #expect(row == ["myshare", "named", "smb", "password=***,share=//server/share,username=user"]) + #expect(!row[3].contains("supersecretpassword")) + } +} diff --git a/Tests/ContainerResourceTests/FilesystemNFSTests.swift b/Tests/ContainerResourceTests/FilesystemNFSTests.swift new file mode 100644 index 000000000..3f54dc026 --- /dev/null +++ b/Tests/ContainerResourceTests/FilesystemNFSTests.swift @@ -0,0 +1,149 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerResource + +struct FilesystemNFSTests { + + @Test("NFS factory produces correct FSType") + func testNFSFactory() { + let fs = Filesystem.nfs( + share: "nas.local:/exports/data", + mountOptions: ["vers": "4"], + destination: "/data", + options: [] + ) + + guard case .nfs(let name, let share, let opts) = fs.type else { + Issue.record("Expected .nfs FSType") + return + } + #expect(name == nil) + #expect(share == "nas.local:/exports/data") + #expect(opts["vers"] == "4") + #expect(fs.source == "nas.local:/exports/data") + #expect(fs.destination == "/data") + } + + @Test("isNFS returns true only for NFS filesystems") + func testIsNFS() { + let nfs = Filesystem.nfs( + share: "nas.local:/exports/data", + mountOptions: [:], + destination: "/data", + options: [] + ) + let virtiofs = Filesystem.virtiofs(source: "/host/path", destination: "/data", options: []) + let tmpfs = Filesystem.tmpfs(destination: "/tmp", options: []) + + #expect(nfs.isNFS) + #expect(!virtiofs.isNFS) + #expect(!tmpfs.isNFS) + } + + @Test("NFS filesystem is not a block device") + func testNFSIsNotBlock() { + let fs = Filesystem.nfs( + share: "nas.local:/exports/data", + mountOptions: [:], + destination: "/data", + options: [] + ) + #expect(!fs.isBlock) + #expect(!fs.isVolume) + #expect(fs.volumeName == nil) + #expect(!fs.isTmpfs) + #expect(!fs.isVirtiofs) + #expect(!fs.isSMB) + } + + @Test("Named NFS volume reports volumeName and isVolume without isBlock") + func testNamedNFSVolume() { + let fs = Filesystem.nfs( + name: "myexport", + share: "nas.local:/exports/data", + mountOptions: ["vers": "4"], + destination: "/data", + options: [] + ) + #expect(fs.isNFS) + #expect(fs.isVolume) + #expect(fs.volumeName == "myexport") + #expect(!fs.isBlock) + } + + @Test("NFS FSType round-trips through Codable") + func testNFSCodable() throws { + let original = Filesystem.nfs( + name: "myexport", + share: "nas.local:/exports/data", + mountOptions: ["vers": "4"], + destination: "/mnt/data", + options: ["ro"] + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(Filesystem.self, from: data) + + guard case .nfs(let name, let share, let opts) = decoded.type else { + Issue.record("Expected .nfs after decoding") + return + } + #expect(name == "myexport") + #expect(share == "nas.local:/exports/data") + #expect(opts["vers"] == "4") + #expect(decoded.destination == "/mnt/data") + #expect(decoded.options == ["ro"]) + } + + @Test("NFS mount options are preserved with extra options") + func testNFSWithExtraOptions() { + let fs = Filesystem.nfs( + share: "nas.local:/exports/backup", + mountOptions: ["vers": "3", "timeo": "600"], + destination: "/backup", + options: ["ro", "noatime"] + ) + + guard case .nfs(_, _, let opts) = fs.type else { + Issue.record("Expected .nfs FSType") + return + } + #expect(opts.count == 2) + #expect(fs.options == ["ro", "noatime"]) + } + + @Test("NFS resolvedMountOptions strips share, formats empty values as bare flags, and sorts keys") + func testNFSResolvedMountOptions() { + let fs = Filesystem.nfs( + name: "myexport", + share: "nas.local:/exports/backup", + mountOptions: [ + "share": "nas.local:/exports/backup", + "vers": "3", + "nolock": "", + "rsize": "1048576", + ], + destination: "/backup", + options: ["ro"] + ) + + #expect(fs.resolvedMountOptions == ["nolock", "rsize=1048576", "vers=3", "ro"]) + } +} diff --git a/Tests/ContainerResourceTests/FilesystemSMBTests.swift b/Tests/ContainerResourceTests/FilesystemSMBTests.swift new file mode 100644 index 000000000..7383ed2fc --- /dev/null +++ b/Tests/ContainerResourceTests/FilesystemSMBTests.swift @@ -0,0 +1,152 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerResource + +struct FilesystemSMBTests { + + @Test("SMB factory produces correct FSType") + func testSMBFactory() { + let fs = Filesystem.smb( + share: "//server/share", + mountOptions: ["username": "user", "password": "secret"], + destination: "/data", + options: [] + ) + + guard case .smb(let name, let share, let opts) = fs.type else { + Issue.record("Expected .smb FSType") + return + } + #expect(name == nil) + #expect(share == "//server/share") + #expect(opts["username"] == "user") + #expect(opts["password"] == "secret") + #expect(fs.source == "//server/share") + #expect(fs.destination == "/data") + } + + @Test("isSMB returns true only for SMB filesystems") + func testIsSMB() { + let smb = Filesystem.smb( + share: "//server/share", + mountOptions: [:], + destination: "/data", + options: [] + ) + let virtiofs = Filesystem.virtiofs(source: "/host/path", destination: "/data", options: []) + let tmpfs = Filesystem.tmpfs(destination: "/tmp", options: []) + + #expect(smb.isSMB) + #expect(!virtiofs.isSMB) + #expect(!tmpfs.isSMB) + } + + @Test("SMB filesystem is not a block device") + func testSMBIsNotBlock() { + let fs = Filesystem.smb( + share: "//server/share", + mountOptions: [:], + destination: "/data", + options: [] + ) + #expect(!fs.isBlock) + #expect(!fs.isVolume) + #expect(fs.volumeName == nil) + #expect(!fs.isTmpfs) + #expect(!fs.isVirtiofs) + #expect(!fs.isNFS) + } + + @Test("Named SMB volume reports volumeName and isVolume without isBlock") + func testNamedSMBVolume() { + let fs = Filesystem.smb( + name: "myshare", + share: "//server/share", + mountOptions: ["username": "user"], + destination: "/data", + options: [] + ) + #expect(fs.isSMB) + #expect(fs.isVolume) + #expect(fs.volumeName == "myshare") + #expect(!fs.isBlock) + } + + @Test("SMB FSType round-trips through Codable") + func testSMBCodable() throws { + let original = Filesystem.smb( + name: "myshare", + share: "//server/share", + mountOptions: ["username": "user", "domain": "corp"], + destination: "/mnt/data", + options: ["ro"] + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(Filesystem.self, from: data) + + guard case .smb(let name, let share, let opts) = decoded.type else { + Issue.record("Expected .smb after decoding") + return + } + #expect(name == "myshare") + #expect(share == "//server/share") + #expect(opts["username"] == "user") + #expect(opts["domain"] == "corp") + #expect(decoded.destination == "/mnt/data") + #expect(decoded.options == ["ro"]) + } + + @Test("SMB mount options are preserved with extra options") + func testSMBWithExtraOptions() { + let fs = Filesystem.smb( + share: "//nas/backup", + mountOptions: ["username": "admin", "password": "pass", "domain": "corp"], + destination: "/backup", + options: ["ro", "noatime"] + ) + + guard case .smb(_, _, let opts) = fs.type else { + Issue.record("Expected .smb FSType") + return + } + #expect(opts.count == 3) + #expect(fs.options == ["ro", "noatime"]) + } + + @Test("SMB resolvedMountOptions strips share, formats empty values as bare flags, and sorts keys") + func testSMBResolvedMountOptions() { + let fs = Filesystem.smb( + name: "myshare", + share: "//nas/backup", + mountOptions: [ + "share": "//nas/backup", + "username": "admin", + "password": "secret", + "domain": "corp", + "guest": "", + ], + destination: "/backup", + options: ["ro", "noatime"] + ) + + #expect(fs.resolvedMountOptions == ["domain=corp", "guest", "password=secret", "username=admin", "ro", "noatime"]) + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 58edd7147..16290a31c 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -914,7 +914,7 @@ Creates a new named volume with an optional size and driver-specific options. **Usage** ```bash -container volume create [--label