Use ManagedResource for volumes in CLI commands (#1607)

## Type of Change
- [x] New feature  

## Motivation and Context
Related to https://github.com/apple/container/issues/1404.

This PR adds the initial work to have volume resources conform to
ManagedResource, in alignment with other resources such as networks (see
[here](https://github.com/apple/container/pull/1421)). Further work is
necessary to move the use of `VolumeResource` down to the APIServer (in
the VolumesService) and in the volume client. Volumes do not currently
have any plugin or runtime state, so that information is not included in
the `VolumeResource`, but could be added later if needed.

## Testing
- [x] Tested locally

Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com>
This commit is contained in:
Kathryn Baldauf
2026-05-28 09:33:21 -07:00
committed by GitHub
parent d43b58ec73
commit 046884df37
11 changed files with 155 additions and 59 deletions
@@ -39,7 +39,7 @@ public struct JSONOptions: Sendable {
///
/// All list commands route their output through these methods. JSON rendering
/// is separate from table/quiet rendering because the JSON model often differs
/// from the display model (e.g., `Volume` for JSON vs `PrintableVolume` for table).
/// from the display model.
public enum Output {
/// Renders an `Encodable` value as a JSON string.
public static func renderJSON<T: Encodable>(_ value: T, options: JSONOptions = .compact) throws -> String {
@@ -83,7 +83,7 @@ public enum Output {
/// Renders list output in the requested format.
///
/// The JSON and display models may be the same type (e.g., `PrintableContainer`)
/// or different types (e.g., `Volume` for JSON and `PrintableVolume` for table).
/// or different types.
public static func render<J: Encodable, D: ListDisplayable>(
json: J, display: [D], format: ListFormat, quiet: Bool
) throws {
@@ -53,7 +53,7 @@ extension Application.VolumeCommand {
public func run() async throws {
let uniqueVolumeNames = Set<String>(names)
let volumes: [Volume]
let volumes: [VolumeConfiguration]
if all {
volumes = try await ClientVolume.list()
@@ -81,7 +81,7 @@ extension Application.VolumeCommand {
var failed = [String]()
let _log = log
try await withThrowingTaskGroup(of: Volume?.self) { group in
try await withThrowingTaskGroup(of: VolumeConfiguration?.self) { group in
for volume in volumes {
group.addTask {
do {
@@ -38,6 +38,7 @@ extension Application.VolumeCommand {
public func run() async throws {
let uniqueNames = Set(names)
let volumes = try await ClientVolume.list().filter { uniqueNames.contains($0.id) }
let volumeResources = volumes.map { VolumeResource(config: $0) }
if volumes.count != uniqueNames.count {
let found = Set(volumes.map { $0.id })
@@ -52,7 +53,7 @@ extension Application.VolumeCommand {
outputFormatting: [.prettyPrinted, .sortedKeys],
dateEncodingStrategy: .iso8601
)
try Output.emit(Output.renderJSON(volumes, options: options))
try Output.emit(Output.renderJSON(volumeResources, options: options))
}
}
}
@@ -41,46 +41,15 @@ extension Application.VolumeCommand {
public func run() async throws {
let volumes = try await ClientVolume.list()
let volumeResources = volumes.map { VolumeResource(config: $0) }
if format == .json {
let options = JSONOptions(dateEncodingStrategy: .iso8601)
try Output.emit(Output.renderJSON(volumes, options: options))
try Output.emit(Output.renderJSON(volumeResources, options: options))
return
}
// Sort by creation time (newest first) for table display only,
// matching the original behavior where JSON and quiet emit unsorted.
let items = quiet ? volumes : volumes.sorted { $0.createdAt > $1.createdAt }
Output.emit(Output.renderList(items.map { PrintableVolume($0) }, quiet: quiet))
try Output.render(json: volumeResources, display: volumeResources, format: format, quiet: quiet)
}
}
}
private struct PrintableVolume: ListDisplayable {
let name: String
let volumeType: String
let driver: String
let optionsString: String
init(_ volume: Volume) {
self.name = volume.name
self.volumeType = volume.isAnonymous ? "anonymous" : "named"
self.driver = volume.driver
self.optionsString =
volume.options.isEmpty
? ""
: volume.options.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: ",")
}
static var tableHeader: [String] {
["NAME", "TYPE", "DRIVER", "OPTIONS"]
}
var tableRow: [String] {
[name, volumeType, driver, optionsString]
}
var quietValue: String {
name
}
}
@@ -0,0 +1,36 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerResource
extension VolumeResource: ListDisplayable {
public static var tableHeader: [String] {
["NAME", "TYPE", "DRIVER", "OPTIONS"]
}
public var tableRow: [String] {
[
name,
isAnonymous ? "anonymous" : "named",
config.driver,
config.options.isEmpty ? "" : config.options.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: ","),
]
}
public var quietValue: String {
name
}
}
@@ -17,7 +17,7 @@
import Foundation
/// A named or anonymous volume that can be mounted in containers.
public struct Volume: Sendable, Codable, Equatable, Identifiable {
public struct VolumeConfiguration: Sendable, Codable, Equatable, Identifiable {
// id of the volume.
public var id: String { name }
// Name of the volume.
@@ -58,7 +58,7 @@ public struct Volume: Sendable, Codable, Equatable, Identifiable {
}
}
extension Volume {
extension VolumeConfiguration {
/// Reserved label key for marking anonymous volumes
public static let anonymousLabel = "com.apple.container.resource.anonymous"
@@ -0,0 +1,90 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Foundation
/// A volume resource, representing a configured volume.
public struct VolumeResource: ManagedResource {
/// The volume's configuration its persistent, intrinsic properties.
public let config: VolumeConfiguration
// MARK: ManagedResource
/// The unique identifier for this volume. Identical to ``VolumeConfiguration/name``.
public var id: String { config.name }
/// The user-assigned name for this volume. For volumes, name and ID are the same.
public var name: String { config.name }
/// The time at which this volume was created.
public var creationDate: Date { config.createdAt }
/// Key-value labels for this volume. If the underlying
/// ``VolumeConfiguration/labels`` dictionary contains values that fail
/// ``ResourceLabels`` validation, this returns an empty label set.
public var labels: ResourceLabels {
(try? ResourceLabels(config.labels)) ?? ResourceLabels()
}
/// Whether this is an anonymous volume (detected via the configuration's labels).
public var isAnonymous: Bool { config.isAnonymous }
// MARK: Initialization
/// Creates a volume resource.
///
/// - Parameters:
/// - config: The volume's intrinsic configuration.
public init(config: VolumeConfiguration) {
self.config = config
}
}
extension VolumeResource {
public static let volumeNamePattern = "^[A-Za-z0-9][A-Za-z0-9_.-]*$"
/// Returns `true` if `name` is a syntactically valid volume identifier.
public static func nameValid(_ name: String) -> Bool {
guard name.count <= 255 else { return false }
do {
let regex = try Regex(volumeNamePattern)
return (try? regex.wholeMatch(in: name)) != nil
} catch {
return false
}
}
}
// MARK: - Codable
extension VolumeResource {
enum CodingKeys: String, CodingKey {
case id
case config
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(config, forKey: .config)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.config = try container.decode(VolumeConfiguration.self, forKey: .config)
}
}
@@ -27,7 +27,7 @@ public struct ClientVolume {
driver: String = "local",
driverOpts: [String: String] = [:],
labels: [String: String] = [:]
) async throws -> Volume {
) async throws -> VolumeConfiguration {
let client = XPCClient(service: serviceIdentifier)
let message = XPCMessage(route: .volumeCreate)
message.set(key: .volumeName, value: name)
@@ -45,7 +45,7 @@ public struct ClientVolume {
throw VolumeError.storageError("invalid response from server")
}
return try JSONDecoder().decode(Volume.self, from: responseData)
return try JSONDecoder().decode(VolumeConfiguration.self, from: responseData)
}
public static func delete(name: String) async throws {
@@ -56,7 +56,7 @@ public struct ClientVolume {
_ = try await client.send(message)
}
public static func list() async throws -> [Volume] {
public static func list() async throws -> [VolumeConfiguration] {
let client = XPCClient(service: serviceIdentifier)
let message = XPCMessage(route: .volumeList)
let reply = try await client.send(message)
@@ -65,10 +65,10 @@ public struct ClientVolume {
return []
}
return try JSONDecoder().decode([Volume].self, from: responseData)
return try JSONDecoder().decode([VolumeConfiguration].self, from: responseData)
}
public static func inspect(_ name: String) async throws -> Volume {
public static func inspect(_ name: String) async throws -> VolumeConfiguration {
let client = XPCClient(service: serviceIdentifier)
let message = XPCMessage(route: .volumeInspect)
message.set(key: .volumeName, value: name)
@@ -79,7 +79,7 @@ public struct ClientVolume {
throw VolumeError.volumeNotFound(name)
}
return try JSONDecoder().decode(Volume.self, from: responseData)
return try JSONDecoder().decode(VolumeConfiguration.self, from: responseData)
}
public static func volumeDiskUsage(name: String) async throws -> UInt64 {
@@ -367,10 +367,10 @@ public struct Utility {
/// Gets an existing volume or creates it if it doesn't exist.
/// Shows a warning for named volumes when auto-creating.
private static func getOrCreateVolume(parsed: ParsedVolume, log: Logger) async throws -> Volume {
let labels = parsed.isAnonymous ? [Volume.anonymousLabel: ""] : [:]
private static func getOrCreateVolume(parsed: ParsedVolume, log: Logger) async throws -> VolumeConfiguration {
let labels = parsed.isAnonymous ? [VolumeConfiguration.anonymousLabel: ""] : [:]
let volume: Volume
let volume: VolumeConfiguration
var wasCreated = false
do {
volume = try await ClientVolume.create(
@@ -28,7 +28,7 @@ import SystemPackage
public actor VolumesService {
private let resourceRoot: FilePath
private let store: ContainerPersistence.FilesystemEntityStore<Volume>
private let store: ContainerPersistence.FilesystemEntityStore<VolumeConfiguration>
private let log: Logger
private let lock = AsyncLock()
private let containersService: ContainersService
@@ -40,7 +40,7 @@ public actor VolumesService {
public init(resourceRoot: FilePath, containersService: ContainersService, log: Logger) throws {
try FileManager.default.createDirectory(atPath: resourceRoot.string, withIntermediateDirectories: true)
self.resourceRoot = resourceRoot
self.store = try FilesystemEntityStore<Volume>(path: resourceRoot, type: "volumes", log: log)
self.store = try FilesystemEntityStore<VolumeConfiguration>(path: resourceRoot, type: "volumes", log: log)
self.containersService = containersService
self.log = log
}
@@ -50,7 +50,7 @@ public actor VolumesService {
driver: String = "local",
driverOpts: [String: String] = [:],
labels: [String: String] = [:]
) async throws -> Volume {
) async throws -> VolumeConfiguration {
log.debug(
"VolumesService: enter",
metadata: [
@@ -96,7 +96,7 @@ public actor VolumesService {
}
}
public func list() async throws -> [Volume] {
public func list() async throws -> [VolumeConfiguration] {
log.debug(
"VolumesService: enter",
metadata: [
@@ -115,7 +115,7 @@ public actor VolumesService {
return try await store.list()
}
public func inspect(_ name: String) async throws -> Volume {
public func inspect(_ name: String) async throws -> VolumeConfiguration {
log.debug(
"VolumesService: enter",
metadata: [
@@ -325,7 +325,7 @@ public actor VolumesService {
driver: String,
driverOpts: [String: String],
labels: [String: String]
) async throws -> Volume {
) async throws -> VolumeConfiguration {
guard VolumeStorage.isValidVolumeName(name) else {
throw VolumeError.invalidVolumeName("invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)")
}
@@ -350,7 +350,7 @@ public actor VolumesService {
try createVolumeImage(for: name, sizeInBytes: sizeInBytes, journal: journalConfig)
let volume = Volume(
let volume = VolumeConfiguration(
name: name,
driver: driver,
format: "ext4",
@@ -400,7 +400,7 @@ public actor VolumesService {
log.info("deleted volume", metadata: ["name": "\(name)"])
}
private func _inspect(_ name: String) async throws -> Volume {
private func _inspect(_ name: String) async throws -> VolumeConfiguration {
guard VolumeStorage.isValidVolumeName(name) else {
throw VolumeError.invalidVolumeName("invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)")
}
@@ -321,7 +321,7 @@ class TestCLIAnonymousVolumes: CLITest {
let data = output.data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let volumes = try decoder.decode([Volume].self, from: data)
let volumes = try decoder.decode([VolumeResource].self, from: data)
let anonVolume = volumes.first { $0.name == volumeName }
#expect(anonVolume != nil, "should find anonymous volume in list")