mirror of
https://github.com/apple/container.git
synced 2026-08-29 03:46:39 +00:00
Fix system df to count content blobs and deduplicate shared storage (#1555)
- Closes #1526 and #1527. ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation update ## Motivation and Context This PR fixes `system df` to report actual on-disk allocated bytes (content blobs + snapshots) instead of summing per-image snapshot sizes. Orphaned blobs are now included as reclaimable, and storage shared across tags is no longer double counted. Also consolidates three identical `calculateDirectorySize` implementations into a shared `FileManager.allocatedSize(of:)` extension. ## Testing - [x] Tested locally - [x] Added/updated tests - [ ] Added/updated docs
This commit is contained in:
@@ -211,7 +211,8 @@ INTEGRATION_TEST_SUITES ?= \
|
||||
TestCLIKernelSet \
|
||||
TestCLIAnonymousVolumes \
|
||||
TestCLINotFound \
|
||||
TestCLINoParallelCases
|
||||
TestCLINoParallelCases \
|
||||
TestCLISystemDF
|
||||
|
||||
empty :=
|
||||
space := $(empty) $(empty)
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "f3e6e0a1ee627e1f396b1565e72a50b179394e7011667ec4569dc53455ee06ed",
|
||||
"originHash" : "2b649fdff52a3ba78453f5b81f7224584c2fa28ec12d4da8fa0c37f9ec832aa6",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "async-http-client",
|
||||
@@ -15,8 +15,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/containerization.git",
|
||||
"state" : {
|
||||
"revision" : "2550dd49f1890702f6fe0171212050bbce9d3825",
|
||||
"version" : "0.33.2"
|
||||
"revision" : "a2a1add6c7e1a1665e5397edc49d925c49090b3a",
|
||||
"version" : "0.33.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ import PackageDescription
|
||||
let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0"
|
||||
let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified"
|
||||
let builderShimVersion = "0.12.0"
|
||||
let scVersion = "0.33.2"
|
||||
let scVersion = "0.33.3"
|
||||
|
||||
let package = Package(
|
||||
name: "container",
|
||||
|
||||
@@ -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).
|
||||
///
|
||||
/// 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,12 @@ 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,
|
||||
imageStore: imageStore,
|
||||
snapshotStore: snapshotStore,
|
||||
log: log
|
||||
)
|
||||
let harness = ImagesServiceHarness(service: service, log: log)
|
||||
|
||||
routes[ImagesServiceXPCRoute.imagePull.rawValue] = XPCServer.route(harness.pull)
|
||||
@@ -124,6 +129,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)
|
||||
|
||||
@@ -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 {
|
||||
@@ -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(
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,7 @@ public enum ImagesServiceXPCRoute: String {
|
||||
case contentGet
|
||||
case contentDelete
|
||||
case contentClean
|
||||
case contentSize
|
||||
case contentIngestStart
|
||||
case contentIngestComplete
|
||||
case contentIngestCancel
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = try await self.service.totalAllocatedSize()
|
||||
let reply = message.reply()
|
||||
reply.set(key: .imageSize, value: size)
|
||||
return reply
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,4 +156,9 @@ public actor ContentStoreService {
|
||||
|
||||
return try await self.contentStore.cancelIngestSession(id)
|
||||
}
|
||||
|
||||
/// Total bytes allocated on disk for the content store.
|
||||
public func totalAllocatedSize() async throws -> UInt64 {
|
||||
try await self.contentStore.totalAllocatedSize()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,12 @@ public actor ImagesService {
|
||||
private let imageStore: ImageStore
|
||||
private let snapshotStore: SnapshotStore
|
||||
|
||||
public init(contentStore: ContentStore, imageStore: ImageStore, snapshotStore: SnapshotStore, log: Logger) throws {
|
||||
public init(
|
||||
contentStore: ContentStore,
|
||||
imageStore: ImageStore,
|
||||
snapshotStore: SnapshotStore,
|
||||
log: Logger
|
||||
) throws {
|
||||
self.contentStore = contentStore
|
||||
self.imageStore = imageStore
|
||||
self.snapshotStore = snapshotStore
|
||||
@@ -270,8 +275,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: [
|
||||
@@ -290,49 +294,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
|
||||
guard activeReferences.contains(image.reference) else { continue }
|
||||
activeCount += 1
|
||||
let imageDigest = image.digest.trimmingDigestPrefix
|
||||
guard processedDigests.insert(imageDigest).inserted else { continue }
|
||||
|
||||
// Check if image is referenced by any container
|
||||
let isActive = activeReferences.contains(image.reference)
|
||||
if isActive {
|
||||
activeCount += 1
|
||||
} else {
|
||||
reclaimableSize += imageSize
|
||||
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)
|
||||
let snapshotDiskSize = await self.snapshotStore.totalAllocatedSize()
|
||||
let contentDiskTotal = try await self.contentStore.totalAllocatedSize()
|
||||
let totalOnDisk = contentDiskTotal + snapshotDiskSize
|
||||
let activeSize = activeContentSizes.values.reduce(0, +) + activeSnapshotSizes.values.reduce(0, +)
|
||||
let reclaimable = totalOnDisk > activeSize ? totalOnDisk - activeSize : 0
|
||||
|
||||
return (images.count, activeCount, totalOnDisk, reclaimable)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
guard descriptor.platform != nil else { continue }
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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
|
||||
import Testing
|
||||
|
||||
@Suite(.serialSuites, .serialized)
|
||||
final class TestCLISystemDF: CLITest {
|
||||
private struct DiskUsageStats: Decodable {
|
||||
let images: ResourceUsage
|
||||
}
|
||||
|
||||
private struct ResourceUsage: Decodable {
|
||||
let active: Int
|
||||
let reclaimable: UInt64
|
||||
let sizeInBytes: UInt64
|
||||
let total: Int
|
||||
}
|
||||
|
||||
// Issue #1526: reported image size must include content blobs, not just unpacked snapshots.
|
||||
@Test func imageDiskUsageIsPopulatedAfterPull() throws {
|
||||
try withCleanImageStore {
|
||||
try doPull(imageName: alpine)
|
||||
let stats = try systemDiskUsage()
|
||||
#expect(stats.images.total >= 1)
|
||||
#expect(stats.images.active == 0)
|
||||
#expect(stats.images.sizeInBytes > 0)
|
||||
#expect(stats.images.reclaimable == stats.images.sizeInBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #1527: tagging the same image must not double-count its storage.
|
||||
@Test func tagsDoNotDoubleCountImageStorage() throws {
|
||||
try withCleanImageStore {
|
||||
try doPull(imageName: alpine)
|
||||
let before = try systemDiskUsage()
|
||||
|
||||
try doImageTag(image: alpine, newName: "local/system-df-alpine:tag-one")
|
||||
try doImageTag(image: alpine, newName: "local/system-df-alpine:tag-two")
|
||||
let after = try systemDiskUsage()
|
||||
|
||||
#expect(after.images.total == before.images.total + 2)
|
||||
#expect(after.images.sizeInBytes == before.images.sizeInBytes)
|
||||
#expect(after.images.reclaimable == before.images.reclaimable)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #1527: removing one of several tags must not free shared storage.
|
||||
// Assumes no background GC runs between operations; blobs stay until all references are removed.
|
||||
@Test func deletingOneOfMultipleTagsPreservesSharedStorage() throws {
|
||||
try withCleanImageStore {
|
||||
let baseline = try systemDiskUsage()
|
||||
|
||||
try doPull(imageName: alpine)
|
||||
try doImageTag(image: alpine, newName: "local/system-df-alpine:delete-probe")
|
||||
let beforeDelete = try systemDiskUsage()
|
||||
|
||||
try doRemoveImages(images: ["local/system-df-alpine:delete-probe"])
|
||||
let afterAliasDelete = try systemDiskUsage()
|
||||
|
||||
#expect(afterAliasDelete.images.total == beforeDelete.images.total - 1)
|
||||
#expect(afterAliasDelete.images.sizeInBytes == beforeDelete.images.sizeInBytes)
|
||||
#expect(afterAliasDelete.images.reclaimable == beforeDelete.images.reclaimable)
|
||||
|
||||
_ = try? run(arguments: ["image", "rm", "--all"])
|
||||
let afterFullClean = try systemDiskUsage()
|
||||
#expect(afterFullClean.images.total <= baseline.images.total)
|
||||
#expect(afterFullClean.images.sizeInBytes <= baseline.images.sizeInBytes)
|
||||
}
|
||||
}
|
||||
|
||||
private func withCleanImageStore(_ body: () throws -> Void) throws {
|
||||
_ = try? run(arguments: ["image", "rm", "--all"])
|
||||
defer {
|
||||
_ = try? run(arguments: ["image", "rm", "--all"])
|
||||
}
|
||||
try body()
|
||||
}
|
||||
|
||||
private func systemDiskUsage() throws -> DiskUsageStats {
|
||||
let (data, _, error, status) = try run(arguments: ["system", "df", "--format", "json"])
|
||||
guard status == 0 else {
|
||||
throw CLIError.executionFailed("system df failed: \(error)")
|
||||
}
|
||||
return try JSONDecoder().decode(DiskUsageStats.self, from: data)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user