From 03fb803ab21dcd470738b6a0272e9fa382169fb9 Mon Sep 17 00:00:00 2001 From: Bortniak Volodymyr Date: Wed, 24 Dec 2025 13:48:26 +0100 Subject: [PATCH 1/2] shows snapshotSize if available with fallback to compressedSize; fix tests --- Package.swift | 9 + .../ContainerClient/Core/ClientImage.swift | 11 ++ .../ContainerCommands/Image/ImageList.swift | 4 +- Sources/Helpers/Images/ImagesHelper.swift | 1 + .../Client/ImageServiceXPCRoutes.swift | 1 + .../Server/ImageService.swift | 9 + .../Server/ImagesServiceHarness.swift | 24 +++ .../SnapshotStoreTests.swift | 184 ++++++++++++++++++ 8 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 Tests/ContainerImagesServiceTests/SnapshotStoreTests.swift diff --git a/Package.swift b/Package.swift index 1d518e73..c8285de6 100644 --- a/Package.swift +++ b/Package.swift @@ -226,6 +226,15 @@ let package = Package( ], path: "Sources/Services/ContainerImagesService/Client" ), + .testTarget( + name: "ContainerImagesServiceTests", + dependencies: [ + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + "ContainerImagesService", + "ContainerPersistence", + ] + ), .target( name: "ContainerBuild", dependencies: [ diff --git a/Sources/ContainerClient/Core/ClientImage.swift b/Sources/ContainerClient/Core/ClientImage.swift index 7cac6700..caa0a3f3 100644 --- a/Sources/ContainerClient/Core/ClientImage.swift +++ b/Sources/ContainerClient/Core/ClientImage.swift @@ -417,6 +417,17 @@ extension ClientImage { return fs } + public func getSnapshotSize(platform: Platform) async throws -> UInt64 { + 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 { diff --git a/Sources/ContainerCommands/Image/ImageList.swift b/Sources/ContainerCommands/Image/ImageList.swift index 4999d5f7..cbcaed0a 100644 --- a/Sources/ContainerCommands/Image/ImageList.swift +++ b/Sources/ContainerCommands/Image/ImageList.swift @@ -80,7 +80,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 }) + 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) diff --git a/Sources/Helpers/Images/ImagesHelper.swift b/Sources/Helpers/Images/ImagesHelper.swift index d7383f4b..20874c7a 100644 --- a/Sources/Helpers/Images/ImagesHelper.swift +++ b/Sources/Helpers/Images/ImagesHelper.swift @@ -102,6 +102,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 { diff --git a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift index f383e0d1..d6bde439 100644 --- a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift +++ b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift @@ -40,6 +40,7 @@ public enum ImagesServiceXPCRoute: String { case imageUnpack case snapshotDelete case snapshotGet + case snapshotSize } extension XPCMessage { diff --git a/Sources/Services/ContainerImagesService/Server/ImageService.swift b/Sources/Services/ContainerImagesService/Server/ImageService.swift index f84e90cf..9947ae19 100644 --- a/Sources/Services/ContainerImagesService/Server/ImageService.swift +++ b/Sources/Services/ContainerImagesService/Server/ImageService.swift @@ -200,6 +200,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 diff --git a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift index 966191b8..8f9a8853 100644 --- a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift +++ b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift @@ -275,4 +275,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 + } } diff --git a/Tests/ContainerImagesServiceTests/SnapshotStoreTests.swift b/Tests/ContainerImagesServiceTests/SnapshotStoreTests.swift new file mode 100644 index 00000000..09026450 --- /dev/null +++ b/Tests/ContainerImagesServiceTests/SnapshotStoreTests.swift @@ -0,0 +1,184 @@ +//===----------------------------------------------------------------------===// +// 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 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)") + } +} From 91e510da051d97c3fd6acfc99a3d1c21a8ea5d60 Mon Sep 17 00:00:00 2001 From: Bortniak Volodymyr Date: Wed, 24 Dec 2025 13:49:03 +0100 Subject: [PATCH 2/2] make fmt --- Tests/ContainerImagesServiceTests/SnapshotStoreTests.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/ContainerImagesServiceTests/SnapshotStoreTests.swift b/Tests/ContainerImagesServiceTests/SnapshotStoreTests.swift index 09026450..7eca304c 100644 --- a/Tests/ContainerImagesServiceTests/SnapshotStoreTests.swift +++ b/Tests/ContainerImagesServiceTests/SnapshotStoreTests.swift @@ -51,7 +51,7 @@ struct SnapshotStoreTests { ) let size = try await snapshotStore.getSnapshotSize(descriptor: descriptor) - + #expect(size == 0, "Expected size to be 0 when snapshot doesn't exist, got \(size)") } @@ -175,7 +175,6 @@ struct SnapshotStoreTests { .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