From 7d905bde646d4532ca57f733be757ff09af5515a Mon Sep 17 00:00:00 2001 From: J Logan Date: Mon, 22 Sep 2025 12:17:24 -0700 Subject: [PATCH] Cleans up network and volume subcommands. (#661) - Part of #515. - Order options alphabetically. - Use consistent delete logic for network and volume delete. ## Motivation and Context See #515 --- .../Network/NetworkCommand.swift | 3 +- .../Network/NetworkCreate.swift | 9 ++- .../Network/NetworkDelete.swift | 9 ++- .../Network/NetworkInspect.swift | 7 +- .../Network/NetworkList.swift | 9 ++- .../Volume/VolumeCommand.swift | 3 +- .../Volume/VolumeCreate.swift | 14 ++-- .../Volume/VolumeDelete.swift | 73 +++++++++++++++++-- .../Volume/VolumeInspect.swift | 10 ++- .../ContainerCommands/Volume/VolumeList.swift | 12 ++- 10 files changed, 113 insertions(+), 36 deletions(-) diff --git a/Sources/ContainerCommands/Network/NetworkCommand.swift b/Sources/ContainerCommands/Network/NetworkCommand.swift index b2d0797c..73670848 100644 --- a/Sources/ContainerCommands/Network/NetworkCommand.swift +++ b/Sources/ContainerCommands/Network/NetworkCommand.swift @@ -18,7 +18,6 @@ import ArgumentParser extension Application { public struct NetworkCommand: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "network", abstract: "Manage container networks", @@ -30,5 +29,7 @@ extension Application { ], aliases: ["n"] ) + + public init() {} } } diff --git a/Sources/ContainerCommands/Network/NetworkCreate.swift b/Sources/ContainerCommands/Network/NetworkCreate.swift index a759eea5..705ecfcd 100644 --- a/Sources/ContainerCommands/Network/NetworkCreate.swift +++ b/Sources/ContainerCommands/Network/NetworkCreate.swift @@ -23,20 +23,21 @@ import TerminalProgress extension Application { public struct NetworkCreate: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a new network") + @Option(name: .customLong("label"), help: "Set metadata for a network") + var labels: [String] = [] + @OptionGroup var global: Flags.Global - @Option(name: .customLong("label"), help: "Set metadata on a network") - var labels: [String] = [] - @Argument(help: "Network name") var name: String + public init() {} + public func run() async throws { let parsedLabels = Utility.parseKeyValuePairs(labels) let config = try NetworkConfiguration(id: self.name, mode: .nat, labels: parsedLabels) diff --git a/Sources/ContainerCommands/Network/NetworkDelete.swift b/Sources/ContainerCommands/Network/NetworkDelete.swift index ac3aa02b..80e545e2 100644 --- a/Sources/ContainerCommands/Network/NetworkDelete.swift +++ b/Sources/ContainerCommands/Network/NetworkDelete.swift @@ -22,21 +22,22 @@ import Foundation extension Application { public struct NetworkDelete: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Delete one or more networks", aliases: ["rm"]) + @Flag(name: .shortAndLong, help: "Delete all networks") + var all = false + @OptionGroup var global: Flags.Global - @Flag(name: .shortAndLong, help: "Remove all networks") - var all = false - @Argument(help: "Network names") var networkNames: [String] = [] + public init() {} + public func validate() throws { if networkNames.count == 0 && !all { throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied") diff --git a/Sources/ContainerCommands/Network/NetworkInspect.swift b/Sources/ContainerCommands/Network/NetworkInspect.swift index 3c4b918e..d80ddd59 100644 --- a/Sources/ContainerCommands/Network/NetworkInspect.swift +++ b/Sources/ContainerCommands/Network/NetworkInspect.swift @@ -22,16 +22,17 @@ import SwiftProtobuf extension Application { public struct NetworkInspect: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "inspect", abstract: "Display information about one or more networks") + @Argument(help: "Networks to inspect") + var networks: [String] + @OptionGroup var global: Flags.Global - @Argument(help: "Networks to inspect") - var networks: [String] + public init() {} public func run() async throws { let objects: [any Codable] = try await ClientNetwork.list().filter { diff --git a/Sources/ContainerCommands/Network/NetworkList.swift b/Sources/ContainerCommands/Network/NetworkList.swift index 155e90bc..4ad71e1e 100644 --- a/Sources/ContainerCommands/Network/NetworkList.swift +++ b/Sources/ContainerCommands/Network/NetworkList.swift @@ -23,21 +23,22 @@ import SwiftProtobuf extension Application { public struct NetworkList: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "list", abstract: "List networks", aliases: ["ls"]) - @Flag(name: .shortAndLong, help: "Only output the network name") - var quiet = false - @Option(name: .long, help: "Format of the output") var format: ListFormat = .table + @Flag(name: .shortAndLong, help: "Only output the network name") + var quiet = false + @OptionGroup var global: Flags.Global + public init() {} + public func run() async throws { let networks = try await ClientNetwork.list() try printNetworks(networks: networks, format: format) diff --git a/Sources/ContainerCommands/Volume/VolumeCommand.swift b/Sources/ContainerCommands/Volume/VolumeCommand.swift index f4e0f426..4b8893e4 100644 --- a/Sources/ContainerCommands/Volume/VolumeCommand.swift +++ b/Sources/ContainerCommands/Volume/VolumeCommand.swift @@ -18,7 +18,6 @@ import ArgumentParser extension Application { public struct VolumeCommand: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "volume", abstract: "Manage container volumes", @@ -30,5 +29,7 @@ extension Application { ], aliases: ["v"] ) + + public init() {} } } diff --git a/Sources/ContainerCommands/Volume/VolumeCreate.swift b/Sources/ContainerCommands/Volume/VolumeCreate.swift index 928abbd9..92a8227b 100644 --- a/Sources/ContainerCommands/Volume/VolumeCreate.swift +++ b/Sources/ContainerCommands/Volume/VolumeCreate.swift @@ -20,24 +20,28 @@ import Foundation extension Application.VolumeCommand { public struct VolumeCreate: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create a volume" ) - @Option(name: .customShort("s"), help: "Size of the volume (default: 512GB). Examples: 1G, 512MB, 2T") - var size: String? + @Option(name: .customLong("label"), help: "Set metadata for a volume") + var labels: [String] = [] @Option(name: .customLong("opt"), help: "Set driver specific options") var driverOpts: [String] = [] - @Option(name: .customLong("label"), help: "Set metadata on a volume") - var labels: [String] = [] + @Option(name: .short, help: "Size of the volume in bytes, with optional K, M, G, T, or P suffix") + var size: String? + + @OptionGroup + var global: Flags.Global @Argument(help: "Volume name") var name: String + public init() {} + public func run() async throws { var parsedDriverOpts = Utility.parseKeyValuePairs(driverOpts) let parsedLabels = Utility.parseKeyValuePairs(labels) diff --git a/Sources/ContainerCommands/Volume/VolumeDelete.swift b/Sources/ContainerCommands/Volume/VolumeDelete.swift index 80e3408d..54133d21 100644 --- a/Sources/ContainerCommands/Volume/VolumeDelete.swift +++ b/Sources/ContainerCommands/Volume/VolumeDelete.swift @@ -16,24 +16,83 @@ import ArgumentParser import ContainerClient +import ContainerizationError import Foundation extension Application.VolumeCommand { public struct VolumeDelete: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "delete", - abstract: "Remove one or more volumes", + abstract: "Delete one or more volumes", aliases: ["rm"] ) - @Argument(help: "Volume name(s)") - var names: [String] + @Flag(name: .shortAndLong, help: "Delete all volumes") + var all = false + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Volume names") + var names: [String] = [] + + public init() {} public func run() async throws { - for name in names { - try await ClientVolume.delete(name: name) - print(name) + let uniqueVolumeNames = Set(names) + let volumes: [Volume] + + if all { + volumes = try await ClientVolume.list() + } else { + volumes = try await ClientVolume.list() + .filter { v in + uniqueVolumeNames.contains(v.id) + } + + // If one of the volumes requested isn't present lets throw. We don't need to do + // this for --all as --all should be perfectly usable with no volumes to remove, + // otherwise it'd be quite clunky. + if volumes.count != uniqueVolumeNames.count { + let missing = uniqueVolumeNames.filter { id in + !volumes.contains { v in + v.id == id + } + } + throw ContainerizationError( + .notFound, + message: "failed to delete one or more volumes: \(missing)" + ) + } + } + + var failed = [String]() + try await withThrowingTaskGroup(of: Volume?.self) { group in + for volume in volumes { + group.addTask { + do { + // delete atomically disables the IP allocator, then deletes + // the allocator disable fails if any IPs are still in use + try await ClientVolume.delete(name: volume.id) + print(volume.id) + return nil + } catch { + log.error("failed to delete volume \(volume.id): \(error)") + return volume + } + } + } + + for try await volume in group { + guard let volume else { + continue + } + failed.append(volume.id) + } + } + + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "delete failed for one or more volumes: \(failed)") } } } diff --git a/Sources/ContainerCommands/Volume/VolumeInspect.swift b/Sources/ContainerCommands/Volume/VolumeInspect.swift index f6405e24..c5afbfa4 100644 --- a/Sources/ContainerCommands/Volume/VolumeInspect.swift +++ b/Sources/ContainerCommands/Volume/VolumeInspect.swift @@ -20,15 +20,19 @@ import Foundation extension Application.VolumeCommand { public struct VolumeInspect: AsyncParsableCommand { - public init() {} public static let configuration = CommandConfiguration( commandName: "inspect", - abstract: "Display detailed information on one or more volumes" + abstract: "Display information about one or more volumes" ) - @Argument(help: "Volume name(s)") + @OptionGroup + var global: Flags.Global + + @Argument(help: "Volume names") var names: [String] + public init() {} + public func run() async throws { var volumes: [Volume] = [] diff --git a/Sources/ContainerCommands/Volume/VolumeList.swift b/Sources/ContainerCommands/Volume/VolumeList.swift index 840df9ba..9c7bdd04 100644 --- a/Sources/ContainerCommands/Volume/VolumeList.swift +++ b/Sources/ContainerCommands/Volume/VolumeList.swift @@ -21,19 +21,23 @@ import Foundation extension Application.VolumeCommand { public struct VolumeList: AsyncParsableCommand { - public init() {} public 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 + @Flag(name: .shortAndLong, help: "Only output the volume name") + var quiet: Bool = false + + @OptionGroup + var global: Flags.Global + + public init() {} + public func run() async throws { let volumes = try await ClientVolume.list() try printVolumes(volumes: volumes, format: format)