//===----------------------------------------------------------------------===// // 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 ContainerPersistence import ContainerResource import Containerization import ContainerizationEXT4 import ContainerizationError import ContainerizationExtras import ContainerizationOS import Foundation import Logging import Synchronization import SystemPackage public actor VolumesService { private let resourceRoot: FilePath 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: FilePath, containersService: ContainersService, log: Logger) async throws { try FileManager.default.createDirectory(atPath: resourceRoot.string, withIntermediateDirectories: true) self.resourceRoot = resourceRoot self.store = try FilesystemEntityStore(path: resourceRoot, type: "volumes", log: log) self.containersService = containersService self.log = log // Migrate configs stored with the old `createdAt` key to `creationDate`. // Deprecated: As of 1.0.0. Use ``creationDate`` instead of ``createdAt``. // Note: Will be removed in a later release. let configurations = try await store.list() for configuration in configurations { do { try await store.update(configuration) } catch { log.error( "failed to migrate volume configuration", metadata: [ "name": "\(configuration.name)", "error": "\(error)", ]) } } } public func create( name: String, driver: String = "local", driverOpts: [String: String] = [:], labels: [String: String] = [:] ) async throws -> VolumeConfiguration { log.debug( "VolumesService: enter", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) defer { log.debug( "VolumesService: exit", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) } return try await lock.withLock { _ in try await self._create(name: name, driver: driver, driverOpts: driverOpts, labels: labels) } } public func delete(name: String) async throws { log.debug( "VolumesService: enter", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) defer { log.debug( "VolumesService: exit", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) } try await lock.withLock { _ in try await self._delete(name: name) } } public func list() async throws -> [VolumeConfiguration] { log.debug( "VolumesService: enter", metadata: [ "func": "\(#function)" ] ) defer { log.debug( "VolumesService: exit", metadata: [ "func": "\(#function)" ] ) } return try await store.list() } public func inspect(_ name: String) async throws -> VolumeConfiguration { log.debug( "VolumesService: enter", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) defer { log.debug( "VolumesService: exit", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) } return try await lock.withLock { _ in try await self._inspect(name) } } /// Calculate disk usage for a single volume public func volumeDiskUsage(name: String) async throws -> UInt64 { log.debug( "VolumesService: enter", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) defer { log.debug( "VolumesService: exit", metadata: [ "func": "\(#function)", "name": "\(name)", ] ) } let volumePath = self.volumePath(for: name) return FileManager.default.allocatedSize(of: URL(fileURLWithPath: volumePath)) } /// Calculate disk usage for volumes /// - Returns: Tuple of (total count, active count, total size, reclaimable size) public func calculateDiskUsage() async throws -> (Int, Int, UInt64, UInt64) { log.debug( "VolumesService: enter", metadata: [ "func": "\(#function)" ] ) defer { log.debug( "VolumesService: exit", metadata: [ "func": "\(#function)" ] ) } return try await lock.withLock { _ in let allVolumes = try await self.store.list() // Atomically get active volumes with container list return try await self.containersService.withContainerList(logMetadata: ["acquirer": "\(#function)"]) { containers in var inUseSet = Set() // Find all mounted volumes for container in containers { for mount in container.configuration.mounts { if mount.isVolume, let volumeName = mount.volumeName { inUseSet.insert(volumeName) } } } var totalSize: UInt64 = 0 var reclaimableSize: UInt64 = 0 // Calculate sizes for volume in allVolumes { let volumePath = self.volumePath(for: volume.name) let volumeSize = FileManager.default.allocatedSize(of: URL(fileURLWithPath: volumePath)) totalSize += volumeSize if !inUseSet.contains(volume.name) { reclaimableSize += volumeSize } } return (allVolumes.count, inUseSet.count, totalSize, reclaimableSize) } } } 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 } // FIXME: These don't guarantee that name doesn't have component separators. private nonisolated func volumePath(for name: String) -> String { resourceRoot.appending(name).string } 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) } static func parseJournalConfig(_ value: String) throws -> EXT4.JournalConfig { let parts = value.split(separator: ":", maxSplits: 1) guard let modeSubstring = parts.first else { throw VolumeError.storageError("invalid journal configuration: expected 'mode' or 'mode:size'") } let modeString = String(modeSubstring) let mode: EXT4.JournalConfig.JournalMode switch modeString { case "writeback": mode = .writeback case "ordered": mode = .ordered case "journal": mode = .journal default: throw VolumeError.storageError("invalid journal mode '\(modeString)': must be writeback, ordered, or journal") } let size: UInt64? = try parts.count > 1 ? UInt64(Measurement.parse(parsing: String(parts[1])).converted(to: .bytes).value) : nil return EXT4.JournalConfig(size: size, defaultMode: mode) } private func createVolumeImage(for name: String, sizeInBytes: UInt64 = VolumeStorage.defaultVolumeSizeBytes, journal: EXT4.JournalConfig? = nil) throws { let blockPath = blockPath(for: name) // Use the containerization library's EXT4 formatter let formatter = try EXT4.Formatter( FilePath(blockPath), blockSize: 4096, minDiskSize: sizeInBytes, journal: journal ) 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 -> VolumeConfiguration { 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 } let journalConfig = try driverOpts["journal"].map { try Self.parseJournalConfig($0) } try createVolumeImage(for: name, sizeInBytes: sizeInBytes, journal: journalConfig) let volume = VolumeConfiguration( name: name, driver: driver, format: "ext4", source: blockPath(for: name), labels: labels, options: driverOpts, sizeInBytes: sizeInBytes ) try await store.create(volume) log.info( "created volume", metadata: [ "name": "\(name)", "driver": "\(driver)", "isAnonymous": "\(volume.isAnonymous)", ]) 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(logMetadata: ["acquirer": "\(#function)", "name": "\(name)"]) { 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 -> VolumeConfiguration { 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 } }