Files
container/Sources/ContainerClient/Parser.swift
T
Raj b8965cae43 Named Volumes (#362)
Closes #339.

This change adds named volume support to container, providing volume
management CLI commands - `create, delete, list and inspect`. The
implementation uses EXT4 block-based persistent storage with a new
`VolumesService` actor for thread-safe operations, integrates seamlessly
with the existing container mount system through a new `.volume`
filesystem type, and provides atomic volume operations with XPC-based
API communication. Volumes are stored in isolated directories with
configurable sizes (default 512GB) and include proper cleanup and
container usage tracking for safe deletion.

Example Usage:

```
# Create a volume
container volume create mydata

# Use volume in container
container run -v mydata:/data alpine

# List volumes
container volume list

# Inspect volume details
container volume inspect mydata

# Clean up
container volume rm mydata
```
2025-08-05 21:47:42 -07:00

639 lines
24 KiB
Swift

//===----------------------------------------------------------------------===//
// 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 Containerization
import ContainerizationError
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)
let mb = ram.converted(to: .mebibytes)
return Int64(mb.value)
}
public static func user(
user: String?, uid: UInt32?, gid: UInt32?,
defaultUser: ProcessConfiguration.User = .id(uid: 0, gid: 0)
) -> (user: ProcessConfiguration.User, groups: [UInt32]) {
var supplementalGroups: [UInt32] = []
let user: ProcessConfiguration.User = {
if let user = user, !user.isEmpty {
return .raw(userString: user)
}
if let uid, let gid {
return .id(uid: uid, gid: gid)
}
if uid == nil, gid == nil {
// Neither uid nor gid is set. return the default user
return defaultUser
}
// One of uid / gid is left unspecified. Set the user accordingly
if let uid {
return .raw(userString: "\(uid)")
}
if let gid {
supplementalGroups.append(gid)
}
return defaultUser
}()
return (user, supplementalGroups)
}
public static func platform(os: String, arch: String) -> ContainerizationOCI.Platform {
.init(arch: arch, os: os)
}
public static func resources(cpus: Int64?, memory: String?) throws -> ContainerConfiguration.Resources {
var resource = ContainerConfiguration.Resources()
if let cpus {
resource.cpus = Int(cpus)
}
if let memory {
resource.memoryInBytes = try Parser.memoryString(memory).mib()
}
return resource
}
public static func allEnv(imageEnvs: [String], envFiles: [String], envs: [String]) throws -> [String] {
var output: [String] = []
output.append(contentsOf: Parser.env(envList: imageEnvs))
for envFile in envFiles {
let content = try Parser.envFile(path: envFile)
output.append(contentsOf: content)
}
output.append(contentsOf: Parser.env(envList: envs))
return output
}
static func envFile(path: String) throws -> [String] {
guard FileManager.default.fileExists(atPath: path) else {
throw ContainerizationError(.notFound, message: "envfile at \(path) not found")
}
let data = try String(contentsOfFile: path, encoding: .utf8)
let lines = data.components(separatedBy: .newlines)
var envVars: [String] = []
for line in lines {
let line = line.trimmingCharacters(in: .whitespaces)
if line.isEmpty {
continue
}
if !line.hasPrefix("#") {
let keyVals = line.split(separator: "=")
if keyVals.count != 2 {
continue
}
let key = keyVals[0].trimmingCharacters(in: .whitespaces)
let val = keyVals[1].trimmingCharacters(in: .whitespaces)
if key.isEmpty || val.isEmpty {
continue
}
envVars.append("\(key)=\(val)")
}
}
return envVars
}
static func env(envList: [String]) -> [String] {
var envVar: [String] = []
for env in envList {
var env = env
let parts = env.split(separator: "=", maxSplits: 2)
if parts.count == 1 {
guard let val = ProcessInfo.processInfo.environment[env] else {
continue
}
env = "\(env)=\(val)"
}
envVar.append(env)
}
return envVar
}
static func labels(_ rawLabels: [String]) throws -> [String: String] {
var result: [String: String] = [:]
for label in rawLabels {
if label.isEmpty {
throw ContainerizationError(.invalidArgument, message: "label cannot be an empty string")
}
let parts = label.split(separator: "=", maxSplits: 2)
switch parts.count {
case 1:
result[String(parts[0])] = ""
case 2:
result[String(parts[0])] = String(parts[1])
default:
throw ContainerizationError(.invalidArgument, message: "invalid label format \(label)")
}
}
return result
}
static func process(
arguments: [String],
processFlags: Flags.Process,
managementFlags: Flags.Management,
config: ContainerizationOCI.ImageConfig?
) throws -> ProcessConfiguration {
let imageEnvVars = config?.env ?? []
let envvars = try Parser.allEnv(imageEnvs: imageEnvVars, envFiles: processFlags.envFile, envs: processFlags.env)
let workingDir: String = {
if let cwd = processFlags.cwd {
return cwd
}
if let cwd = config?.workingDir {
return cwd
}
return "/"
}()
let processArguments: [String]? = {
var result: [String] = []
var hasEntrypointOverride: Bool = false
// ensure the entrypoint is honored if it has been explicitly set by the user
if let entrypoint = managementFlags.entryPoint, !entrypoint.isEmpty {
result = [entrypoint]
hasEntrypointOverride = true
} else if let entrypoint = config?.entrypoint, !entrypoint.isEmpty {
result = entrypoint
}
if !arguments.isEmpty {
result.append(contentsOf: arguments)
} else {
if let cmd = config?.cmd, !hasEntrypointOverride, !cmd.isEmpty {
result.append(contentsOf: cmd)
}
}
return result.count > 0 ? result : nil
}()
guard let commandToRun = processArguments, commandToRun.count > 0 else {
throw ContainerizationError(.invalidArgument, message: "Command/Entrypoint not specified for container process")
}
let defaultUser: ProcessConfiguration.User = {
if let u = config?.user {
return .raw(userString: u)
}
return .id(uid: 0, gid: 0)
}()
let (user, additionalGroups) = Parser.user(
user: processFlags.user, uid: processFlags.uid,
gid: processFlags.gid, defaultUser: defaultUser)
return .init(
executable: commandToRun.first!,
arguments: [String](commandToRun.dropFirst()),
environment: envvars,
workingDirectory: workingDir,
terminal: processFlags.tty,
user: user,
supplementalGroups: additionalGroups
)
}
// MARK: Mounts
static let mountTypes = [
"virtiofs",
"bind",
"tmpfs",
]
static let defaultDirectives = ["type": "virtiofs"]
static func tmpfsMounts(_ mounts: [String]) throws -> [Filesystem] {
var result: [Filesystem] = []
let mounts = mounts.dedupe()
for tmpfs in mounts {
let fs = Filesystem.tmpfs(destination: tmpfs, options: [])
try validateMount(.filesystem(fs))
result.append(fs)
}
return result
}
static func mounts(_ rawMounts: [String]) throws -> [VolumeOrFilesystem] {
var mounts: [VolumeOrFilesystem] = []
let rawMounts = rawMounts.dedupe()
for mount in rawMounts {
let m = try Parser.mount(mount)
try validateMount(m)
mounts.append(m)
}
return mounts
}
static func mount(_ mount: String) throws -> VolumeOrFilesystem {
let parts = mount.split(separator: ",")
if parts.count == 0 {
throw ContainerizationError(.invalidArgument, message: "invalid mount format: \(mount)")
}
var directives = defaultDirectives
for part in parts {
let keyVal = part.split(separator: "=", maxSplits: 2)
var key = String(keyVal[0])
var skipValue = false
switch key {
case "type", "size", "mode":
break
case "source", "src":
key = "source"
case "destination", "dst", "target":
key = "destination"
case "readonly", "ro":
key = "ro"
skipValue = true
default:
throw ContainerizationError(.invalidArgument, message: "unknown directive \(key) when parsing mount \(mount)")
}
var value = ""
if !skipValue {
if keyVal.count != 2 {
throw ContainerizationError(.invalidArgument, message: "invalid directive format missing value \(part) in \(mount)")
}
value = String(keyVal[1])
}
directives[key] = value
}
var fs = Filesystem()
var isVolume = false
var volumeName = ""
for (key, val) in directives {
var val = val
let type = directives["type"] ?? ""
switch key {
case "type":
if val == "bind" {
val = "virtiofs"
}
switch val {
case "virtiofs":
fs.type = Filesystem.FSType.virtiofs
case "tmpfs":
fs.type = Filesystem.FSType.tmpfs
default:
throw ContainerizationError(.invalidArgument, message: "unsupported mount type \(val)")
}
case "ro":
fs.options.append("ro")
case "size":
if type != "tmpfs" {
throw ContainerizationError(.invalidArgument, message: "unsupported option size for \(type) mount")
}
var overflow: Bool
var memory = try Parser.memoryString(val)
(memory, overflow) = memory.multipliedReportingOverflow(by: 1024 * 1024)
if overflow {
throw ContainerizationError(.invalidArgument, message: "overflow encountered when parsing memory string: \(val)")
}
let s = "size=\(memory)"
fs.options.append(s)
case "mode":
if type != "tmpfs" {
throw ContainerizationError(.invalidArgument, message: "unsupported option mode for \(type) mount")
}
let s = "mode=\(val)"
fs.options.append(s)
case "source":
switch type {
case "virtiofs", "bind":
// 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:
throw ContainerizationError(.invalidArgument, message: "unknown mount type \(type)")
}
case "destination":
fs.destination = val
default:
throw ContainerizationError(.invalidArgument, message: "unknown mount directive \(key)")
}
}
guard isVolume else {
return .filesystem(fs)
}
return .volume(
ParsedVolume(
name: volumeName,
destination: fs.destination,
options: fs.options
))
}
static func volumes(_ rawVolumes: [String]) throws -> [VolumeOrFilesystem] {
var mounts: [VolumeOrFilesystem] = []
for volume in rawVolumes {
let m = try Parser.volume(volume)
try Parser.validateMount(m)
mounts.append(m)
}
return mounts
}
private static func volume(_ volume: String) throws -> VolumeOrFilesystem {
var vol = volume
vol.trimLeft(char: ":")
let parts = vol.split(separator: ":")
switch parts.count {
case 1:
throw ContainerizationError(.invalidArgument, message: "anonymous volumes are not supported")
case 2, 3:
let src = String(parts[0])
let dst = String(parts[1])
// 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: absolutePath).absolutePath(),
destination: dst,
options: []
)
if parts.count == 3 {
fs.options = parts[2].split(separator: ",").map { String($0) }
}
return .filesystem(fs)
default:
throw ContainerizationError(.invalidArgument, message: "invalid volume format \(volume)")
}
}
static func validMountType(_ type: String) -> Bool {
mountTypes.contains(type)
}
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 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
}
}
/// Parse --publish-port arguments into PublishPort objects
/// The format of each argument is `[host-ip:]host-port:container-port[/protocol]`
/// (e.g., "127.0.0.1:8080:80/tcp")
///
/// - Parameter rawPublishPorts: Array of port arguments
/// - Returns: Array of PublishPort objects
/// - Throws: ContainerizationError if parsing fails
static func publishPorts(_ rawPublishPorts: [String]) throws -> [PublishPort] {
var sockets: [PublishPort] = []
// Process each raw port string
for socket in rawPublishPorts {
let parsedSocket = try Parser.publishPort(socket)
sockets.append(parsedSocket)
}
return sockets
}
// Parse a single `--publish-port` argument into a `PublishPort`.
private static func publishPort(_ portText: String) throws -> PublishPort {
let protoSplit = portText.split(separator: "/")
let proto: PublishProtocol
let addressAndPortText: String
switch protoSplit.count {
case 1:
addressAndPortText = String(protoSplit[0])
proto = .tcp
case 2:
addressAndPortText = String(protoSplit[0])
let protoText = String(protoSplit[1])
guard let parsedProto = PublishProtocol(protoText) else {
throw ContainerizationError(.invalidArgument, message: "invalid publish protocol: \(protoText)")
}
proto = parsedProto
default:
throw ContainerizationError(.invalidArgument, message: "invalid publish value: \(portText)")
}
let hostAddress: String
let hostPortText: String
let containerPortText: String
let parts = addressAndPortText.split(separator: ":")
switch parts.count {
case 2:
hostAddress = "0.0.0.0"
hostPortText = String(parts[0])
containerPortText = String(parts[1])
case 3:
hostAddress = String(parts[0])
hostPortText = String(parts[1])
containerPortText = String(parts[2])
default:
throw ContainerizationError(.invalidArgument, message: "invalid publish address: \(portText)")
}
guard let hostPort = Int(hostPortText) else {
throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)")
}
guard let containerPort = Int(containerPortText) else {
throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)")
}
return PublishPort(
hostAddress: hostAddress,
hostPort: hostPort,
containerPort: containerPort,
proto: proto
)
}
/// Parse --publish-socket arguments into PublishSocket objects
/// The format of each argument is `host_path:container_path`
/// (e.g., "/tmp/docker.sock:/var/run/docker.sock")
///
/// - Parameter rawPublishSockets: Array of socket arguments
/// - Returns: Array of PublishSocket objects
/// - Throws: ContainerizationError if parsing fails or a path is invalid
static func publishSockets(_ rawPublishSockets: [String]) throws -> [PublishSocket] {
var sockets: [PublishSocket] = []
// Process each raw socket string
for socket in rawPublishSockets {
let parsedSocket = try Parser.publishSocket(socket)
sockets.append(parsedSocket)
}
return sockets
}
// Parse a single `--publish-socket`` argument into a `PublishSocket`.
private static func publishSocket(_ socketText: String) throws -> PublishSocket {
// Split by colon to two parts: [host_path, container_path]
let parts = socketText.split(separator: ":")
switch parts.count {
case 2:
// Extract host and container paths
let hostPath = String(parts[0])
let containerPath = String(parts[1])
// Validate paths are not empty
if hostPath.isEmpty {
throw ContainerizationError(
.invalidArgument, message: "host socket path cannot be empty")
}
if containerPath.isEmpty {
throw ContainerizationError(
.invalidArgument, message: "container socket path cannot be empty")
}
// Ensure container path must start with /
if !containerPath.hasPrefix("/") {
throw ContainerizationError(
.invalidArgument,
message: "container socket path must be absolute: \(containerPath)")
}
// Convert host path to absolute path for consistency
let hostURL = URL(fileURLWithPath: hostPath)
let absoluteHostPath = hostURL.absoluteURL.path
// Check if host socket already exists and might be in use
if FileManager.default.fileExists(atPath: absoluteHostPath) {
do {
let attrs = try FileManager.default.attributesOfItem(atPath: absoluteHostPath)
if let fileType = attrs[.type] as? FileAttributeType, fileType == .typeSocket {
throw ContainerizationError(
.invalidArgument,
message: "host socket \(absoluteHostPath) already exists and may be in use")
}
// If it exists but is not a socket, we can remove it and create socket
try FileManager.default.removeItem(atPath: absoluteHostPath)
} catch let error as ContainerizationError {
throw error
} catch {
// For other file system errors, continue with creation
}
}
// Create host directory if it doesn't exist
let hostDir = hostURL.deletingLastPathComponent()
if !FileManager.default.fileExists(atPath: hostDir.path) {
try FileManager.default.createDirectory(
at: hostDir, withIntermediateDirectories: true)
}
// Create and return PublishSocket object with validated paths
return PublishSocket(
containerPath: URL(fileURLWithPath: containerPath),
hostPath: URL(fileURLWithPath: absoluteHostPath),
permissions: nil
)
default:
throw ContainerizationError(
.invalidArgument,
message:
"invalid publish-socket format \(socketText). Expected: host_path:container_path")
}
}
}