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
47 changes: 47 additions & 0 deletions Sources/ContainerResource/Common/FileManager+AllocatedSize.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//===----------------------------------------------------------------------===//
// 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).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comments should include more details on what is and isn't counted. As far as I can tell:

  • Symlinks to files outside directory will include those files' sizes in the total.
  • Symlinks to files inside directory will include those files' sizes in the total, resulting in inaccuracy due to multiple counting.
  • The same issues apply for hard links.
  • Symlinks to directories aren't followed (which is good).

Do we need to address the double counting issues, or will the directories we enumerate never have these sorts of things?

///
/// Caveats: hidden files are skipped, symlinks to directories are not followed, but
/// symlinks-to-files and hard links each contribute their target's full allocation
/// so shared inodes are counted multiple times.
public func allocatedSize(of directory: URL) -> UInt64 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We shouldn't write any more code that uses URL for fs paths.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also noting that this would be a good candidate for extracting to a FilePathOps utility type in ContainerizationOS: apple/containerization#744.

guard
let enumerator = self.enumerator(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I added FileDescriptorOps.enumerate() to ContainerizationOS for TOCTOU-safe traversal of directory trees; it might make sense to use here.

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
}
}
9 changes: 8 additions & 1 deletion Sources/Plugins/CoreImages/ImagesHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,13 @@ extension ImagesHelper {
let imageStore = try ImageStore(path: rootURL, contentStore: contentStore)
let unpackStrategy = SnapshotStore.defaultUnpackStrategy(initImage: containerSystemConfig.vminit.image)
let snapshotStore = try SnapshotStore(path: rootURL, unpackStrategy: unpackStrategy, log: log)
let service = try ImagesService(contentStore: contentStore, imageStore: imageStore, snapshotStore: snapshotStore, log: log)
let service = try ImagesService(
contentStore: contentStore,
contentStoreTotalSize: { try await contentStore.totalAllocatedSize() },
imageStore: imageStore,
snapshotStore: snapshotStore,
log: log
)
let harness = ImagesServiceHarness(service: service, log: log)

routes[ImagesServiceXPCRoute.imagePull.rawValue] = XPCServer.route(harness.pull)
Expand All @@ -124,6 +130,7 @@ extension ImagesHelper {
routes[ImagesServiceXPCRoute.contentClean.rawValue] = XPCServer.route(harness.clean)
routes[ImagesServiceXPCRoute.contentGet.rawValue] = XPCServer.route(harness.get)
routes[ImagesServiceXPCRoute.contentDelete.rawValue] = XPCServer.route(harness.delete)
routes[ImagesServiceXPCRoute.contentSize.rawValue] = XPCServer.route(harness.totalSize)
routes[ImagesServiceXPCRoute.contentIngestStart.rawValue] = XPCServer.route(harness.newIngestSession)
routes[ImagesServiceXPCRoute.contentIngestCancel.rawValue] = XPCServer.route(harness.cancelIngestSession)
routes[ImagesServiceXPCRoute.contentIngestComplete.rawValue] = XPCServer.route(harness.completeIngestSession)
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 @@ -900,7 +867,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
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public enum ImagesServiceXPCRoute: String {
case contentGet
case contentDelete
case contentClean
case contentSize
case contentIngestStart
case contentIngestComplete
case contentIngestCancel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ public struct RemoteContentStoreClient: ContentStore {
request.set(key: .ingestSessionId, value: id)
try await client.send(request)
}

public func totalAllocatedSize() async throws -> UInt64 {
let client = Self.newClient()
let request = XPCMessage(route: .contentSize)
let response = try await client.send(request)
return response.uint64(key: .imageSize)
}
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,12 @@ public struct ContentServiceHarness: Sendable {
reply.set(key: .digests, value: d)
return reply
}

@Sendable
public func totalSize(_ message: XPCMessage) async throws -> XPCMessage {
let size = await self.service.totalAllocatedSize()
let reply = message.reply()
reply.set(key: .imageSize, value: size)
return reply
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
//===----------------------------------------------------------------------===//

import ContainerImagesServiceClient
import ContainerResource
import Containerization
import ContainerizationOCI
import Foundation
Expand Down Expand Up @@ -156,4 +157,9 @@ public actor ContentStoreService {

return try await self.contentStore.cancelIngestSession(id)
}

/// Total bytes allocated on disk for the content store.
public func totalAllocatedSize() -> UInt64 {
FileManager.default.allocatedSize(of: self.root)
}
}
73 changes: 36 additions & 37 deletions Sources/Services/ContainerImagesService/Server/ImagesService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,19 @@ import TerminalProgress
public actor ImagesService {
private let log: Logger
private let contentStore: ContentStore
private let contentStoreTotalSize: @Sendable () async throws -> UInt64
private let imageStore: ImageStore
private let snapshotStore: SnapshotStore

public init(contentStore: ContentStore, imageStore: ImageStore, snapshotStore: SnapshotStore, log: Logger) throws {
public init(
contentStore: ContentStore,
contentStoreTotalSize: @Sendable @escaping () async throws -> UInt64,
imageStore: ImageStore,
snapshotStore: SnapshotStore,
log: Logger
) throws {
self.contentStore = contentStore
self.contentStoreTotalSize = contentStoreTotalSize
self.imageStore = imageStore
self.snapshotStore = snapshotStore
self.log = log
Expand Down Expand Up @@ -270,8 +278,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 +297,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 }

for digest in try await image.referencedDigests() where activeContentSizes[digest] == nil {
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 contentDiskSize = try await self.contentStoreTotalSize()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What prevents us from invoking self.contentStore.totalAllocatedSize() here?

let totalOnDisk = contentDiskSize + 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
Loading