diff --git a/Makefile b/Makefile index 70286c85..6b85625c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/Sources/APIServer/APIServer.swift b/Sources/APIServer/APIServer.swift index 343812b1..4b7c2998 100644 --- a/Sources/APIServer/APIServer.swift +++ b/Sources/APIServer/APIServer.swift @@ -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" } diff --git a/Sources/APIServer/Containers/ContainersService.swift b/Sources/APIServer/Containers/ContainersService.swift index edadc3f0..ca8da054 100644 --- a/Sources/APIServer/Containers/ContainersService.swift +++ b/Sources/APIServer/Containers/ContainersService.swift @@ -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(_ 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 { diff --git a/Sources/APIServer/Volumes/VolumesHarness.swift b/Sources/APIServer/Volumes/VolumesHarness.swift new file mode 100644 index 00000000..eff0a55b --- /dev/null +++ b/Sources/APIServer/Volumes/VolumesHarness.swift @@ -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 + } +} diff --git a/Sources/APIServer/Volumes/VolumesService.swift b/Sources/APIServer/Volumes/VolumesService.swift new file mode 100644 index 00000000..d4a703f9 --- /dev/null +++ b/Sources/APIServer/Volumes/VolumesService.swift @@ -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 + 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(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 + } + +} diff --git a/Sources/CLI/Application.swift b/Sources/CLI/Application.swift index 1b7c5d80..4236e99b 100644 --- a/Sources/CLI/Application.swift +++ b/Sources/CLI/Application.swift @@ -76,6 +76,12 @@ struct Application: AsyncParsableCommand { RegistryCommand.self, ] ), + CommandGroup( + name: "Volume", + subcommands: [ + VolumeCommand.self + ] + ), CommandGroup( name: "Other", subcommands: Self.otherCommands() diff --git a/Sources/CLI/Volume/VolumeCommand.swift b/Sources/CLI/Volume/VolumeCommand.swift new file mode 100644 index 00000000..fe429369 --- /dev/null +++ b/Sources/CLI/Volume/VolumeCommand.swift @@ -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"] + ) + } +} diff --git a/Sources/CLI/Volume/VolumeCreate.swift b/Sources/CLI/Volume/VolumeCreate.swift new file mode 100644 index 00000000..de7bbe30 --- /dev/null +++ b/Sources/CLI/Volume/VolumeCreate.swift @@ -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) + } + } +} diff --git a/Sources/CLI/Volume/VolumeDelete.swift b/Sources/CLI/Volume/VolumeDelete.swift new file mode 100644 index 00000000..5dddab7e --- /dev/null +++ b/Sources/CLI/Volume/VolumeDelete.swift @@ -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) + } + } + } +} diff --git a/Sources/CLI/Volume/VolumeInspect.swift b/Sources/CLI/Volume/VolumeInspect.swift new file mode 100644 index 00000000..1fe08b01 --- /dev/null +++ b/Sources/CLI/Volume/VolumeInspect.swift @@ -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)!) + } + } +} diff --git a/Sources/CLI/Volume/VolumeList.swift b/Sources/CLI/Volume/VolumeList.swift new file mode 100644 index 00000000..7bd3ce26 --- /dev/null +++ b/Sources/CLI/Volume/VolumeList.swift @@ -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, + ] + } +} diff --git a/Sources/ContainerClient/Core/ClientVolume.swift b/Sources/ContainerClient/Core/ClientVolume.swift new file mode 100644 index 00000000..b0ed23de --- /dev/null +++ b/Sources/ContainerClient/Core/ClientVolume.swift @@ -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) + } + +} diff --git a/Sources/ContainerClient/Core/Filesystem.swift b/Sources/ContainerClient/Core/Filesystem.swift index e7e59e41..0af4771f 100644 --- a/Sources/ContainerClient/Core/Filesystem.swift +++ b/Sources/ContainerClient/Core/Filesystem.swift @@ -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 { diff --git a/Sources/ContainerClient/Core/Volume.swift b/Sources/ContainerClient/Core/Volume.swift new file mode 100644 index 00000000..7742aab5 --- /dev/null +++ b/Sources/ContainerClient/Core/Volume.swift @@ -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 + } + } +} diff --git a/Sources/ContainerClient/Parser.swift b/Sources/ContainerClient/Parser.swift index f3be89d3..4c9dd98c 100644 --- a/Sources/ContainerClient/Parser.swift +++ b/Sources/ContainerClient/Parser.swift @@ -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 } } diff --git a/Sources/ContainerClient/Utility.swift b/Sources/ContainerClient/Utility.swift index 6f7af413..a5675145 100644 --- a/Sources/ContainerClient/Utility.swift +++ b/Sources/ContainerClient/Utility.swift @@ -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 + } } diff --git a/Sources/ContainerClient/XPC+.swift b/Sources/ContainerClient/XPC+.swift index 183a239f..1582c25b 100644 --- a/Sources/ContainerClient/XPC+.swift +++ b/Sources/ContainerClient/XPC+.swift @@ -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 diff --git a/Sources/ContainerXPC/XPCServer.swift b/Sources/ContainerXPC/XPCServer.swift index 3af374a8..d496e877 100644 --- a/Sources/ContainerXPC/XPCServer.swift +++ b/Sources/ContainerXPC/XPCServer.swift @@ -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) } } diff --git a/Sources/Services/ContainerSandboxService/SandboxService.swift b/Sources/Services/ContainerSandboxService/SandboxService.swift index cf4d6cd1..5039679e 100644 --- a/Sources/Services/ContainerSandboxService/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/SandboxService.swift @@ -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 + ) } } diff --git a/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift b/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift new file mode 100644 index 00000000..7229f79d --- /dev/null +++ b/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift @@ -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) + } +} diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index 249e4124..3bf168ec 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -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)") } diff --git a/Tests/ContainerClientTests/UtilityTests.swift b/Tests/ContainerClientTests/UtilityTests.swift new file mode 100644 index 00000000..dd5fc8d7 --- /dev/null +++ b/Tests/ContainerClientTests/UtilityTests.swift @@ -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") + } +} diff --git a/Tests/ContainerClientTests/VolumeValidationTests.swift b/Tests/ContainerClientTests/VolumeValidationTests.swift new file mode 100644 index 00000000..e4e5fa4a --- /dev/null +++ b/Tests/ContainerClientTests/VolumeValidationTests.swift @@ -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") + } + } +}