Skip to content
Open
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
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@ INTEGRATION_TEST_SUITES := \
TestCLIKernelSet \
TestCLIAnonymousVolumes \
TestCLINotFound \
TestCLINoParallelCases
TestCLINoParallelCases \
TestCLISystemDF

empty :=
space := $(empty) $(empty)
Expand Down
43 changes: 43 additions & 0 deletions Sources/ContainerResource/Common/FileManager+AllocatedSize.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//===----------------------------------------------------------------------===//
// 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

extension FileManager {
/// Total bytes allocated on disk for all files in a directory (recursive).
public func allocatedSize(of directory: URL) -> UInt64 {
guard
let enumerator = self.enumerator(
at: directory,
includingPropertiesForKeys: [.totalFileAllocatedSizeKey],
options: [.skipsHiddenFiles]
)
else {
return 0
}

var size: UInt64 = 0
for case let fileURL as URL in enumerator {
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]),
let fileSize = resourceValues.totalFileAllocatedSize
else {
continue
}
size += UInt64(fileSize)
}
return size
}
}
3 changes: 2 additions & 1 deletion Sources/Plugins/CoreImages/ImagesHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,11 @@ extension ImagesHelper {

private func initializeImagesService(root: URL, containerSystemConfig: ContainerSystemConfig, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws {
let contentStore = RemoteContentStoreClient()
let contentBlobsPath = root.appendingPathComponent("content/blobs/sha256")
let imageStore = try ImageStore(path: root, contentStore: contentStore)
let unpackStrategy = SnapshotStore.defaultUnpackStrategy(initImage: containerSystemConfig.vminit.image)
let snapshotStore = try SnapshotStore(path: root, unpackStrategy: unpackStrategy, log: log)
let service = try ImagesService(contentStore: contentStore, imageStore: imageStore, snapshotStore: snapshotStore, log: log)
let service = try ImagesService(contentStore: contentStore, contentBlobsPath: contentBlobsPath, imageStore: imageStore, snapshotStore: snapshotStore, log: log)
let harness = ImagesServiceHarness(service: service, log: log)

routes[ImagesServiceXPCRoute.imagePull.rawValue] = XPCServer.route(harness.pull)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ public actor ContainersService {

for (id, state) in await self.containers {
let bundlePath = self.containerRoot.appendingPathComponent(id)
let containerSize = Self.calculateDirectorySize(at: bundlePath.path)
let containerSize = FileManager.default.allocatedSize(of: bundlePath)
totalSize += containerSize

if state.snapshot.status == .running {
Expand All @@ -243,39 +243,6 @@ public actor ContainersService {
}
}

/// 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, initImage: String? = nil, runtimeData: Data? = nil) async throws {
log.debug(
Expand Down Expand Up @@ -871,7 +838,7 @@ public actor ContainersService {

let containerPath = self.containerRoot.appendingPathComponent(id).path

return Self.calculateDirectorySize(at: containerPath)
return FileManager.default.allocatedSize(of: URL(fileURLWithPath: containerPath))
}

public func exportRootfs(id: String, archive: URL) async throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ public actor VolumesService {
}

let volumePath = self.volumePath(for: name)
return self.calculateDirectorySize(at: volumePath)
return FileManager.default.allocatedSize(of: URL(fileURLWithPath: volumePath))
}

/// Calculate disk usage for volumes
Expand Down Expand Up @@ -201,7 +201,7 @@ public actor VolumesService {
// Calculate sizes
for volume in allVolumes {
let volumePath = self.volumePath(for: volume.name)
let volumeSize = self.calculateDirectorySize(at: volumePath)
let volumeSize = FileManager.default.allocatedSize(of: URL(fileURLWithPath: volumePath))
totalSize += volumeSize

if !inUseSet.contains(volume.name) {
Expand All @@ -214,33 +214,6 @@ public actor VolumesService {
}
}

private 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
}

private func parseSize(_ sizeString: String) throws -> UInt64 {
let measurement = try Measurement.parse(parsing: sizeString)
let bytes = measurement.converted(to: .bytes).value
Expand Down
67 changes: 30 additions & 37 deletions Sources/Services/ContainerImagesService/Server/ImagesService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ import TerminalProgress
public actor ImagesService {
private let log: Logger
private let contentStore: ContentStore
private let contentBlobsPath: URL
private let imageStore: ImageStore
private let snapshotStore: SnapshotStore

public init(contentStore: ContentStore, imageStore: ImageStore, snapshotStore: SnapshotStore, log: Logger) throws {
public init(contentStore: ContentStore, contentBlobsPath: URL, imageStore: ImageStore, snapshotStore: SnapshotStore, log: Logger) throws {
self.contentStore = contentStore
self.contentBlobsPath = contentBlobsPath
self.imageStore = imageStore
self.snapshotStore = snapshotStore
self.log = log
Expand Down Expand Up @@ -270,8 +272,7 @@ public actor ImagesService {

/// 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<String>) async throws -> (Int, Int, UInt64, UInt64) {
public func calculateDiskUsage(activeReferences: Set<String>) async throws -> (totalCount: Int, activeCount: Int, totalSize: UInt64, reclaimableSize: UInt64) {
self.log.debug(
"ImagesService: enter",
metadata: [
Expand All @@ -290,49 +291,41 @@ public actor ImagesService {
}

let images = try await self._list()
var totalSize: UInt64 = 0
var reclaimableSize: UInt64 = 0
var activeCount = 0
var activeContentSizes: [String: UInt64] = [:]
var activeSnapshotSizes: [String: UInt64] = [:]
var processedDigests = Set<String>()

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
guard activeReferences.contains(image.reference) else { continue }
activeCount += 1
let imageDigest = image.digest.trimmingDigestPrefix
guard processedDigests.insert(imageDigest).inserted else { continue }

var seen = Set<String>()
for digest in try await image.referencedDigests() where seen.insert(digest).inserted {
guard let content: Content = try await self.contentStore.get(digest: digest) else { continue }
activeContentSizes[digest] = try self.contentDiskSize(content)
}
for (digest, size) in try await self.snapshotStore.getSnapshotSizes(for: image) {
activeSnapshotSizes[digest] = size
}
}

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
}
let snapshotDiskSize = await self.snapshotStore.totalAllocatedSize()
let totalOnDisk = FileManager.default.allocatedSize(of: self.contentBlobsPath) + snapshotDiskSize
let activeSize = activeContentSizes.values.reduce(0, +) + activeSnapshotSizes.values.reduce(0, +)
let reclaimable = totalOnDisk > activeSize ? totalOnDisk - activeSize : 0

guard descriptor.platform != nil else { continue }
return (images.count, activeCount, totalOnDisk, reclaimable)
}

// Get snapshot size for this platform
if let snapshotSize = try? await self.snapshotStore.getSnapshotSize(descriptor: descriptor) {
totalSize += snapshotSize
}
private func contentDiskSize(_ content: Content) throws -> UInt64 {
let values = try? content.path.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
if let allocatedSize = values?.totalFileAllocatedSize {
return UInt64(allocatedSize)
}

return totalSize
return try content.size()
}
}

Expand Down
40 changes: 15 additions & 25 deletions Sources/Services/ContainerImagesService/Server/SnapshotStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ public actor SnapshotStore {
guard self.fm.fileExists(atPath: unpackedPath.absolutePath()) else {
continue
}
deletedBytes += (try? self.fm.directorySize(dir: unpackedPath)) ?? 0
deletedBytes += self.fm.allocatedSize(of: unpackedPath)
try self.fm.removeItem(at: unpackedPath)
}
return deletedBytes
Expand Down Expand Up @@ -213,38 +213,28 @@ public actor SnapshotStore {
}

/// Get the disk size for a specific snapshot descriptor
public func getSnapshotSize(descriptor: Descriptor) throws -> UInt64 {
public func getSnapshotSize(descriptor: Descriptor) -> UInt64 {
let snapshotPath = self.snapshotDir(descriptor)
guard self.fm.fileExists(atPath: snapshotPath.path) else {
return 0
}
return try self.fm.directorySize(dir: snapshotPath)
return self.fm.allocatedSize(of: snapshotPath)
}
}

extension FileManager {
fileprivate func directorySize(dir: URL) throws -> UInt64 {
var size: UInt64 = 0
let resourceKeys: [URLResourceKey] = [.totalFileAllocatedSizeKey]

guard
let enumerator = self.enumerator(
at: dir,
includingPropertiesForKeys: resourceKeys,
options: [.skipsHiddenFiles]
)
else {
return 0
/// Returns (trimmed digest, size) pairs for every unpackable snapshot owned by the image.
public func getSnapshotSizes(for image: Containerization.Image) async throws -> [(digest: String, size: UInt64)] {
var results: [(digest: String, size: UInt64)] = []
for descriptor in try await image.unpackableDescriptors() {
let size = self.getSnapshotSize(descriptor: descriptor)
guard size > 0 else { continue }
results.append((descriptor.digest.trimmingDigestPrefix, size))
}
return results
}

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
/// Total allocated bytes across all snapshot storage (including orphans).
public func totalAllocatedSize() -> UInt64 {
self.fm.allocatedSize(of: self.path)
}
}

Expand Down
Loading