diff --git a/Sources/ContainerClient/Core/ClientDiskUsage.swift b/Sources/ContainerClient/Core/ClientDiskUsage.swift new file mode 100644 index 00000000..ff92c15e --- /dev/null +++ b/Sources/ContainerClient/Core/ClientDiskUsage.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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 ContainerXPC +import ContainerizationError +import Foundation + +/// Client API for disk usage operations +public struct ClientDiskUsage { + static let serviceIdentifier = "com.apple.container.apiserver" + + /// Get disk usage statistics for all resource types + public static func get() async throws -> DiskUsageStats { + let client = XPCClient(service: serviceIdentifier) + let message = XPCMessage(route: .systemDiskUsage) + let reply = try await client.send(message) + + guard let responseData = reply.dataNoCopy(key: .diskUsageStats) else { + throw ContainerizationError( + .internalError, + message: "Invalid response from server: missing disk usage data" + ) + } + + return try JSONDecoder().decode(DiskUsageStats.self, from: responseData) + } +} diff --git a/Sources/ContainerClient/Core/ClientImage.swift b/Sources/ContainerClient/Core/ClientImage.swift index 235dbe7d..356c6aff 100644 --- a/Sources/ContainerClient/Core/ClientImage.swift +++ b/Sources/ContainerClient/Core/ClientImage.swift @@ -289,10 +289,30 @@ extension ClientImage { let request = newRequest(.imagePrune) let response = try await client.send(request) let digests = try response.digests() - let size = response.uint64(key: .size) + let size = response.uint64(key: .imageSize) return (digests, size) } + /// Calculate disk usage for images + /// - Parameter activeReferences: Set of image references currently in use by containers + /// - Returns: Tuple of (total count, active count, total size, reclaimable size) + public static func calculateDiskUsage(activeReferences: Set) async throws -> (totalCount: Int, activeCount: Int, totalSize: UInt64, reclaimableSize: UInt64) { + let client = newXPCClient() + let request = newRequest(.imageDiskUsage) + + // Encode active references + let activeRefsData = try JSONEncoder().encode(activeReferences) + request.set(key: .activeImageReferences, value: activeRefsData) + + let response = try await client.send(request) + let total = Int(response.int64(key: .totalCount)) + let active = Int(response.int64(key: .activeCount)) + let size = response.uint64(key: .imageSize) + let reclaimable = response.uint64(key: .reclaimableSize) + + return (totalCount: total, activeCount: active, totalSize: size, reclaimableSize: reclaimable) + } + public static func fetch(reference: String, platform: Platform? = nil, scheme: RequestScheme = .auto, progressUpdate: ProgressUpdateHandler? = nil) async throws -> ClientImage { do { diff --git a/Sources/ContainerClient/Core/ClientVolume.swift b/Sources/ContainerClient/Core/ClientVolume.swift index c16fce19..5483ef4c 100644 --- a/Sources/ContainerClient/Core/ClientVolume.swift +++ b/Sources/ContainerClient/Core/ClientVolume.swift @@ -91,7 +91,7 @@ public struct ClientVolume { } let volumeNames = try JSONDecoder().decode([String].self, from: responseData) - let size = reply.uint64(key: .size) + let size = reply.uint64(key: .volumeSize) return (volumeNames, size) } diff --git a/Sources/ContainerClient/Core/DiskUsage.swift b/Sources/ContainerClient/Core/DiskUsage.swift new file mode 100644 index 00000000..e187390c --- /dev/null +++ b/Sources/ContainerClient/Core/DiskUsage.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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 + +/// Disk usage statistics for all resource types +public struct DiskUsageStats: Sendable, Codable { + /// Disk usage for images + public var images: ResourceUsage + + /// Disk usage for containers + public var containers: ResourceUsage + + /// Disk usage for volumes + public var volumes: ResourceUsage + + public init(images: ResourceUsage, containers: ResourceUsage, volumes: ResourceUsage) { + self.images = images + self.containers = containers + self.volumes = volumes + } +} + +/// Disk usage statistics for a specific resource type +public struct ResourceUsage: Sendable, Codable { + /// Total number of resources + public var total: Int + + /// Number of active/running resources + public var active: Int + + /// Total size in bytes + public var sizeInBytes: UInt64 + + /// Reclaimable size in bytes (from unused/inactive resources) + public var reclaimable: UInt64 + + public init(total: Int, active: Int, sizeInBytes: UInt64, reclaimable: UInt64) { + self.total = total + self.active = active + self.sizeInBytes = sizeInBytes + self.reclaimable = reclaimable + } +} diff --git a/Sources/ContainerClient/Core/XPC+.swift b/Sources/ContainerClient/Core/XPC+.swift index ba00060b..dade31ca 100644 --- a/Sources/ContainerClient/Core/XPC+.swift +++ b/Sources/ContainerClient/Core/XPC+.swift @@ -118,6 +118,10 @@ public enum XPCKeys: String { /// Container statistics case statistics + case volumeSize + + /// Disk usage + case diskUsageStats } public enum XPCRoute: String { @@ -153,6 +157,8 @@ public enum XPCRoute: String { case volumeInspect case volumePrune + case systemDiskUsage + case ping case installKernel diff --git a/Sources/ContainerCommands/System/SystemCommand.swift b/Sources/ContainerCommands/System/SystemCommand.swift index f1d33995..807e4d5c 100644 --- a/Sources/ContainerCommands/System/SystemCommand.swift +++ b/Sources/ContainerCommands/System/SystemCommand.swift @@ -23,6 +23,7 @@ extension Application { commandName: "system", abstract: "Manage system components", subcommands: [ + SystemDF.self, SystemDNS.self, SystemKernel.self, SystemLogs.self, diff --git a/Sources/ContainerCommands/System/SystemDF.swift b/Sources/ContainerCommands/System/SystemDF.swift new file mode 100644 index 00000000..75301df1 --- /dev/null +++ b/Sources/ContainerCommands/System/SystemDF.swift @@ -0,0 +1,115 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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 ArgumentParser +import ContainerClient +import ContainerizationError +import Foundation + +extension Application { + public struct SystemDF: AsyncParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "df", + abstract: "Show disk usage for images, containers, and volumes" + ) + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @OptionGroup + var global: Flags.Global + + public init() {} + + public func run() async throws { + let stats = try await ClientDiskUsage.get() + + if format == .json { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(stats) + guard let jsonString = String(data: data, encoding: .utf8) else { + throw ContainerizationError( + .internalError, + message: "Failed to encode JSON output" + ) + } + print(jsonString) + return + } + + printTable(stats: stats) + } + + private func printTable(stats: DiskUsageStats) { + var rows: [[String]] = [] + + // Header row + rows.append(["TYPE", "TOTAL", "ACTIVE", "SIZE", "RECLAIMABLE"]) + + // Images row + rows.append([ + "Images", + "\(stats.images.total)", + "\(stats.images.active)", + formatSize(stats.images.sizeInBytes), + formatReclaimable(stats.images.reclaimable, total: stats.images.sizeInBytes), + ]) + + // Containers row + rows.append([ + "Containers", + "\(stats.containers.total)", + "\(stats.containers.active)", + formatSize(stats.containers.sizeInBytes), + formatReclaimable(stats.containers.reclaimable, total: stats.containers.sizeInBytes), + ]) + + // Volumes row + rows.append([ + "Local Volumes", + "\(stats.volumes.total)", + "\(stats.volumes.active)", + formatSize(stats.volumes.sizeInBytes), + formatReclaimable(stats.volumes.reclaimable, total: stats.volumes.sizeInBytes), + ]) + + let tableFormatter = TableOutput(rows: rows) + print(tableFormatter.format()) + } + + private func formatSize(_ bytes: UInt64) -> String { + if bytes == 0 { + return "0 B" + } + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) + } + + private func formatReclaimable(_ reclaimable: UInt64, total: UInt64) -> String { + let sizeStr = formatSize(reclaimable) + + if total == 0 { + return "\(sizeStr) (0%)" + } + + // Cap at 100% in case reclaimable > total (shouldn't happen but be defensive) + let percentage = min(100, Int(round(Double(reclaimable) / Double(total) * 100.0))) + return "\(sizeStr) (\(percentage)%)" + } + } +} diff --git a/Sources/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index e035cb4f..a048d655 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -67,7 +67,13 @@ extension APIServer { ) initializeHealthCheckService(log: log, routes: &routes) try initializeKernelService(log: log, routes: &routes) - try initializeVolumeService(containersService: containersService, log: log, routes: &routes) + let volumesService = try initializeVolumeService(containersService: containersService, log: log, routes: &routes) + try initializeDiskUsageService( + containersService: containersService, + volumesService: volumesService, + log: log, + routes: &routes + ) let server = XPCServer( identifier: "com.apple.container.apiserver", @@ -254,7 +260,7 @@ extension APIServer { containersService: ContainersService, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler] - ) throws { + ) throws -> VolumesService { log.info("initializing volume service") let resourceRoot = appRoot.appendingPathComponent("volumes") @@ -266,6 +272,26 @@ extension APIServer { routes[XPCRoute.volumeList] = harness.list routes[XPCRoute.volumeInspect] = harness.inspect routes[XPCRoute.volumePrune] = harness.prune + + return service + } + + private func initializeDiskUsageService( + containersService: ContainersService, + volumesService: VolumesService, + log: Logger, + routes: inout [XPCRoute: XPCServer.RouteHandler] + ) throws { + log.info("initializing disk usage service") + + let service = DiskUsageService( + containersService: containersService, + volumesService: volumesService, + log: log + ) + let harness = DiskUsageHarness(service: service, log: log) + + routes[XPCRoute.systemDiskUsage] = harness.get } } } diff --git a/Sources/Helpers/Images/ImagesHelper.swift b/Sources/Helpers/Images/ImagesHelper.swift index 8b8bf359..7fc167b4 100644 --- a/Sources/Helpers/Images/ImagesHelper.swift +++ b/Sources/Helpers/Images/ImagesHelper.swift @@ -99,6 +99,7 @@ extension ImagesHelper { routes[ImagesServiceXPCRoute.imageLoad.rawValue] = harness.load routes[ImagesServiceXPCRoute.imageUnpack.rawValue] = harness.unpack routes[ImagesServiceXPCRoute.imagePrune.rawValue] = harness.prune + routes[ImagesServiceXPCRoute.imageDiskUsage.rawValue] = harness.calculateDiskUsage routes[ImagesServiceXPCRoute.snapshotDelete.rawValue] = harness.deleteSnapshot routes[ImagesServiceXPCRoute.snapshotGet.rawValue] = harness.getSnapshot } diff --git a/Sources/Services/ContainerAPIService/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Containers/ContainersService.swift index 28b46e91..d12d5072 100644 --- a/Sources/Services/ContainerAPIService/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Containers/ContainersService.swift @@ -126,6 +126,76 @@ public actor ContainersService { } } + /// Calculate disk usage for containers + /// - Returns: Tuple of (total count, active count, total size, reclaimable size) + public func calculateDiskUsage() async -> (Int, Int, UInt64, UInt64) { + await lock.withLock { _ in + var totalSize: UInt64 = 0 + var reclaimableSize: UInt64 = 0 + var activeCount = 0 + + for (id, state) in await self.containers { + let bundlePath = self.containerRoot.appendingPathComponent(id) + let containerSize = Self.calculateDirectorySize(at: bundlePath.path) + totalSize += containerSize + + if state.snapshot.status == .running { + activeCount += 1 + } else { + // Stopped containers are reclaimable + reclaimableSize += containerSize + } + } + + return (await self.containers.count, activeCount, totalSize, reclaimableSize) + } + } + + /// Get set of image references used by containers (for disk usage calculation) + /// - Returns: Set of image references currently in use + public func getActiveImageReferences() async -> Set { + await lock.withLock { _ in + var imageRefs = Set() + for (_, state) in await self.containers { + imageRefs.insert(state.snapshot.configuration.image.reference) + } + return imageRefs + } + } + + /// Calculate directory size using APFS-aware resource keys + /// - Parameter path: Path to directory + /// - Returns: Total allocated size in bytes + private static nonisolated func calculateDirectorySize(at path: String) -> UInt64 { + let url = URL(fileURLWithPath: path) + let fileManager = FileManager.default + + guard + let enumerator = fileManager.enumerator( + at: url, + includingPropertiesForKeys: [.totalFileAllocatedSizeKey], + options: [.skipsHiddenFiles] + ) + else { + return 0 + } + + var totalSize: UInt64 = 0 + for case let fileURL as URL in enumerator { + guard + let resourceValues = try? fileURL.resourceValues( + forKeys: [.totalFileAllocatedSizeKey] + ), + let fileSize = resourceValues.totalFileAllocatedSize + else { + continue + } + totalSize += UInt64(fileSize) + } + + return totalSize + } + /// Create a new container from the provided id and configuration. public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions) async throws { self.log.debug("\(#function)") diff --git a/Sources/Services/ContainerAPIService/DiskUsage/DiskUsageHarness.swift b/Sources/Services/ContainerAPIService/DiskUsage/DiskUsageHarness.swift new file mode 100644 index 00000000..ddcf5508 --- /dev/null +++ b/Sources/Services/ContainerAPIService/DiskUsage/DiskUsageHarness.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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 ContainerClient +import ContainerXPC +import ContainerizationError +import Foundation +import Logging + +/// XPC harness for disk usage operations +public struct DiskUsageHarness: Sendable { + let log: Logger + let service: DiskUsageService + + public init(service: DiskUsageService, log: Logger) { + self.log = log + self.service = service + } + + @Sendable + public func get(_ message: XPCMessage) async throws -> XPCMessage { + do { + let stats = try await service.calculateDiskUsage() + let data = try JSONEncoder().encode(stats) + + let reply = message.reply() + reply.set(key: .diskUsageStats, value: data) + return reply + } catch { + log.error("failed to get disk usage", metadata: ["error": "\(error)"]) + throw ContainerizationError( + .internalError, + message: "failed to get disk usage", + cause: error + ) + } + } +} diff --git a/Sources/Services/ContainerAPIService/DiskUsage/DiskUsageService.swift b/Sources/Services/ContainerAPIService/DiskUsage/DiskUsageService.swift new file mode 100644 index 00000000..312fe95e --- /dev/null +++ b/Sources/Services/ContainerAPIService/DiskUsage/DiskUsageService.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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 ContainerClient +import Logging + +/// Service for calculating disk usage across all resource types +public actor DiskUsageService { + private let containersService: ContainersService + private let volumesService: VolumesService + private let log: Logger + + public init( + containersService: ContainersService, + volumesService: VolumesService, + log: Logger + ) { + self.containersService = containersService + self.volumesService = volumesService + self.log = log + } + + /// Calculate disk usage for all resource types + public func calculateDiskUsage() async throws -> DiskUsageStats { + log.debug("calculating disk usage for all resources") + + // Get active image references first (needed for image calculation) + let activeImageRefs = await containersService.getActiveImageReferences() + + // Query all services concurrently + async let imageStats = ClientImage.calculateDiskUsage(activeReferences: activeImageRefs) + async let containerStats = containersService.calculateDiskUsage() + async let volumeStats = volumesService.calculateDiskUsage() + + let (imageData, containerData, volumeData) = try await (imageStats, containerStats, volumeStats) + + let stats = DiskUsageStats( + images: ResourceUsage( + total: imageData.totalCount, + active: imageData.activeCount, + sizeInBytes: imageData.totalSize, + reclaimable: imageData.reclaimableSize + ), + containers: ResourceUsage( + total: containerData.0, + active: containerData.1, + sizeInBytes: containerData.2, + reclaimable: containerData.3 + ), + volumes: ResourceUsage( + total: volumeData.0, + active: volumeData.1, + sizeInBytes: volumeData.2, + reclaimable: volumeData.3 + ) + ) + + log.debug( + "disk usage calculation complete", + metadata: [ + "images_total": "\(imageData.totalCount)", + "containers_total": "\(containerData.0)", + "volumes_total": "\(volumeData.0)", + ]) + + return stats + } +} diff --git a/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift b/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift index 68a01a7a..e145b18a 100644 --- a/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift +++ b/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift @@ -100,7 +100,7 @@ public struct VolumesHarness: Sendable { let reply = message.reply() reply.set(key: .volumes, value: data) - reply.set(key: .size, value: size) + reply.set(key: .volumeSize, value: size) return reply } } diff --git a/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift b/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift index 3525c7d6..3c09784e 100644 --- a/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift +++ b/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift @@ -116,6 +116,44 @@ public actor VolumesService { } } + /// 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) { + 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 { 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 = self.calculateDirectorySize(at: volumePath) + totalSize += volumeSize + + if !inUseSet.contains(volume.name) { + reclaimableSize += volumeSize + } + } + + return (allVolumes.count, inUseSet.count, totalSize, reclaimableSize) + } + } + } + private nonisolated func calculateDirectorySize(at path: String) -> UInt64 { let url = URL(fileURLWithPath: path) let fileManager = FileManager.default diff --git a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift index f290fe20..c81c76dc 100644 --- a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift +++ b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift @@ -41,8 +41,14 @@ public enum ImagesServiceXPCKeys: String { case digests case directory case contentPath - case size + case imageSize case ingestSessionId + + /// Disk Usage + case activeImageReferences + case totalCount + case activeCount + case reclaimableSize } extension XPCMessage { @@ -62,6 +68,10 @@ extension XPCMessage { self.set(key: key.rawValue, value: value) } + public func set(key: ImagesServiceXPCKeys, value: Int64) { + self.set(key: key.rawValue, value: value) + } + public func string(key: ImagesServiceXPCKeys) -> String? { self.string(key: key.rawValue) } @@ -78,6 +88,10 @@ extension XPCMessage { self.uint64(key: key.rawValue) } + public func int64(key: ImagesServiceXPCKeys) -> Int64 { + self.int64(key: key.rawValue) + } + public func bool(key: ImagesServiceXPCKeys) -> Bool { self.bool(key: key.rawValue) } diff --git a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift index 0000a17d..8ae2ea03 100644 --- a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift +++ b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift @@ -28,6 +28,7 @@ public enum ImagesServiceXPCRoute: String { case imageSave case imageLoad case imagePrune + case imageDiskUsage case contentGet case contentDelete diff --git a/Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift b/Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift index 1aec3b0f..270ad114 100644 --- a/Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift +++ b/Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift @@ -77,7 +77,7 @@ public struct RemoteContentStoreClient: ContentStore { let decoder = JSONDecoder() let deleted = try decoder.decode([String].self, from: data) - let size = response.uint64(key: .size) + let size = response.uint64(key: .imageSize) return (deleted, size) } @@ -96,7 +96,7 @@ public struct RemoteContentStoreClient: ContentStore { let decoder = JSONDecoder() let deleted = try decoder.decode([String].self, from: data) - let size = response.uint64(key: .size) + let size = response.uint64(key: .imageSize) return (deleted, size) } diff --git a/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift b/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift index ee1a2b5c..544659a8 100644 --- a/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift +++ b/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift @@ -58,7 +58,7 @@ public struct ContentServiceHarness: Sendable { let d = try JSONEncoder().encode(deleted) let reply = message.reply() reply.set(key: .digests, value: d) - reply.set(key: .size, value: size) + reply.set(key: .imageSize, value: size) return reply } @@ -73,7 +73,7 @@ public struct ContentServiceHarness: Sendable { let d = try JSONEncoder().encode(deleted) let reply = message.reply() reply.set(key: .digests, value: d) - reply.set(key: .size, value: size) + reply.set(key: .imageSize, value: size) return reply } diff --git a/Sources/Services/ContainerImagesService/Server/ImageService.swift b/Sources/Services/ContainerImagesService/Server/ImageService.swift index 6ca43f8c..bbc04204 100644 --- a/Sources/Services/ContainerImagesService/Server/ImageService.swift +++ b/Sources/Services/ContainerImagesService/Server/ImageService.swift @@ -124,6 +124,56 @@ public actor ImagesService { let (deleted, freedContentBytes) = try await self.imageStore.prune() return (deleted, freedContentBytes + freedSnapshotBytes) } + + /// Calculate disk usage for images + /// - Parameter activeReferences: Set of image references currently in use by containers + /// - Returns: Tuple of (total count, active count, total size, reclaimable size) + public func calculateDiskUsage(activeReferences: Set) async throws -> (Int, Int, UInt64, UInt64) { + let images = try await self._list() + var totalSize: UInt64 = 0 + var reclaimableSize: UInt64 = 0 + var activeCount = 0 + + for image in images { + // Calculate size for all platform variants + let imageSize = try await self.calculateImageSize(image) + totalSize += imageSize + + // Check if image is referenced by any container + let isActive = activeReferences.contains(image.reference) + if isActive { + activeCount += 1 + } else { + reclaimableSize += imageSize + } + } + + return (images.count, activeCount, totalSize, reclaimableSize) + } + + /// Calculate total size for an image including all platform variants + private func calculateImageSize(_ image: Containerization.Image) async throws -> UInt64 { + var totalSize: UInt64 = 0 + let index = try await image.index() + + for descriptor in index.manifests { + // Skip attestation manifests + if let refType = descriptor.annotations?["vnd.docker.reference.type"], + refType == "attestation-manifest" + { + continue + } + + guard descriptor.platform != nil else { continue } + + // Get snapshot size for this platform + if let snapshotSize = try? await self.snapshotStore.getSnapshotSize(descriptor: descriptor) { + totalSize += snapshotSize + } + } + + return totalSize + } } // MARK: Image Snapshot Methods diff --git a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift index 3c6c681a..22db71cf 100644 --- a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift +++ b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift @@ -178,7 +178,28 @@ public struct ImagesServiceHarness: Sendable { let reply = message.reply() let data = try JSONEncoder().encode(deleted) reply.set(key: .digests, value: data) - reply.set(key: .size, value: size) + reply.set(key: .imageSize, value: size) + return reply + } + + @Sendable + public func calculateDiskUsage(_ message: XPCMessage) async throws -> XPCMessage { + // Decode active image references from the message + let activeRefsData = message.dataNoCopy(key: .activeImageReferences) + let activeRefs: Set + if let activeRefsData { + activeRefs = try JSONDecoder().decode(Set.self, from: activeRefsData) + } else { + activeRefs = Set() + } + + let (total, active, size, reclaimable) = try await service.calculateDiskUsage(activeReferences: activeRefs) + + let reply = message.reply() + reply.set(key: .totalCount, value: Int64(total)) + reply.set(key: .activeCount, value: Int64(active)) + reply.set(key: .imageSize, value: size) + reply.set(key: .reclaimableSize, value: reclaimable) return reply } } diff --git a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift index 507312f6..08fff5dc 100644 --- a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift +++ b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift @@ -209,21 +209,40 @@ public actor SnapshotStore { try self.fm.createDirectory(at: uniqueDirectoryURL, withIntermediateDirectories: true, attributes: nil) return uniqueDirectoryURL } + + /// Get the disk size for a specific snapshot descriptor + public func getSnapshotSize(descriptor: Descriptor) throws -> UInt64 { + let snapshotPath = self.snapshotDir(descriptor) + guard self.fm.fileExists(atPath: snapshotPath.path) else { + return 0 + } + return try self.fm.directorySize(dir: snapshotPath) + } } extension FileManager { fileprivate func directorySize(dir: URL) throws -> UInt64 { - var size = 0 - let resourceKeys: [URLResourceKey] = [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey] - let contents = try self.contentsOfDirectory( - at: dir, - includingPropertiesForKeys: resourceKeys) + var size: UInt64 = 0 + let resourceKeys: [URLResourceKey] = [.totalFileAllocatedSizeKey] - for p in contents { - let val = try p.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]) - size += val.totalFileAllocatedSize ?? val.fileAllocatedSize ?? 0 + guard + let enumerator = self.enumerator( + at: dir, + includingPropertiesForKeys: resourceKeys, + options: [.skipsHiddenFiles] + ) + else { + return 0 } - return UInt64(size) + + for case let fileURL as URL in enumerator { + if let resourceValues = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]), + let fileSize = resourceValues.totalFileAllocatedSize + { + size += UInt64(fileSize) + } + } + return size } } diff --git a/Tests/ContainerClientTests/DiskUsageTests.swift b/Tests/ContainerClientTests/DiskUsageTests.swift new file mode 100644 index 00000000..ca445c12 --- /dev/null +++ b/Tests/ContainerClientTests/DiskUsageTests.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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 +import Testing + +@testable import ContainerClient + +struct DiskUsageTests { + + @Test("DiskUsageStats JSON encoding and decoding") + func testJSONSerialization() throws { + let stats = DiskUsageStats( + images: ResourceUsage(total: 10, active: 5, sizeInBytes: 1024, reclaimable: 512), + containers: ResourceUsage(total: 3, active: 2, sizeInBytes: 2048, reclaimable: 1024), + volumes: ResourceUsage(total: 7, active: 4, sizeInBytes: 4096, reclaimable: 2048) + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(stats) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(DiskUsageStats.self, from: data) + + #expect(decoded.images.total == stats.images.total) + #expect(decoded.images.active == stats.images.active) + #expect(decoded.images.sizeInBytes == stats.images.sizeInBytes) + #expect(decoded.images.reclaimable == stats.images.reclaimable) + + #expect(decoded.containers.total == stats.containers.total) + #expect(decoded.containers.active == stats.containers.active) + #expect(decoded.containers.sizeInBytes == stats.containers.sizeInBytes) + #expect(decoded.containers.reclaimable == stats.containers.reclaimable) + + #expect(decoded.volumes.total == stats.volumes.total) + #expect(decoded.volumes.active == stats.volumes.active) + #expect(decoded.volumes.sizeInBytes == stats.volumes.sizeInBytes) + #expect(decoded.volumes.reclaimable == stats.volumes.reclaimable) + } + + @Test("ResourceUsage with zero values") + func testZeroValues() throws { + let emptyUsage = ResourceUsage(total: 0, active: 0, sizeInBytes: 0, reclaimable: 0) + + let encoder = JSONEncoder() + let data = try encoder.encode(emptyUsage) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(ResourceUsage.self, from: data) + + #expect(decoded.total == 0) + #expect(decoded.active == 0) + #expect(decoded.sizeInBytes == 0) + #expect(decoded.reclaimable == 0) + } + + @Test("ResourceUsage with large values") + func testLargeValues() throws { + let largeUsage = ResourceUsage( + total: 1000, + active: 500, + sizeInBytes: UInt64.max, + reclaimable: UInt64.max / 2 + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(largeUsage) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(ResourceUsage.self, from: data) + + #expect(decoded.total == 1000) + #expect(decoded.active == 500) + #expect(decoded.sizeInBytes == UInt64.max) + #expect(decoded.reclaimable == UInt64.max / 2) + } + + @Test("ResourceUsage percentage calculations") + func testPercentageCalculations() throws { + // 0% reclaimable + let noneReclaimable = ResourceUsage(total: 10, active: 10, sizeInBytes: 1000, reclaimable: 0) + #expect(Double(noneReclaimable.reclaimable) / Double(noneReclaimable.sizeInBytes) == 0.0) + + // 50% reclaimable + let halfReclaimable = ResourceUsage(total: 10, active: 5, sizeInBytes: 1000, reclaimable: 500) + #expect(Double(halfReclaimable.reclaimable) / Double(halfReclaimable.sizeInBytes) == 0.5) + + // 100% reclaimable + let allReclaimable = ResourceUsage(total: 10, active: 0, sizeInBytes: 1000, reclaimable: 1000) + #expect(Double(allReclaimable.reclaimable) / Double(allReclaimable.sizeInBytes) == 1.0) + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 9fe8ecea..566f97c2 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -921,6 +921,20 @@ container system logs [--follow] [--last ] [--debug] * `-f, --follow`: Follow log output * `--last `: Fetch logs starting from the specified time period (minus the current time); supported formats: m, h, d (default: 5m) +### `container system df` + +Shows disk usage for images, containers, and volumes. Displays total count, active count, size, and reclaimable space for each resource type. + +**Usage** + +```bash +container system df [--format ] [--debug] +``` + +**Options** + +* `--format `: Format of the output (values: json, table; default: table) + ### `container system dns create` Creates a local DNS domain for containers. Requires administrator privileges (use sudo).