Skip to content

Commit dfa0ceb

Browse files
committed
shows snapshotSize if available with fallback to compressedSize; fix tests
1 parent 08f48d9 commit dfa0ceb

8 files changed

Lines changed: 242 additions & 1 deletion

File tree

Package.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,15 @@ let package = Package(
251251
"ContainerXPC",
252252
],
253253
path: "Sources/Helpers/NetworkVmnet"
254+
)
255+
.testTarget(
256+
name: "ContainerImagesServiceTests",
257+
dependencies: [
258+
.product(name: "Containerization", package: "containerization"),
259+
.product(name: "ContainerizationOCI", package: "containerization"),
260+
"ContainerImagesService",
261+
"ContainerPersistence",
262+
]
254263
),
255264
.target(
256265
name: "ContainerNetworkService",

Sources/ContainerCommands/Image/ImageList.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ extension Application {
8080
}
8181

8282
let created = config.created ?? ""
83-
let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size })
83+
let compressedSize = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size })
84+
let snapshotSize = (try? await image.getSnapshotSize(platform: platform)) ?? 0
85+
let size = snapshotSize > 0 ? Int64(snapshotSize) : compressedSize
8486
let formattedSize = formatter.string(fromByteCount: size)
8587

8688
let processedReferenceString = try ClientImage.denormalizeReference(image.reference)

Sources/Helpers/Images/ImagesHelper.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ extension ImagesHelper {
102102
routes[ImagesServiceXPCRoute.imageDiskUsage.rawValue] = harness.calculateDiskUsage
103103
routes[ImagesServiceXPCRoute.snapshotDelete.rawValue] = harness.deleteSnapshot
104104
routes[ImagesServiceXPCRoute.snapshotGet.rawValue] = harness.getSnapshot
105+
routes[ImagesServiceXPCRoute.snapshotSize.rawValue] = harness.getSnapshotSize
105106
}
106107

107108
private func initializeContentService(root: URL, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws {

Sources/Services/ContainerAPIService/Client/ClientImage.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,17 @@ extension ClientImage {
420420
return fs
421421
}
422422

423+
public func getSnapshotSize(platform: Platform) async throws -> UInt64 {
424+
let client = Self.newXPCClient()
425+
let request = Self.newRequest(.snapshotSize)
426+
427+
try request.set(description: description)
428+
try request.set(platform: platform)
429+
430+
let response = try await client.send(request)
431+
return response.uint64(key: .imageSize)
432+
}
433+
423434
@discardableResult
424435
public func getCreateSnapshot(platform: Platform, progressUpdate: ProgressUpdateHandler? = nil) async throws -> Filesystem {
425436
do {

Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public enum ImagesServiceXPCRoute: String {
4040
case imageUnpack
4141
case snapshotDelete
4242
case snapshotGet
43+
case snapshotSize
4344
}
4445

4546
extension XPCMessage {

Sources/Services/ContainerImagesService/Server/ImageService.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,15 @@ extension ImagesService {
206206
let img = try await self._get(description)
207207
return try await self.snapshotStore.get(for: img, platform: platform)
208208
}
209+
210+
public func getSnapshotSize(description: ImageDescription, platform: Platform) async throws -> UInt64 {
211+
self.log.info("ImagesService: \(#function) - description: \(description), platform: \(String(describing: platform))")
212+
let img = try await self._get(description)
213+
let descriptor = try await img.descriptor(for: platform)
214+
// getSnapshotSize returns 0 if snapshot doesn't exist, and can throw on filesystem errors
215+
// We catch errors and return 0 to match the behavior when snapshot doesn't exist
216+
return (try? await self.snapshotStore.getSnapshotSize(descriptor: descriptor)) ?? 0
217+
}
209218
}
210219

211220
// MARK: Static Methods

Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,4 +282,28 @@ extension ImagesServiceHarness {
282282
reply.set(key: .filesystem, value: fsData)
283283
return reply
284284
}
285+
286+
@Sendable
287+
public func getSnapshotSize(_ message: XPCMessage) async throws -> XPCMessage {
288+
let descriptionData = message.dataNoCopy(key: .imageDescription)
289+
guard let descriptionData else {
290+
throw ContainerizationError(
291+
.invalidArgument,
292+
message: "missing image description"
293+
)
294+
}
295+
let description = try JSONDecoder().decode(ImageDescription.self, from: descriptionData)
296+
let platformData = message.dataNoCopy(key: .ociPlatform)
297+
guard let platformData else {
298+
throw ContainerizationError(
299+
.invalidArgument,
300+
message: "missing OCI platform"
301+
)
302+
}
303+
let platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData)
304+
let size = try await self.service.getSnapshotSize(description: description, platform: platform)
305+
let reply = message.reply()
306+
reply.set(key: .imageSize, value: size)
307+
return reply
308+
}
285309
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import ContainerImagesService
18+
import ContainerizationOCI
19+
import Foundation
20+
import Testing
21+
22+
@testable import ContainerImagesService
23+
24+
struct SnapshotStoreTests {
25+
26+
@Test("getSnapshotSize should return 0 when snapshot directory does not exist")
27+
func testGetSnapshotSizeWhenSnapshotDoesNotExist() async throws {
28+
// prepare
29+
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
30+
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
31+
defer {
32+
try? FileManager.default.removeItem(at: tempDir)
33+
}
34+
35+
let snapshotStore = try SnapshotStore(
36+
path: tempDir,
37+
unpackStrategy: SnapshotStore.defaultUnpackStrategy,
38+
log: nil
39+
)
40+
41+
// Create a descriptor for a non-existent snapshot
42+
let fakeDigest = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
43+
let platform = Platform(arch: "amd64", os: "linux")
44+
45+
// Create descriptor directly with the properties
46+
let descriptor = Descriptor(
47+
mediaType: "application/vnd.oci.image.manifest.v1+json",
48+
digest: fakeDigest,
49+
size: 1024,
50+
platform: platform
51+
)
52+
53+
let size = try await snapshotStore.getSnapshotSize(descriptor: descriptor)
54+
55+
#expect(size == 0, "Expected size to be 0 when snapshot doesn't exist, got \(size)")
56+
}
57+
58+
@Test("getSnapshotSize should return correct size when snapshot exists")
59+
func testGetSnapshotSizeWhenSnapshotExists() async throws {
60+
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
61+
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
62+
defer {
63+
try? FileManager.default.removeItem(at: tempDir)
64+
}
65+
66+
let snapshotStore = try SnapshotStore(
67+
path: tempDir,
68+
unpackStrategy: SnapshotStore.defaultUnpackStrategy,
69+
log: nil
70+
)
71+
72+
// Create a test descriptor
73+
let fakeDigest = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
74+
let descriptor = Descriptor(
75+
mediaType: "application/vnd.oci.image.manifest.v1+json",
76+
digest: fakeDigest,
77+
size: 1024,
78+
platform: Platform(arch: "amd64", os: "linux")
79+
)
80+
81+
// Create snapshot directory
82+
let snapshotDir =
83+
tempDir
84+
.appendingPathComponent("snapshots")
85+
.appendingPathComponent(descriptor.digest.trimmingDigestPrefix)
86+
try FileManager.default.createDirectory(at: snapshotDir, withIntermediateDirectories: true)
87+
88+
// Create test files: 1KB snapshot + 512 bytes info = 1536 bytes total
89+
let testFile = snapshotDir.appendingPathComponent("snapshot")
90+
try Data(repeating: 0, count: 1024).write(to: testFile)
91+
92+
let infoFile = snapshotDir.appendingPathComponent("snapshot-info")
93+
try Data(repeating: 0, count: 512).write(to: infoFile)
94+
95+
let size = try await snapshotStore.getSnapshotSize(descriptor: descriptor)
96+
97+
#expect(size >= 1536, "Expected at least 1536 bytes (1024 + 512), got \(size) allocated")
98+
}
99+
100+
@Test("getSnapshotSize should handle multiple files in snapshot directory")
101+
func testGetSnapshotSizeWithMultipleFiles() async throws {
102+
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
103+
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
104+
defer {
105+
try? FileManager.default.removeItem(at: tempDir)
106+
}
107+
108+
let snapshotStore = try SnapshotStore(
109+
path: tempDir,
110+
unpackStrategy: SnapshotStore.defaultUnpackStrategy,
111+
log: nil
112+
)
113+
114+
let fakeDigest = "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"
115+
let platform = Platform(arch: "arm64", os: "linux")
116+
let descriptor = Descriptor(
117+
mediaType: "application/vnd.oci.image.manifest.v1+json",
118+
digest: fakeDigest,
119+
size: 2048,
120+
platform: platform
121+
)
122+
123+
let snapshotDir =
124+
tempDir
125+
.appendingPathComponent("snapshots", isDirectory: true)
126+
.appendingPathComponent(descriptor.digest.trimmingDigestPrefix, isDirectory: true)
127+
try FileManager.default.createDirectory(at: snapshotDir, withIntermediateDirectories: true)
128+
129+
// Create multiple files
130+
let file1 = snapshotDir.appendingPathComponent("snapshot", isDirectory: false)
131+
try Data(repeating: 1, count: 2048).write(to: file1) // 2KB
132+
133+
let file2 = snapshotDir.appendingPathComponent("snapshot-info", isDirectory: false)
134+
try Data(repeating: 2, count: 1024).write(to: file2) // 1KB
135+
136+
let subdir = snapshotDir.appendingPathComponent("subdir", isDirectory: true)
137+
try FileManager.default.createDirectory(at: subdir, withIntermediateDirectories: true)
138+
let file3 = subdir.appendingPathComponent("extra", isDirectory: false)
139+
try Data(repeating: 3, count: 512).write(to: file3) // 512 bytes
140+
141+
let expectedMinSize: UInt64 = 2048 + 1024 + 512 // 3.5KB
142+
143+
let size = try await snapshotStore.getSnapshotSize(descriptor: descriptor)
144+
145+
#expect(size >= expectedMinSize, "Expected size to include all files (at least \(expectedMinSize) bytes), got \(size)")
146+
}
147+
148+
@Test("getSnapshotSize should handle empty snapshot directory")
149+
func testGetSnapshotSizeWithEmptyDirectory() async throws {
150+
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
151+
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
152+
defer {
153+
try? FileManager.default.removeItem(at: tempDir)
154+
}
155+
156+
let snapshotStore = try SnapshotStore(
157+
path: tempDir,
158+
unpackStrategy: SnapshotStore.defaultUnpackStrategy,
159+
log: nil
160+
)
161+
162+
let fakeDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
163+
let platform = Platform(arch: "amd64", os: "linux")
164+
let descriptor = Descriptor(
165+
mediaType: "application/vnd.oci.image.manifest.v1+json",
166+
digest: fakeDigest,
167+
size: 0,
168+
platform: platform
169+
)
170+
171+
// Create empty snapshot directory
172+
let snapshotDir =
173+
tempDir
174+
.appendingPathComponent("snapshots", isDirectory: true)
175+
.appendingPathComponent(descriptor.digest.trimmingDigestPrefix, isDirectory: true)
176+
try FileManager.default.createDirectory(at: snapshotDir, withIntermediateDirectories: true)
177+
178+
179+
let size = try await snapshotStore.getSnapshotSize(descriptor: descriptor)
180+
181+
// Empty directory should return 0 or a small overhead value
182+
#expect(size >= 0, "Expected size to be non-negative, got \(size)")
183+
}
184+
}

0 commit comments

Comments
 (0)