mirror of
https://github.com/apple/container.git
synced 2026-09-23 16:15:37 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -76,6 +76,12 @@ struct Application: AsyncParsableCommand {
|
||||
RegistryCommand.self,
|
||||
]
|
||||
),
|
||||
CommandGroup(
|
||||
name: "Volume",
|
||||
subcommands: [
|
||||
VolumeCommand.self
|
||||
]
|
||||
),
|
||||
CommandGroup(
|
||||
name: "Other",
|
||||
subcommands: Self.otherCommands()
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user