Named Volumes (#362)

Closes #339.

This change adds named volume support to container, providing volume
management CLI commands - `create, delete, list and inspect`. The
implementation uses EXT4 block-based persistent storage with a new
`VolumesService` actor for thread-safe operations, integrates seamlessly
with the existing container mount system through a new `.volume`
filesystem type, and provides atomic volume operations with XPC-based
API communication. Volumes are stored in isolated directories with
configurable sizes (default 512GB) and include proper cleanup and
container usage tracking for safe deletion.

Example Usage:

```
# Create a volume
container volume create mydata

# Use volume in container
container run -v mydata:/data alpine

# List volumes
container volume list

# Inspect volume details
container volume inspect mydata

# Clean up
container volume rm mydata
```
This commit is contained in:
Raj
2025-08-05 21:47:42 -07:00
committed by GitHub
parent d048ea5201
commit b8965cae43
23 changed files with 1465 additions and 47 deletions
+1
View File
@@ -143,6 +143,7 @@ integration: init-block
@$(SWIFT) test -c $(BUILD_CONFIGURATION) --filter TestCLIImagesCommand
@$(SWIFT) test -c $(BUILD_CONFIGURATION) --filter TestCLIRunBase
@$(SWIFT) test -c $(BUILD_CONFIGURATION) --filter TestCLIBuildBase
@$(SWIFT) test -c $(BUILD_CONFIGURATION) --filter TestCLIVolumes
@echo Ensuring apiserver stopped after the CLI integration tests...
@scripts/ensure-container-stopped.sh
+16 -2
View File
@@ -68,7 +68,7 @@ struct APIServer: AsyncParsableCommand {
var routes = [XPCRoute: XPCServer.RouteHandler]()
let pluginLoader = try initializePluginLoader(log: log)
try await initializePlugins(pluginLoader: pluginLoader, log: log, routes: &routes)
try initializeContainerService(root: root, pluginLoader: pluginLoader, log: log, routes: &routes)
let containersService = try initializeContainerService(root: root, pluginLoader: pluginLoader, log: log, routes: &routes)
let networkService = try await initializeNetworkService(
root: root,
pluginLoader: pluginLoader,
@@ -77,6 +77,7 @@ struct APIServer: AsyncParsableCommand {
)
initializeHealthCheckService(log: log, routes: &routes)
try initializeKernelService(log: log, routes: &routes)
try initializeVolumeService(root: root, containersService: containersService, log: log, routes: &routes)
let server = XPCServer(
identifier: "com.apple.container.apiserver",
@@ -198,7 +199,7 @@ struct APIServer: AsyncParsableCommand {
routes[XPCRoute.getDefaultKernel] = harness.getDefaultKernel
}
private func initializeContainerService(root: URL, pluginLoader: PluginLoader, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws {
private func initializeContainerService(root: URL, pluginLoader: PluginLoader, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws -> ContainersService {
let service = try ContainersService(
root: root,
pluginLoader: pluginLoader,
@@ -211,6 +212,8 @@ struct APIServer: AsyncParsableCommand {
routes[XPCRoute.deleteContainer] = harness.delete
routes[XPCRoute.containerLogs] = harness.logs
routes[XPCRoute.containerEvent] = harness.eventHandler
return service
}
private func initializeNetworkService(
@@ -242,6 +245,17 @@ struct APIServer: AsyncParsableCommand {
return service
}
private func initializeVolumeService(root: URL, containersService: ContainersService, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws {
let resourceRoot = root.appendingPathComponent("volumes")
let service = try VolumesService(resourceRoot: resourceRoot, containersService: containersService, log: log)
let harness = VolumesHarness(service: service, log: log)
routes[XPCRoute.volumeCreate] = harness.create
routes[XPCRoute.volumeDelete] = harness.delete
routes[XPCRoute.volumeList] = harness.list
routes[XPCRoute.volumeInspect] = harness.inspect
}
private static func releaseVersion() -> String {
(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? get_release_version().map { String(cString: $0) } ?? "0.0.0"
}
@@ -117,6 +117,25 @@ actor ContainersService {
}
}
/// 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>(_ operation: @Sendable @escaping ([ContainerSnapshot]) async throws -> T) async throws -> T {
try await lock.withLock { context in
var snapshots = [ContainerSnapshot]()
for (id, item) in await self.containers {
do {
let result = try await item.asSnapshot()
snapshots.append(result.0)
} catch {
self.log.error("unable to load bundle for \(id) \(error)")
}
}
return try await operation(snapshots)
}
}
/// Create a new container from the provided id and configuration.
public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions) async throws {
self.log.debug("\(#function)")
@@ -228,6 +247,7 @@ actor ContainersService {
private func _cleanup(id: String, item: Item) throws {
self.log.debug("\(#function)")
let config = try item.bundle.configuration
let label = Self.fullLaunchdServiceLabel(runtimeName: config.runtimeHandler, instanceId: id)
try ServiceManager.deregister(fullServiceLabel: label)
try item.bundle.delete()
@@ -240,7 +260,7 @@ actor ContainersService {
try ServiceManager.kill(fullServiceLabel: label)
}
private func cleanup(id: String, item: Item, context: AsyncLock.Context) async throws {
private func cleanup(id: String, item: Item, context: AsyncLock.Context) throws {
try self._cleanup(id: id, item: item)
}
@@ -257,7 +277,7 @@ actor ContainersService {
}
let options: ContainerCreateOptions = try item.bundle.load(filename: "options.json")
if options.autoRemove {
try await self.cleanup(id: id, item: item, context: context)
try self.cleanup(id: id, item: item, context: context)
}
} catch {
self.log.error(
@@ -334,6 +354,7 @@ extension ContainersService {
)
}
}
}
extension ContainersService.Item {
@@ -0,0 +1,95 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerClient
import ContainerXPC
import ContainerizationError
import Foundation
import Logging
struct VolumesHarness: Sendable {
let log: Logging.Logger
let service: VolumesService
init(service: VolumesService, log: Logging.Logger) {
self.log = log
self.service = service
}
@Sendable
func list(_ message: XPCMessage) async throws -> XPCMessage {
let volumes = try await service.list()
let data = try JSONEncoder().encode(volumes)
let reply = message.reply()
reply.set(key: .volumes, value: data)
return reply
}
@Sendable
func create(_ message: XPCMessage) async throws -> XPCMessage {
guard let name = message.string(key: .volumeName) else {
throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty")
}
let driver = message.string(key: .volumeDriver) ?? "local"
let driverOpts: [String: String]
if let driverOptsData = message.dataNoCopy(key: .volumeDriverOpts) {
driverOpts = try JSONDecoder().decode([String: String].self, from: driverOptsData)
} else {
driverOpts = [:]
}
let labels: [String: String]
if let labelsData = message.dataNoCopy(key: .volumeLabels) {
labels = try JSONDecoder().decode([String: String].self, from: labelsData)
} else {
labels = [:]
}
let volume = try await service.create(name: name, driver: driver, driverOpts: driverOpts, labels: labels)
let responseData = try JSONEncoder().encode(volume)
let reply = message.reply()
reply.set(key: .volume, value: responseData)
return reply
}
@Sendable
func delete(_ message: XPCMessage) async throws -> XPCMessage {
guard let name = message.string(key: .volumeName) else {
throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty")
}
try await service.delete(name: name)
return message.reply()
}
@Sendable
func inspect(_ message: XPCMessage) async throws -> XPCMessage {
guard let name = message.string(key: .volumeName) else {
throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty")
}
let volume = try await service.inspect(name)
let data = try JSONEncoder().encode(volume)
let reply = message.reply()
reply.set(key: .volume, value: data)
return reply
}
}
@@ -0,0 +1,215 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerClient
import ContainerPersistence
import Containerization
import ContainerizationEXT4
import ContainerizationError
import ContainerizationExtras
import ContainerizationOS
import Foundation
import Logging
import Synchronization
import SystemPackage
actor VolumesService {
private let resourceRoot: URL
private let store: ContainerPersistence.FilesystemEntityStore<Volume>
private let log: Logger
private let lock = AsyncLock()
private let containersService: ContainersService
// Storage constants
private static let entityFile = "entity.json"
private static let blockFile = "volume.img"
public init(resourceRoot: URL, containersService: ContainersService, log: Logger) throws {
try FileManager.default.createDirectory(at: resourceRoot, withIntermediateDirectories: true)
self.resourceRoot = resourceRoot
self.store = try FilesystemEntityStore<Volume>(path: resourceRoot, type: "volumes", log: log)
self.containersService = containersService
self.log = log
}
public func create(
name: String,
driver: String = "local",
driverOpts: [String: String] = [:],
labels: [String: String] = [:]
) async throws -> Volume {
try await lock.withLock { _ in
try await self._create(name: name, driver: driver, driverOpts: driverOpts, labels: labels)
}
}
public func delete(name: String) async throws {
try await lock.withLock { _ in
try await self._delete(name: name)
}
}
public func list() async throws -> [Volume] {
try await store.list()
}
public func inspect(_ name: String) async throws -> Volume {
try await lock.withLock { _ in
try await self._inspect(name)
}
}
private func parseSize(_ sizeString: String) throws -> UInt64 {
let measurement = try Measurement.parse(parsing: sizeString)
let bytes = measurement.converted(to: .bytes).value
// Validate minimum size
let minSize: UInt64 = 1.mib() // 1mib minimum
let sizeInBytes = UInt64(bytes)
guard sizeInBytes >= minSize else {
throw VolumeError.storageError("Volume size too small: minimum 1MiB")
}
return sizeInBytes
}
private nonisolated func volumePath(for name: String) -> String {
resourceRoot.appendingPathComponent(name).path
}
private nonisolated func entityPath(for name: String) -> String {
"\(volumePath(for: name))/\(Self.entityFile)"
}
private nonisolated func blockPath(for name: String) -> String {
"\(volumePath(for: name))/\(Self.blockFile)"
}
private func createVolumeDirectory(for name: String) throws {
let volumePath = volumePath(for: name)
let fm = FileManager.default
try fm.createDirectory(atPath: volumePath, withIntermediateDirectories: true, attributes: nil)
}
private func createVolumeImage(for name: String, sizeInBytes: UInt64 = VolumeStorage.defaultVolumeSizeBytes) throws {
let blockPath = blockPath(for: name)
// Use the containerization library's EXT4 formatter
let formatter = try EXT4.Formatter(
FilePath(blockPath),
blockSize: 4096,
minDiskSize: sizeInBytes
)
try formatter.close()
}
private nonisolated func removeVolumeDirectory(for name: String) throws {
let volumePath = volumePath(for: name)
let fm = FileManager.default
if fm.fileExists(atPath: volumePath) {
try fm.removeItem(atPath: volumePath)
}
}
private func _create(
name: String,
driver: String,
driverOpts: [String: String],
labels: [String: String]
) async throws -> Volume {
guard VolumeStorage.isValidVolumeName(name) else {
throw VolumeError.invalidVolumeName("Invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)")
}
// Check if volume already exists by trying to list and finding it
let existingVolumes = try await store.list()
if existingVolumes.contains(where: { $0.name == name }) {
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
}
try createVolumeImage(for: name, sizeInBytes: sizeInBytes)
let volume = Volume(
name: name,
driver: driver,
format: "ext4",
source: blockPath(for: name),
labels: labels,
options: driverOpts
)
try await store.create(volume)
log.info("Created volume", metadata: ["name": "\(name)", "driver": "\(driver)"])
return volume
}
private func _delete(name: String) async throws {
guard VolumeStorage.isValidVolumeName(name) else {
throw VolumeError.invalidVolumeName("Invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)")
}
// Check if volume exists by trying to list and finding it
let existingVolumes = try await store.list()
guard existingVolumes.contains(where: { $0.name == name }) else {
throw VolumeError.volumeNotFound(name)
}
// Check if volume is in use by any container atomically
try await containersService.withContainerList { containers in
for container in containers {
for mount in container.configuration.mounts {
if mount.isVolume && mount.volumeName == name {
throw VolumeError.volumeInUse(name)
}
}
}
try await self.store.delete(name)
try self.removeVolumeDirectory(for: name)
}
log.info("Deleted volume", metadata: ["name": "\(name)"])
}
private func _inspect(_ name: String) async throws -> Volume {
guard VolumeStorage.isValidVolumeName(name) else {
throw VolumeError.invalidVolumeName("Invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)")
}
let volumes = try await store.list()
guard let volume = volumes.first(where: { $0.name == name }) else {
throw VolumeError.volumeNotFound(name)
}
return volume
}
}
+6
View File
@@ -76,6 +76,12 @@ struct Application: AsyncParsableCommand {
RegistryCommand.self,
]
),
CommandGroup(
name: "Volume",
subcommands: [
VolumeCommand.self
]
),
CommandGroup(
name: "Other",
subcommands: Self.otherCommands()
+33
View File
@@ -0,0 +1,33 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ArgumentParser
extension Application {
struct VolumeCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "volume",
abstract: "Manage container volumes",
subcommands: [
VolumeCreate.self,
VolumeDelete.self,
VolumeList.self,
VolumeInspect.self,
],
aliases: ["v"]
)
}
}
+58
View File
@@ -0,0 +1,58 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ArgumentParser
import ContainerClient
import Foundation
extension Application.VolumeCommand {
struct VolumeCreate: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Create a volume"
)
@Argument(help: "Volume name")
var name: String
@Option(name: .customShort("s"), help: "Size of the volume (default: 512GB). Examples: 1G, 512MB, 2T")
var size: String?
@Option(name: .customLong("opt"), parsing: .upToNextOption, help: "Set driver specific options")
var driverOpts: [String] = []
@Option(name: .customLong("label"), parsing: .upToNextOption, help: "Set metadata on a volume")
var labels: [String] = []
func run() async throws {
var parsedDriverOpts = Utility.parseKeyValuePairs(driverOpts)
let parsedLabels = Utility.parseKeyValuePairs(labels)
// If --size is specified, add it to driver options
if let size = size {
parsedDriverOpts["size"] = size
}
let volume = try await ClientVolume.create(
name: name,
driver: "local",
driverOpts: parsedDriverOpts,
labels: parsedLabels
)
print(volume.name)
}
}
}
+39
View File
@@ -0,0 +1,39 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ArgumentParser
import ContainerClient
import Foundation
extension Application.VolumeCommand {
struct VolumeDelete: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Remove one or more volumes",
aliases: ["rm"]
)
@Argument(help: "Volume name(s)")
var names: [String]
func run() async throws {
for name in names {
try await ClientVolume.delete(name: name)
print(name)
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ArgumentParser
import ContainerClient
import Foundation
extension Application.VolumeCommand {
struct VolumeInspect: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "inspect",
abstract: "Display detailed information on one or more volumes"
)
@Argument(help: "Volume name(s)")
var names: [String]
func run() async throws {
var volumes: [Volume] = []
for name in names {
let volume = try await ClientVolume.inspect(name)
volumes.append(volume)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(volumes)
print(String(data: data, encoding: .utf8)!)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ArgumentParser
import ContainerClient
import ContainerizationExtras
import Foundation
extension Application.VolumeCommand {
struct VolumeList: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List volumes",
aliases: ["ls"]
)
@Flag(name: .shortAndLong, help: "Only display volume names")
var quiet: Bool = false
@Option(name: .long, help: "Format of the output")
var format: Application.ListFormat = .table
func run() async throws {
let volumes = try await ClientVolume.list()
try printVolumes(volumes: volumes, format: format)
}
private func createHeader() -> [[String]] {
[["NAME", "DRIVER", "OPTIONS"]]
}
private func printVolumes(volumes: [Volume], format: Application.ListFormat) throws {
if format == .json {
let data = try JSONEncoder().encode(volumes)
print(String(data: data, encoding: .utf8)!)
return
}
if quiet {
volumes.forEach {
print($0.name)
}
return
}
var rows = createHeader()
for volume in volumes {
rows.append(volume.asRow)
}
let formatter = TableOutput(rows: rows)
print(formatter.format())
}
}
}
extension Volume {
var asRow: [String] {
let optionsString = options.isEmpty ? "" : options.map { "\($0.key)=\($0.value)" }.joined(separator: ",")
return [
self.name,
self.driver,
optionsString,
]
}
}
@@ -0,0 +1,84 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerXPC
import Containerization
import Foundation
public struct ClientVolume {
static let serviceIdentifier = "com.apple.container.apiserver"
public static func create(
name: String,
driver: String = "local",
driverOpts: [String: String] = [:],
labels: [String: String] = [:]
) async throws -> Volume {
let client = XPCClient(service: serviceIdentifier)
let message = XPCMessage(route: .volumeCreate)
message.set(key: .volumeName, value: name)
message.set(key: .volumeDriver, value: driver)
let driverOptsData = try JSONEncoder().encode(driverOpts)
message.set(key: .volumeDriverOpts, value: driverOptsData)
let labelsData = try JSONEncoder().encode(labels)
message.set(key: .volumeLabels, value: labelsData)
let reply = try await client.send(message)
guard let responseData = reply.dataNoCopy(key: .volume) else {
throw VolumeError.storageError("Invalid response from server")
}
return try JSONDecoder().decode(Volume.self, from: responseData)
}
public static func delete(name: String) async throws {
let client = XPCClient(service: serviceIdentifier)
let message = XPCMessage(route: .volumeDelete)
message.set(key: .volumeName, value: name)
_ = try await client.send(message)
}
public static func list() async throws -> [Volume] {
let client = XPCClient(service: serviceIdentifier)
let message = XPCMessage(route: .volumeList)
let reply = try await client.send(message)
guard let responseData = reply.dataNoCopy(key: .volumes) else {
return []
}
return try JSONDecoder().decode([Volume].self, from: responseData)
}
public static func inspect(_ name: String) async throws -> Volume {
let client = XPCClient(service: serviceIdentifier)
let message = XPCMessage(route: .volumeInspect)
message.set(key: .volumeName, value: name)
let reply = try await client.send(message)
guard let responseData = reply.dataNoCopy(key: .volume) else {
throw VolumeError.volumeNotFound(name)
}
return try JSONDecoder().decode(Volume.self, from: responseData)
}
}
@@ -56,6 +56,7 @@ public struct Filesystem: Sendable, Codable {
}
case block(format: String, cache: CacheMode, sync: SyncMode)
case volume(name: String, format: String, cache: CacheMode, sync: SyncMode)
case virtiofs
case tmpfs
}
@@ -96,6 +97,19 @@ public struct Filesystem: Sendable, Codable {
)
}
/// A named volume filesystem.
public static func volume(
name: String, format: String, source: String, destination: String, options: MountOptions,
cache: CacheMode = .auto, sync: SyncMode = .full
) -> Filesystem {
.init(
type: .volume(name: name, format: format, cache: cache, sync: sync),
source: URL(fileURLWithPath: source).absolutePath(),
destination: destination,
options: options
)
}
/// A vritiofs backed filesystem providing a directory.
public static func virtiofs(source: String, destination: String, options: MountOptions) -> Filesystem {
.init(
@@ -119,10 +133,27 @@ public struct Filesystem: Sendable, Codable {
public var isBlock: Bool {
switch type {
case .block(_, _, _): true
case .volume(_, _, _, _): true
default: false
}
}
/// Returns true if the Filesystem is a named volume.
public var isVolume: Bool {
switch type {
case .volume(_, _, _, _): true
default: false
}
}
/// Returns the volume name if this is a volume filesystem, nil otherwise.
public var volumeName: String? {
switch type {
case .volume(let name, _, _, _): name
default: nil
}
}
/// Returns true if the Filesystem is backed by a in-memory mount type.
public var isTmpfs: Bool {
switch type {
+103
View File
@@ -0,0 +1,103 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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
/// A named volume that can be mounted in containers.
public struct Volume: Sendable, Codable, Equatable, Identifiable {
// id of the volume.
public var id: String { name }
// Name of the volume.
public var name: String
// Driver used to create the volume.
public var driver: String
// Filesystem format of the volume.
public var format: String
// The mount point of the volume on the host.
public var source: String
// Timestamp when the volume was created.
public var createdAt: Date
// User-defined key/value metadata.
public var labels: [String: String]
// Driver-specific options.
public var options: [String: String]
// Size of the volume in bytes (optional).
public var sizeInBytes: UInt64?
public init(
name: String,
driver: String = "local",
format: String = "ext4",
source: String,
createdAt: Date = Date(),
labels: [String: String] = [:],
options: [String: String] = [:],
sizeInBytes: UInt64? = nil,
) {
self.name = name
self.driver = driver
self.format = format
self.source = source
self.createdAt = createdAt
self.labels = labels
self.options = options
self.sizeInBytes = sizeInBytes
}
}
/// Error types for volume operations.
public enum VolumeError: Error, LocalizedError {
case volumeNotFound(String)
case volumeAlreadyExists(String)
case volumeInUse(String)
case invalidVolumeName(String)
case driverNotSupported(String)
case storageError(String)
public var errorDescription: String? {
switch self {
case .volumeNotFound(let name):
return "Volume '\(name)' not found"
case .volumeAlreadyExists(let name):
return "Volume '\(name)' already exists"
case .volumeInUse(let name):
return "Volume '\(name)' is currently in use and cannot be accessed by another container, or deleted."
case .invalidVolumeName(let name):
return "Invalid volume name '\(name)'"
case .driverNotSupported(let driver):
return "Volume driver '\(driver)' is not supported"
case .storageError(let message):
return "Storage error: \(message)"
}
}
}
/// Volume storage management utilities.
public struct VolumeStorage {
public static let volumeNamePattern = "^[A-Za-z0-9][A-Za-z0-9_.-]*$"
public static let defaultVolumeSizeBytes: UInt64 = 512 * 1024 * 1024 * 1024 // 512GB
public static func isValidVolumeName(_ name: String) -> Bool {
guard name.count <= 255 else { return false }
do {
let regex = try Regex(volumeNamePattern)
return name.contains(regex)
} catch {
return false
}
}
}
+105 -27
View File
@@ -20,6 +20,25 @@ import ContainerizationOCI
import ContainerizationOS
import Foundation
/// A parsed volume specification from user input
public struct ParsedVolume {
public let name: String
public let destination: String
public let options: [String]
public init(name: String, destination: String, options: [String] = []) {
self.name = name
self.destination = destination
self.options = options
}
}
/// Union type for parsed mount specifications
public enum VolumeOrFilesystem {
case filesystem(Filesystem)
case volume(ParsedVolume)
}
public struct Parser {
public static func memoryString(_ memory: String) throws -> Int64 {
let ram = try Measurement.parse(parsing: memory)
@@ -227,14 +246,14 @@ public struct Parser {
let mounts = mounts.dedupe()
for tmpfs in mounts {
let fs = Filesystem.tmpfs(destination: tmpfs, options: [])
try validateMount(fs)
try validateMount(.filesystem(fs))
result.append(fs)
}
return result
}
static func mounts(_ rawMounts: [String]) throws -> [Filesystem] {
var mounts: [Filesystem] = []
static func mounts(_ rawMounts: [String]) throws -> [VolumeOrFilesystem] {
var mounts: [VolumeOrFilesystem] = []
let rawMounts = rawMounts.dedupe()
for mount in rawMounts {
let m = try Parser.mount(mount)
@@ -244,7 +263,7 @@ public struct Parser {
return mounts
}
static func mount(_ mount: String) throws -> Filesystem {
static func mount(_ mount: String) throws -> VolumeOrFilesystem {
let parts = mount.split(separator: ",")
if parts.count == 0 {
throw ContainerizationError(.invalidArgument, message: "invalid mount format: \(mount)")
@@ -278,6 +297,8 @@ public struct Parser {
}
var fs = Filesystem()
var isVolume = false
var volumeName = ""
for (key, val) in directives {
var val = val
let type = directives["type"] ?? ""
@@ -317,10 +338,31 @@ public struct Parser {
let s = "mode=\(val)"
fs.options.append(s)
case "source":
let absPath = URL(filePath: val).absoluteURL.path
switch type {
case "virtiofs", "bind":
fs.source = absPath
// Check if it's an absolute directory path first
if val.hasPrefix("/") {
let url = URL(filePath: val)
let absolutePath = url.absoluteURL.path
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory) else {
throw ContainerizationError(.invalidArgument, message: "path '\(val)' does not exist")
}
guard isDirectory.boolValue else {
throw ContainerizationError(.invalidArgument, message: "path '\(val)' is not a directory")
}
fs.source = absolutePath
} else {
guard VolumeStorage.isValidVolumeName(val) else {
throw ContainerizationError(.invalidArgument, message: "Invalid volume name '\(val)': must match \(VolumeStorage.volumeNamePattern)")
}
// This is a named volume
isVolume = true
volumeName = val
fs.source = val
}
case "tmpfs":
throw ContainerizationError(.invalidArgument, message: "cannot specify source for tmpfs mount")
default:
@@ -332,11 +374,20 @@ public struct Parser {
throw ContainerizationError(.invalidArgument, message: "unknown mount directive \(key)")
}
}
return fs
guard isVolume else {
return .filesystem(fs)
}
return .volume(
ParsedVolume(
name: volumeName,
destination: fs.destination,
options: fs.options
))
}
static func volumes(_ rawVolumes: [String]) throws -> [Filesystem] {
var mounts: [Filesystem] = []
static func volumes(_ rawVolumes: [String]) throws -> [VolumeOrFilesystem] {
var mounts: [VolumeOrFilesystem] = []
for volume in rawVolumes {
let m = try Parser.volume(volume)
try Parser.validateMount(m)
@@ -345,7 +396,7 @@ public struct Parser {
return mounts
}
private static func volume(_ volume: String) throws -> Filesystem {
private static func volume(_ volume: String) throws -> VolumeOrFilesystem {
var vol = volume
vol.trimLeft(char: ":")
@@ -354,24 +405,43 @@ public struct Parser {
case 1:
throw ContainerizationError(.invalidArgument, message: "anonymous volumes are not supported")
case 2, 3:
// Bind / volume mounts.
let src = String(parts[0])
let dst = String(parts[1])
let abs = URL(filePath: src).absoluteURL.path
if !FileManager.default.fileExists(atPath: abs) {
throw ContainerizationError(.invalidArgument, message: "named volumes are not supported")
// Check if it's an absolute directory path first
guard src.hasPrefix("/") else {
// Named volume - validate name syntax only
guard VolumeStorage.isValidVolumeName(src) else {
throw ContainerizationError(.invalidArgument, message: "Invalid volume name '\(src)': must match \(VolumeStorage.volumeNamePattern)")
}
// This is a named volume
let options = parts.count == 3 ? parts[2].split(separator: ",").map { String($0) } : []
return .volume(
ParsedVolume(
name: src,
destination: dst,
options: options
))
}
let url = URL(filePath: src)
let absolutePath = url.absoluteURL.path
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory) else {
throw ContainerizationError(.invalidArgument, message: "path '\(src)' does not exist")
}
// This is a filesystem mount
var fs = Filesystem.virtiofs(
source: URL(fileURLWithPath: src).absolutePath(),
source: URL(fileURLWithPath: absolutePath).absolutePath(),
destination: dst,
options: []
)
if parts.count == 3 {
fs.options = parts[2].split(separator: ",").map { String($0) }
}
return fs
return .filesystem(fs)
default:
throw ContainerizationError(.invalidArgument, message: "invalid volume format \(volume)")
}
@@ -381,19 +451,27 @@ public struct Parser {
mountTypes.contains(type)
}
static func validateMount(_ mount: Filesystem) throws {
if !mount.isTmpfs {
if !mount.source.isAbsolutePath() {
throw ContainerizationError(
.invalidArgument, message: "\(mount.source) is not an absolute path on the host")
static func validateMount(_ mount: VolumeOrFilesystem) throws {
switch mount {
case .filesystem(let fs):
if !fs.isTmpfs {
if !fs.source.isAbsolutePath() {
throw ContainerizationError(
.invalidArgument, message: "\(fs.source) is not an absolute path on the host")
}
if !FileManager.default.fileExists(atPath: fs.source) {
throw ContainerizationError(.invalidArgument, message: "file path '\(fs.source)' does not exist")
}
}
if !FileManager.default.fileExists(atPath: mount.source) {
throw ContainerizationError(.invalidArgument, message: "file path '\(mount.source)' does not exist")
}
}
if mount.destination.isEmpty {
throw ContainerizationError(.invalidArgument, message: "mount destination cannot be empty")
if fs.destination.isEmpty {
throw ContainerizationError(.invalidArgument, message: "mount destination cannot be empty")
}
case .volume(let vol):
if vol.destination.isEmpty {
throw ContainerizationError(.invalidArgument, message: "volume destination cannot be empty")
}
// Volume name validation already done during parsing
}
}
+47 -5
View File
@@ -144,11 +144,35 @@ public struct Utility {
)
let tmpfs = try Parser.tmpfsMounts(management.tmpFs)
let volumes = try Parser.volumes(management.volumes)
var mounts = try Parser.mounts(management.mounts)
mounts.append(contentsOf: tmpfs)
mounts.append(contentsOf: volumes)
config.mounts = mounts
let volumesOrFs = try Parser.volumes(management.volumes)
let mountsOrFs = try Parser.mounts(management.mounts)
var resolvedMounts: [Filesystem] = []
resolvedMounts.append(contentsOf: tmpfs)
// Resolve volumes and filesystems
for item in (volumesOrFs + mountsOrFs) {
switch item {
case .filesystem(let fs):
resolvedMounts.append(fs)
case .volume(let parsed):
do {
let volume = try await ClientVolume.inspect(parsed.name)
let volumeMount = Filesystem.volume(
name: parsed.name,
format: volume.format,
source: volume.source,
destination: parsed.destination,
options: parsed.options
)
resolvedMounts.append(volumeMount)
} catch {
throw ContainerizationError(.invalidArgument, message: "volume '\(parsed.name)' not found")
}
}
}
config.mounts = resolvedMounts
config.virtualization = management.virtualization
@@ -211,4 +235,22 @@ public struct Utility {
}
return try await ClientKernel.getDefaultKernel(for: s)
}
/// Parses key-value pairs from command line arguments.
///
/// Supports formats like "key=value" and standalone keys (treated as "key=").
/// - Parameter pairs: Array of strings in "key=value" format
/// - Returns: Dictionary mapping keys to values
public static func parseKeyValuePairs(_ pairs: [String]) -> [String: String] {
var result: [String: String] = [:]
for pair in pairs {
let components = pair.split(separator: "=", maxSplits: 1)
if components.count == 2 {
result[String(components[0])] = String(components[1])
} else {
result[pair] = ""
}
}
return result
}
}
+15
View File
@@ -94,6 +94,16 @@ public enum XPCKeys: String {
case kernelTarURL
case kernelFilePath
case systemPlatform
/// Volume
case volume
case volumes
case volumeName
case volumeDriver
case volumeDriverOpts
case volumeLabels
case volumeReadonly
case volumeContainerId
}
public enum XPCRoute: String {
@@ -113,6 +123,11 @@ public enum XPCRoute: String {
case networkDelete
case networkList
case volumeCreate
case volumeDelete
case volumeList
case volumeInspect
case ping
case installKernel
+11 -2
View File
@@ -155,8 +155,17 @@ public struct XPCServer: Sendable {
} catch {
let reply = message.reply()
log.error("handler for \(route) threw error \(error)")
let err = ContainerizationError(.unknown, message: String(describing: error))
reply.set(error: err)
// Check if this is a VolumeError by looking at the error description
let errorMessage = error.localizedDescription
let errorTypeString = String(describing: type(of: error))
if errorTypeString.contains("VolumeError") || errorMessage.contains("Volume") {
let err = ContainerizationError(.invalidArgument, message: errorMessage)
reply.set(error: err)
} else {
let err = ContainerizationError(.unknown, message: String(describing: error))
reply.set(error: err)
}
xpc_connection_send_message(connection, reply.underlying)
}
}
@@ -973,6 +973,13 @@ extension Filesystem {
destination: self.destination,
options: self.options
)
case .volume(_, let format, _, _):
return .block(
format: format,
source: self.source,
destination: self.destination,
options: self.options
)
}
}
@@ -0,0 +1,250 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerClient
import Foundation
import Testing
class TestCLIVolumes: CLITest {
func doVolumeCreate(name: String) throws {
let (_, error, status) = try run(arguments: ["volume", "create", name])
if status != 0 {
throw CLIError.executionFailed("volume create failed: \(error)")
}
}
func doVolumeDelete(name: String) throws {
let (_, error, status) = try run(arguments: ["volume", "rm", name])
if status != 0 {
throw CLIError.executionFailed("volume delete failed: \(error)")
}
}
func doVolumeDeleteIfExists(name: String) {
let (_, _, _) = (try? run(arguments: ["volume", "rm", name])) ?? ("", "", 1)
}
func doRemoveIfExists(name: String, force: Bool = false) {
var args = ["delete"]
if force {
args.append("--force")
}
args.append(name)
let (_, _, _) = (try? run(arguments: args)) ?? ("", "", 1)
}
func doesVolumeDeleteFail(name: String) throws -> Bool {
let (_, _, status) = try run(arguments: ["volume", "rm", name])
return status != 0
}
@Test func testVolumeDataPersistenceAcrossContainers() throws {
let testName: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"])
let volumeName = "\(testName!)_vol"
let container1Name = "\(testName!)_c1"
let container2Name = "\(testName!)_c2"
let testData = "persistent-data-test"
let testFile = "/data/test.txt"
// Clean up any existing resources from previous runs
doVolumeDeleteIfExists(name: volumeName)
doRemoveIfExists(name: container1Name, force: true)
doRemoveIfExists(name: container2Name, force: true)
defer {
// Cleanup containers and volume
try? doStop(name: container1Name)
doRemoveIfExists(name: container1Name, force: true)
try? doStop(name: container2Name)
doRemoveIfExists(name: container2Name, force: true)
doVolumeDeleteIfExists(name: volumeName)
}
// Create volume
try doVolumeCreate(name: volumeName)
// Run first container with volume, write data, then stop
try doLongRun(name: container1Name, args: ["-v", "\(volumeName):/data"])
try waitForContainerRunning(container1Name)
// Write test data to the volume
_ = try doExec(name: container1Name, cmd: ["sh", "-c", "echo '\(testData)' > \(testFile)"])
// Stop first container
try doStop(name: container1Name)
// Run second container with same volume
try doLongRun(name: container2Name, args: ["-v", "\(volumeName):/data"])
try waitForContainerRunning(container2Name)
// Verify data persisted
var output = try doExec(name: container2Name, cmd: ["cat", testFile])
output = output.trimmingCharacters(in: .whitespacesAndNewlines)
#expect(output == testData, "expected persisted data '\(testData)', instead got '\(output)'")
try doStop(name: container2Name)
try doVolumeDelete(name: volumeName)
}
@Test func testVolumeSharedAccessConflict() throws {
let testName: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"])
let volumeName = "\(testName!)_vol"
let container1Name = "\(testName!)_c1"
let container2Name = "\(testName!)_c2"
// Clean up any existing resources from previous runs
doVolumeDeleteIfExists(name: volumeName)
doRemoveIfExists(name: container1Name, force: true)
doRemoveIfExists(name: container2Name, force: true)
defer {
// Cleanup containers and volume
try? doStop(name: container1Name)
doRemoveIfExists(name: container1Name, force: true)
try? doStop(name: container2Name)
doRemoveIfExists(name: container2Name, force: true)
doVolumeDeleteIfExists(name: volumeName)
}
// Create volume
try doVolumeCreate(name: volumeName)
// Run first container with volume
try doLongRun(name: container1Name, args: ["-v", "\(volumeName):/data"])
try waitForContainerRunning(container1Name)
// Try to run second container with same volume - should fail
let (_, _, status) = try run(arguments: ["run", "--name", container2Name, "-v", "\(volumeName):/data", alpine] + defaultContainerArgs)
#expect(status != 0, "second container should fail when trying to use volume already in use")
// Cleanup
try doStop(name: container1Name)
doRemoveIfExists(name: container1Name, force: true)
doVolumeDeleteIfExists(name: volumeName)
}
@Test func testVolumeDeleteProtectionWhileInUse() throws {
let testName: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"])
let volumeName = "\(testName!)_vol"
let containerName = "\(testName!)_c1"
// Clean up any existing resources from previous runs
doVolumeDeleteIfExists(name: volumeName)
doRemoveIfExists(name: containerName, force: true)
defer {
// Cleanup container and volume
try? doStop(name: containerName)
doRemoveIfExists(name: containerName, force: true)
doVolumeDeleteIfExists(name: volumeName)
}
// Create volume
try doVolumeCreate(name: volumeName)
// Run container with volume
try doLongRun(name: containerName, args: ["-v", "\(volumeName):/data"])
try waitForContainerRunning(containerName)
// Try to delete volume while container is running - should fail
let deleteFailedWhileInUse = try doesVolumeDeleteFail(name: volumeName)
#expect(deleteFailedWhileInUse, "volume delete should fail while volume is in use")
// Stop container
try doStop(name: containerName)
doRemoveIfExists(name: containerName, force: true)
// Now volume delete should succeed
try doVolumeDelete(name: volumeName)
}
@Test func testVolumeDeleteProtectionWithCreatedContainer() async throws {
let testName: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"])
let volumeName = "\(testName!)_vol"
let containerName = "\(testName!)_c1"
// Clean up any existing resources from previous runs
doVolumeDeleteIfExists(name: volumeName)
doRemoveIfExists(name: containerName, force: true)
defer {
// Cleanup container and volume
try? doStop(name: containerName)
doRemoveIfExists(name: containerName, force: true)
doVolumeDeleteIfExists(name: volumeName)
}
// Create volume
try doVolumeCreate(name: volumeName)
// Create (but don't start) container with volume
try doCreate(name: containerName, image: alpine, volumes: ["\(volumeName):/mnt/data"])
// Give some time for container to be fully registered
try await Task.sleep(for: .seconds(1))
// Try to delete volume while container is created - should fail
let deleteFailedWhileInUse = try doesVolumeDeleteFail(name: volumeName)
#expect(deleteFailedWhileInUse, "volume delete should fail when volume is used by created container")
// Remove the container
doRemoveIfExists(name: containerName, force: true)
// Now volume delete should succeed
doVolumeDeleteIfExists(name: volumeName)
}
@Test func testVolumeBasicOperations() throws {
let testName: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"])
let volumeName = "\(testName!)_vol"
// Clean up any existing resources from previous runs
doVolumeDeleteIfExists(name: volumeName)
defer {
doVolumeDeleteIfExists(name: volumeName)
}
// Create volume
try doVolumeCreate(name: volumeName)
// List volumes and verify it exists
let (output, error, status) = try run(arguments: ["volume", "list", "--quiet"])
if status != 0 {
throw CLIError.executionFailed("volume list failed: \(error)")
}
let volumes = output.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
#expect(volumes.contains(volumeName), "created volume should appear in list")
// Inspect volume
let (inspectOutput, inspectError, inspectStatus) = try run(arguments: ["volume", "inspect", volumeName])
if inspectStatus != 0 {
throw CLIError.executionFailed("volume inspect failed: \(inspectError)")
}
#expect(inspectOutput.contains(volumeName), "volume inspect should contain volume name")
// Delete volume
try doVolumeDelete(name: volumeName)
}
}
+12 -9
View File
@@ -223,17 +223,20 @@ class CLITest {
}
}
func doCreate(name: String, image: String? = nil, args: [String]? = nil) throws {
func doCreate(name: String, image: String? = nil, args: [String]? = nil, volumes: [String] = []) throws {
let image = image ?? alpine
let args: [String] = args ?? ["sleep", "infinity"]
let (_, error, status) = try run(
arguments: [
"create",
"--rm",
"--name",
name,
image,
] + args)
var arguments = ["create", "--rm", "--name", name]
// Add volume mounts
for volume in volumes {
arguments += ["-v", volume]
}
arguments += [image] + args
let (_, error, status) = try run(arguments: arguments)
if status != 0 {
throw CLIError.executionFailed("command failed: \(error)")
}
@@ -0,0 +1,54 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerClient
struct UtilityTests {
@Test("Parse simple key-value pairs")
func testSimpleKeyValuePairs() {
let result = Utility.parseKeyValuePairs(["key1=value1", "key2=value2"])
#expect(result["key1"] == "value1")
#expect(result["key2"] == "value2")
}
@Test("Parse standalone keys")
func testStandaloneKeys() {
let result = Utility.parseKeyValuePairs(["standalone"])
#expect(result["standalone"] == "")
}
@Test("Parse empty input")
func testEmptyInput() {
let result = Utility.parseKeyValuePairs([])
#expect(result.isEmpty)
}
@Test("Parse mixed format")
func testMixedFormat() {
let result = Utility.parseKeyValuePairs(["key1=value1", "standalone", "key2=value2"])
#expect(result["key1"] == "value1")
#expect(result["standalone"] == "")
#expect(result["key2"] == "value2")
}
}
@@ -0,0 +1,134 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerClient
struct VolumeValidationTests {
@Test("Valid volume names should pass validation")
func testValidVolumeNames() {
let validNames = [
"a", // Single alphanumeric
"1", // Single numeric
"volume1", // Alphanumeric
"my-volume", // With hyphen
"my_volume", // With underscore
"my.volume", // With period
"volume-1.2_test", // Mixed valid characters
"1volume", // Starting with number
"Avolume", // Starting with uppercase
"a" + String(repeating: "x", count: 254), // Max length (255)
]
for name in validNames {
#expect(VolumeStorage.isValidVolumeName(name), "'\(name)' should be valid")
}
}
@Test("Invalid volume names should fail validation")
func testInvalidVolumeNames() {
let invalidNames = [
"", // Empty string
".volume", // Starting with period
"_volume", // Starting with underscore
"-volume", // Starting with hyphen
"volume@", // Contains invalid character (@)
"volume space", // Contains space
"volume/path", // Contains slash
"volume:tag", // Contains colon
"volume#hash", // Contains hash
"volume$", // Contains dollar sign
"volume!", // Contains exclamation
"volume%", // Contains percent
"volume*", // Contains asterisk
"volume+", // Contains plus
"volume=", // Contains equals
"volume[", // Contains bracket
"volume]", // Contains bracket
"volume{", // Contains brace
"volume}", // Contains brace
"volume|", // Contains pipe
"volume\\", // Contains backslash
"volume\"", // Contains quote
"volume'", // Contains single quote
"volume<", // Contains less than
"volume>", // Contains greater than
"volume?", // Contains question mark
"volume,", // Contains comma
"volume;", // Contains semicolon
"a" + String(repeating: "x", count: 255), // Too long (256 chars)
]
for name in invalidNames {
#expect(!VolumeStorage.isValidVolumeName(name), "'\(name)' should be invalid")
}
}
@Test("Edge cases for volume name validation")
func testVolumeNameEdgeCases() {
// Test exact boundary conditions
#expect(VolumeStorage.isValidVolumeName("a"), "Single character should be valid")
#expect(!VolumeStorage.isValidVolumeName(""), "Empty string should be invalid")
// Test maximum length boundary
let maxLengthName = String(repeating: "a", count: 255)
let tooLongName = String(repeating: "a", count: 256)
#expect(VolumeStorage.isValidVolumeName(maxLengthName), "255 character name should be valid")
#expect(!VolumeStorage.isValidVolumeName(tooLongName), "256 character name should be invalid")
// Test other edge cases
#expect(VolumeStorage.isValidVolumeName("0volume"), "Name starting with digit should be valid")
#expect(VolumeStorage.isValidVolumeName("Volume"), "Name starting with uppercase should be valid")
#expect(!VolumeStorage.isValidVolumeName(".hidden"), "Name starting with period should be invalid")
#expect(!VolumeStorage.isValidVolumeName("_private"), "Name starting with underscore should be invalid")
#expect(!VolumeStorage.isValidVolumeName("-dash"), "Name starting with hyphen should be invalid")
}
@Test("Unicode and special character handling")
func testUnicodeCharacters() {
let unicodeNames = [
"volume-ñ", // Non-ASCII letter
"volume-中文", // Chinese characters
"volume-🍎", // Emoji
"volume-café", // Accented characters
"αβγ", // Greek letters
]
for name in unicodeNames {
#expect(!VolumeStorage.isValidVolumeName(name), "Unicode name '\(name)' should be invalid")
}
}
@Test("Common Container volume name patterns")
func testCommonVolumeNames() {
let commonPatterns = [
"myapp-data", // Common app data volume
"postgres_data", // Database volume
"nginx.conf", // Config volume
"logs-2024", // Log volume with year
"cache_redis_v1.2", // Version-tagged cache
"backup.daily", // Backup volume
"shared-storage", // Shared volume
]
for name in commonPatterns {
#expect(VolumeStorage.isValidVolumeName(name), "Common volume name pattern '\(name)' should be valid")
}
}
}