Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
dfa0ceb
shows snapshotSize if available with fallback to compressedSize; fix …
Bortnyak Dec 24, 2025
50eccb4
make fmt
Bortnyak Dec 24, 2025
cabe573
Merge branch 'main' into fix-image-size
Bortnyak Jan 19, 2026
6239a16
Merge branch 'main' into fix-image-size
Bortnyak Jan 20, 2026
2c845e2
Merge branch 'main' into fix-image-size
Bortnyak Jan 21, 2026
6d648c4
Merge branch 'main' into fix-image-size
Bortnyak Jan 22, 2026
27532ad
Merge branch 'main' into fix-image-size
Bortnyak Jan 24, 2026
288507c
make fmt
Bortnyak Jan 24, 2026
39fcdc1
Merge branch 'apple:main' into fix-image-size
Bortnyak Jan 31, 2026
55fc6a1
Merge branch 'main' into fix-image-size
Bortnyak Feb 3, 2026
137f452
Merge branch 'main' into fix-image-size
jglogan Feb 5, 2026
a005a13
consolidates size retrieval into a single method that returns both oc…
Bortnyak Feb 7, 2026
58b80cd
Merge branch 'main' into fix-image-size
Bortnyak Feb 7, 2026
b4a79c8
make fmt
Bortnyak Feb 7, 2026
4eb8751
Merge branch 'main' into fix-image-size
Bortnyak Feb 10, 2026
311163a
Merge branch 'main' into fix-image-size
Bortnyak Feb 11, 2026
74fc5c9
Merge branch 'main' into fix-image-size
Bortnyak Feb 12, 2026
b2072d3
Merge branch 'main' into fix-image-size
Bortnyak Feb 15, 2026
27978bb
Merge branch 'main' into fix-image-size
Bortnyak Apr 23, 2026
aaab2e0
merge master; update func docs
Bortnyak Apr 23, 2026
2e8266a
Merge branch 'main' into fix-image-size
Bortnyak Apr 23, 2026
f2ec540
add a note about FULL size in the docs
Bortnyak Apr 23, 2026
e7a0ac3
Merge branch 'main' into fix-image-size
Bortnyak Apr 27, 2026
5fe3c23
Merge branch 'main' into fix-image-size
Bortnyak Apr 29, 2026
768848c
Merge branch 'main' into fix-image-size
Bortnyak Apr 29, 2026
cd97445
Merge branch 'main' into fix-image-size
Bortnyak May 1, 2026
11cb273
Merge branch 'main' into fix-image-size
Bortnyak May 1, 2026
99b502b
Merge branch 'main' into fix-image-size
Bortnyak May 4, 2026
81155fc
Merge branch 'main' into fix-image-size
Bortnyak May 5, 2026
3e6001f
Merge branch 'main' into fix-image-size
Bortnyak May 6, 2026
2245db2
Merge branch 'main' into fix-image-size
Bortnyak May 10, 2026
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
9 changes: 9 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,15 @@ let package = Package(
"ContainerXPC",
],
path: "Sources/Helpers/NetworkVmnet"
)
.testTarget(
name: "ContainerImagesServiceTests",
dependencies: [
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
"ContainerImagesService",
"ContainerPersistence",
]
),
.target(
name: "ContainerNetworkService",
Expand Down
4 changes: 3 additions & 1 deletion Sources/ContainerCommands/Image/ImageList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ extension Application {
}

let created = config.created ?? ""
let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size })
let compressedSize = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size })

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.

See the implementation in #862, if that moves into the image client and we merge that, you could use the same logic for the OCI image size, and then you could extend it so it instead returns a struct containing say, ociImageSize and snapshotSize.

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.

Please refer to #1098 now :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks to both of you. I'll wait until #1098 is merged and will use that logic

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.

@Bortnyak you should be good to go now!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hey @jglogan

I've added the change consolidating size retrieval into a single method that returns both ociImageSize and snapshotSize in an ImageSizeInfo struct, following your suggestion.
Check the PR, please.

let snapshotSize = (try? await image.getSnapshotSize(platform: platform)) ?? 0
let size = snapshotSize > 0 ? Int64(snapshotSize) : compressedSize
let formattedSize = formatter.string(fromByteCount: size)

let processedReferenceString = try ClientImage.denormalizeReference(image.reference)
Expand Down
1 change: 1 addition & 0 deletions Sources/Helpers/Images/ImagesHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ extension ImagesHelper {
routes[ImagesServiceXPCRoute.imageDiskUsage.rawValue] = harness.calculateDiskUsage
routes[ImagesServiceXPCRoute.snapshotDelete.rawValue] = harness.deleteSnapshot
routes[ImagesServiceXPCRoute.snapshotGet.rawValue] = harness.getSnapshot
routes[ImagesServiceXPCRoute.snapshotSize.rawValue] = harness.getSnapshotSize
}

private func initializeContentService(root: URL, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws {
Expand Down
11 changes: 11 additions & 0 deletions Sources/Services/ContainerAPIService/Client/ClientImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,17 @@ extension ClientImage {
return fs
}

public func getSnapshotSize(platform: Platform) async throws -> UInt64 {
Comment thread
Bortnyak marked this conversation as resolved.
let client = Self.newXPCClient()
let request = Self.newRequest(.snapshotSize)

try request.set(description: description)
try request.set(platform: platform)

let response = try await client.send(request)
return response.uint64(key: .imageSize)
}

@discardableResult
public func getCreateSnapshot(platform: Platform, progressUpdate: ProgressUpdateHandler? = nil) async throws -> Filesystem {
do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public enum ImagesServiceXPCRoute: String {
case imageUnpack
case snapshotDelete
case snapshotGet
case snapshotSize
}

extension XPCMessage {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,15 @@ extension ImagesService {
let img = try await self._get(description)
return try await self.snapshotStore.get(for: img, platform: platform)
}

public func getSnapshotSize(description: ImageDescription, platform: Platform) async throws -> UInt64 {
self.log.info("ImagesService: \(#function) - description: \(description), platform: \(String(describing: platform))")
let img = try await self._get(description)
let descriptor = try await img.descriptor(for: platform)
// getSnapshotSize returns 0 if snapshot doesn't exist, and can throw on filesystem errors
// We catch errors and return 0 to match the behavior when snapshot doesn't exist
return (try? await self.snapshotStore.getSnapshotSize(descriptor: descriptor)) ?? 0
}
}

// MARK: Static Methods
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,28 @@ extension ImagesServiceHarness {
reply.set(key: .filesystem, value: fsData)
return reply
}

@Sendable
public func getSnapshotSize(_ message: XPCMessage) async throws -> XPCMessage {
let descriptionData = message.dataNoCopy(key: .imageDescription)
guard let descriptionData else {
throw ContainerizationError(
.invalidArgument,
message: "missing image description"
)
}
let description = try JSONDecoder().decode(ImageDescription.self, from: descriptionData)
let platformData = message.dataNoCopy(key: .ociPlatform)
guard let platformData else {
throw ContainerizationError(
.invalidArgument,
message: "missing OCI platform"
)
}
let platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData)
let size = try await self.service.getSnapshotSize(description: description, platform: platform)
let reply = message.reply()
reply.set(key: .imageSize, value: size)
return reply
}
}
183 changes: 183 additions & 0 deletions Tests/ContainerImagesServiceTests/SnapshotStoreTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//===----------------------------------------------------------------------===//
// 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 ContainerImagesService
import ContainerizationOCI
import Foundation
import Testing

@testable import ContainerImagesService

struct SnapshotStoreTests {

@Test("getSnapshotSize should return 0 when snapshot directory does not exist")
func testGetSnapshotSizeWhenSnapshotDoesNotExist() async throws {
// prepare
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: tempDir)
}

let snapshotStore = try SnapshotStore(
path: tempDir,
unpackStrategy: SnapshotStore.defaultUnpackStrategy,
log: nil
)

// Create a descriptor for a non-existent snapshot
let fakeDigest = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
let platform = Platform(arch: "amd64", os: "linux")

// Create descriptor directly with the properties
let descriptor = Descriptor(
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: fakeDigest,
size: 1024,
platform: platform
)

let size = try await snapshotStore.getSnapshotSize(descriptor: descriptor)

#expect(size == 0, "Expected size to be 0 when snapshot doesn't exist, got \(size)")
}

@Test("getSnapshotSize should return correct size when snapshot exists")
func testGetSnapshotSizeWhenSnapshotExists() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: tempDir)
}

let snapshotStore = try SnapshotStore(
path: tempDir,
unpackStrategy: SnapshotStore.defaultUnpackStrategy,
log: nil
)

// Create a test descriptor
let fakeDigest = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
let descriptor = Descriptor(
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: fakeDigest,
size: 1024,
platform: Platform(arch: "amd64", os: "linux")
)

// Create snapshot directory
let snapshotDir =
tempDir
.appendingPathComponent("snapshots")
.appendingPathComponent(descriptor.digest.trimmingDigestPrefix)
try FileManager.default.createDirectory(at: snapshotDir, withIntermediateDirectories: true)

// Create test files: 1KB snapshot + 512 bytes info = 1536 bytes total
let testFile = snapshotDir.appendingPathComponent("snapshot")
try Data(repeating: 0, count: 1024).write(to: testFile)

let infoFile = snapshotDir.appendingPathComponent("snapshot-info")
try Data(repeating: 0, count: 512).write(to: infoFile)

let size = try await snapshotStore.getSnapshotSize(descriptor: descriptor)

#expect(size >= 1536, "Expected at least 1536 bytes (1024 + 512), got \(size) allocated")
}

@Test("getSnapshotSize should handle multiple files in snapshot directory")
func testGetSnapshotSizeWithMultipleFiles() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: tempDir)
}

let snapshotStore = try SnapshotStore(
path: tempDir,
unpackStrategy: SnapshotStore.defaultUnpackStrategy,
log: nil
)

let fakeDigest = "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"
let platform = Platform(arch: "arm64", os: "linux")
let descriptor = Descriptor(
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: fakeDigest,
size: 2048,
platform: platform
)

let snapshotDir =
tempDir
.appendingPathComponent("snapshots", isDirectory: true)
.appendingPathComponent(descriptor.digest.trimmingDigestPrefix, isDirectory: true)
try FileManager.default.createDirectory(at: snapshotDir, withIntermediateDirectories: true)

// Create multiple files
let file1 = snapshotDir.appendingPathComponent("snapshot", isDirectory: false)
try Data(repeating: 1, count: 2048).write(to: file1) // 2KB

let file2 = snapshotDir.appendingPathComponent("snapshot-info", isDirectory: false)
try Data(repeating: 2, count: 1024).write(to: file2) // 1KB

let subdir = snapshotDir.appendingPathComponent("subdir", isDirectory: true)
try FileManager.default.createDirectory(at: subdir, withIntermediateDirectories: true)
let file3 = subdir.appendingPathComponent("extra", isDirectory: false)
try Data(repeating: 3, count: 512).write(to: file3) // 512 bytes

let expectedMinSize: UInt64 = 2048 + 1024 + 512 // 3.5KB

let size = try await snapshotStore.getSnapshotSize(descriptor: descriptor)

#expect(size >= expectedMinSize, "Expected size to include all files (at least \(expectedMinSize) bytes), got \(size)")
}

@Test("getSnapshotSize should handle empty snapshot directory")
func testGetSnapshotSizeWithEmptyDirectory() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: tempDir)
}

let snapshotStore = try SnapshotStore(
path: tempDir,
unpackStrategy: SnapshotStore.defaultUnpackStrategy,
log: nil
)

let fakeDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
let platform = Platform(arch: "amd64", os: "linux")
let descriptor = Descriptor(
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: fakeDigest,
size: 0,
platform: platform
)

// Create empty snapshot directory
let snapshotDir =
tempDir
.appendingPathComponent("snapshots", isDirectory: true)
.appendingPathComponent(descriptor.digest.trimmingDigestPrefix, isDirectory: true)
try FileManager.default.createDirectory(at: snapshotDir, withIntermediateDirectories: true)

let size = try await snapshotStore.getSnapshotSize(descriptor: descriptor)

// Empty directory should return 0 or a small overhead value
#expect(size >= 0, "Expected size to be non-negative, got \(size)")
}
}
Loading