Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ integration: init-block
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIBuildBase || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIVolumes || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIKernelSet || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIAnonymousVolumes || exit_code=1 ; \
echo Ensuring apiserver stopped after the CLI integration tests ; \
scripts/ensure-container-stopped.sh ; \
exit $${exit_code} ; \
Expand Down
21 changes: 18 additions & 3 deletions Sources/ContainerClient/Core/Volume.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import Foundation

/// A named volume that can be mounted in containers.
/// A named or anonymous volume that can be mounted in containers.
public struct Volume: Sendable, Codable, Equatable, Identifiable {
// id of the volume.
public var id: String { name }
Expand Down Expand Up @@ -45,7 +45,7 @@ public struct Volume: Sendable, Codable, Equatable, Identifiable {
createdAt: Date = Date(),
labels: [String: String] = [:],
options: [String: String] = [:],
sizeInBytes: UInt64? = nil,
sizeInBytes: UInt64? = nil
) {
self.name = name
self.driver = driver
Expand All @@ -58,6 +58,16 @@ public struct Volume: Sendable, Codable, Equatable, Identifiable {
}
}

extension Volume {
/// Reserved label key for marking anonymous volumes
public static let anonymousLabel = "com.apple.container.resource.anonymous"

/// Whether this is an anonymous volume (detected via label)
public var isAnonymous: Bool {
labels[Self.anonymousLabel] != nil
}
}

/// Error types for volume operations.
public enum VolumeError: Error, LocalizedError {
case volumeNotFound(String)
Expand Down Expand Up @@ -95,9 +105,14 @@ public struct VolumeStorage {

do {
let regex = try Regex(volumeNamePattern)
return name.contains(regex)
return (try? regex.wholeMatch(in: name)) != nil
} catch {
return false
}
}

/// Generates an anonymous volume name with UUID format
public static func generateAnonymousVolumeName() -> String {
UUID().uuidString.lowercased()
}
}
32 changes: 26 additions & 6 deletions Sources/ContainerClient/Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ public struct ParsedVolume {
public let name: String
public let destination: String
public let options: [String]
public let isAnonymous: Bool

public init(name: String, destination: String, options: [String] = []) {
public init(name: String, destination: String, options: [String] = [], isAnonymous: Bool = false) {
self.name = name
self.destination = destination
self.options = options
self.isAnonymous = isAnonymous
}
}

Expand Down Expand Up @@ -368,8 +370,7 @@ public struct Parser {
case "tmpfs":
fs.type = Filesystem.FSType.tmpfs
case "volume":
// Volume type will be set later in source parsing when we create the actual volume filesystem
break
isVolume = true
default:
throw ContainerizationError(.invalidArgument, message: "unsupported mount type \(val)")
}
Expand Down Expand Up @@ -416,7 +417,6 @@ public struct Parser {
}

// This is a named volume
isVolume = true
volumeName = val
fs.source = val
case "tmpfs":
Expand All @@ -434,11 +434,19 @@ public struct Parser {
guard isVolume else {
return .filesystem(fs)
}

// If it's a volume type but no source was provided, create an anonymous volume
let isAnonymous = volumeName.isEmpty
if isAnonymous {
volumeName = VolumeStorage.generateAnonymousVolumeName()
}

return .volume(
ParsedVolume(
name: volumeName,
destination: fs.destination,
options: fs.options
options: fs.options,
isAnonymous: isAnonymous
))
}

Expand All @@ -459,7 +467,19 @@ public struct Parser {
let parts = vol.split(separator: ":")
switch parts.count {
case 1:
throw ContainerizationError(.invalidArgument, message: "anonymous volumes are not supported")
// Anonymous volume: -v /path
// Generate a random name for the anonymous volume
let anonymousName = VolumeStorage.generateAnonymousVolumeName()
let destination = String(parts[0])
let options: [String] = []

return .volume(
ParsedVolume(
name: anonymousName,
destination: destination,
options: options,
isAnonymous: true
))
case 2, 3:
let src = String(parts[0])
let dst = String(parts[1])
Expand Down
48 changes: 36 additions & 12 deletions Sources/ContainerClient/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,19 +160,43 @@ public struct Utility {
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")
let volume: Volume

if parsed.isAnonymous {
// Anonymous volume so try to create it, inspect if already exists
do {
volume = try await ClientVolume.create(
name: parsed.name,
driver: "local",
driverOpts: [:],
labels: [Volume.anonymousLabel: ""]
)
} catch let error as VolumeError {
guard case .volumeAlreadyExists = error else {
throw error
}
// Volume already exists, just inspect it
volume = try await ClientVolume.inspect(parsed.name)
} catch let error as ContainerizationError {
// Handle XPC-wrapped volumeAlreadyExists error
guard error.message.contains("already exists") else {
throw error
}
volume = try await ClientVolume.inspect(parsed.name)
}
} else {
// Named volume so it must already exist
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)
}
}

Expand Down
11 changes: 9 additions & 2 deletions Sources/ContainerCommands/Volume/VolumeList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ extension Application.VolumeCommand {
}

private func createHeader() -> [[String]] {
[["NAME", "DRIVER", "OPTIONS"]]
[["NAME", "TYPE", "DRIVER", "OPTIONS"]]
}

func printVolumes(volumes: [Volume], format: Application.ListFormat) throws {
Expand All @@ -61,8 +61,13 @@ extension Application.VolumeCommand {
return
}

// Sort volumes by creation time (newest first)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the future, we should probably sort everything this way.

let sortedVolumes = volumes.sorted { v1, v2 in
v1.createdAt > v2.createdAt
}

var rows = createHeader()
for volume in volumes {
for volume in sortedVolumes {
rows.append(volume.asRow)
}

Expand All @@ -74,9 +79,11 @@ extension Application.VolumeCommand {

extension Volume {
var asRow: [String] {
let volumeType = self.isAnonymous ? "anonymous" : "named"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems fine. If we have this, anon- on the name really does seem superfluous.

let optionsString = options.isEmpty ? "" : options.map { "\($0.key)=\($0.value)" }.joined(separator: ",")
return [
self.name,
volumeType,
self.driver,
optionsString,
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ public actor VolumesService {

try await store.create(volume)

log.info("Created volume", metadata: ["name": "\(name)", "driver": "\(driver)"])
log.info("Created volume", metadata: ["name": "\(name)", "driver": "\(driver)", "isAnonymous": "\(volume.isAnonymous)"])
return volume
}

Expand Down
Loading