Skip to content

Commit d327a50

Browse files
authored
Add container system df command for disk usage reporting (#902)
- Closes #884. ## Type of Change - [ ] Bug fix - [x] New feature - [ ] Breaking change - [ ] Documentation update ## Motivation and Context This PR implements the `container system df` command to display disk usage statistics for images, containers, and volumes, along with their total count, active count, size, and reclaimable space for each resource type. Active resources are determined by container mount references and running state, while reclaimable space is calculated from inactive or stopped resources. Example output: ``` ~/container ❯ container system df TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 4 3 4.42 GB 516.5 MB (11%) Containers 4 2 2.69 GB 1.51 GB (56%) Local Volumes 3 2 208.5 MB 66.2 MB (32%) ``` I'll have some follow-on PRs that will add `-v/--verbose` flag for detailed per-resource information, `--filter` flag for filtering output by resource type, and a `--debug` flag for debug statistics like block usage, clone counts etc. ## Testing - [x] Tested locally - [x] Added/updated tests - [ ] Added/updated docs
1 parent c76e0f4 commit d327a50

23 files changed

+751
-21
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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 ContainerXPC
18+
import ContainerizationError
19+
import Foundation
20+
21+
/// Client API for disk usage operations
22+
public struct ClientDiskUsage {
23+
static let serviceIdentifier = "com.apple.container.apiserver"
24+
25+
/// Get disk usage statistics for all resource types
26+
public static func get() async throws -> DiskUsageStats {
27+
let client = XPCClient(service: serviceIdentifier)
28+
let message = XPCMessage(route: .systemDiskUsage)
29+
let reply = try await client.send(message)
30+
31+
guard let responseData = reply.dataNoCopy(key: .diskUsageStats) else {
32+
throw ContainerizationError(
33+
.internalError,
34+
message: "Invalid response from server: missing disk usage data"
35+
)
36+
}
37+
38+
return try JSONDecoder().decode(DiskUsageStats.self, from: responseData)
39+
}
40+
}

Sources/ContainerClient/Core/ClientImage.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,10 +289,30 @@ extension ClientImage {
289289
let request = newRequest(.imagePrune)
290290
let response = try await client.send(request)
291291
let digests = try response.digests()
292-
let size = response.uint64(key: .size)
292+
let size = response.uint64(key: .imageSize)
293293
return (digests, size)
294294
}
295295

296+
/// Calculate disk usage for images
297+
/// - Parameter activeReferences: Set of image references currently in use by containers
298+
/// - Returns: Tuple of (total count, active count, total size, reclaimable size)
299+
public static func calculateDiskUsage(activeReferences: Set<String>) async throws -> (totalCount: Int, activeCount: Int, totalSize: UInt64, reclaimableSize: UInt64) {
300+
let client = newXPCClient()
301+
let request = newRequest(.imageDiskUsage)
302+
303+
// Encode active references
304+
let activeRefsData = try JSONEncoder().encode(activeReferences)
305+
request.set(key: .activeImageReferences, value: activeRefsData)
306+
307+
let response = try await client.send(request)
308+
let total = Int(response.int64(key: .totalCount))
309+
let active = Int(response.int64(key: .activeCount))
310+
let size = response.uint64(key: .imageSize)
311+
let reclaimable = response.uint64(key: .reclaimableSize)
312+
313+
return (totalCount: total, activeCount: active, totalSize: size, reclaimableSize: reclaimable)
314+
}
315+
296316
public static func fetch(reference: String, platform: Platform? = nil, scheme: RequestScheme = .auto, progressUpdate: ProgressUpdateHandler? = nil) async throws -> ClientImage
297317
{
298318
do {

Sources/ContainerClient/Core/ClientVolume.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public struct ClientVolume {
9191
}
9292

9393
let volumeNames = try JSONDecoder().decode([String].self, from: responseData)
94-
let size = reply.uint64(key: .size)
94+
let size = reply.uint64(key: .volumeSize)
9595
return (volumeNames, size)
9696
}
9797

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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 Foundation
18+
19+
/// Disk usage statistics for all resource types
20+
public struct DiskUsageStats: Sendable, Codable {
21+
/// Disk usage for images
22+
public var images: ResourceUsage
23+
24+
/// Disk usage for containers
25+
public var containers: ResourceUsage
26+
27+
/// Disk usage for volumes
28+
public var volumes: ResourceUsage
29+
30+
public init(images: ResourceUsage, containers: ResourceUsage, volumes: ResourceUsage) {
31+
self.images = images
32+
self.containers = containers
33+
self.volumes = volumes
34+
}
35+
}
36+
37+
/// Disk usage statistics for a specific resource type
38+
public struct ResourceUsage: Sendable, Codable {
39+
/// Total number of resources
40+
public var total: Int
41+
42+
/// Number of active/running resources
43+
public var active: Int
44+
45+
/// Total size in bytes
46+
public var sizeInBytes: UInt64
47+
48+
/// Reclaimable size in bytes (from unused/inactive resources)
49+
public var reclaimable: UInt64
50+
51+
public init(total: Int, active: Int, sizeInBytes: UInt64, reclaimable: UInt64) {
52+
self.total = total
53+
self.active = active
54+
self.sizeInBytes = sizeInBytes
55+
self.reclaimable = reclaimable
56+
}
57+
}

Sources/ContainerClient/Core/XPC+.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ public enum XPCKeys: String {
118118

119119
/// Container statistics
120120
case statistics
121+
case volumeSize
122+
123+
/// Disk usage
124+
case diskUsageStats
121125
}
122126

123127
public enum XPCRoute: String {
@@ -153,6 +157,8 @@ public enum XPCRoute: String {
153157
case volumeInspect
154158
case volumePrune
155159

160+
case systemDiskUsage
161+
156162
case ping
157163

158164
case installKernel

Sources/ContainerCommands/System/SystemCommand.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ extension Application {
2323
commandName: "system",
2424
abstract: "Manage system components",
2525
subcommands: [
26+
SystemDF.self,
2627
SystemDNS.self,
2728
SystemKernel.self,
2829
SystemLogs.self,
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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 ArgumentParser
18+
import ContainerClient
19+
import ContainerizationError
20+
import Foundation
21+
22+
extension Application {
23+
public struct SystemDF: AsyncParsableCommand {
24+
public static let configuration = CommandConfiguration(
25+
commandName: "df",
26+
abstract: "Show disk usage for images, containers, and volumes"
27+
)
28+
29+
@Option(name: .long, help: "Format of the output")
30+
var format: ListFormat = .table
31+
32+
@OptionGroup
33+
var global: Flags.Global
34+
35+
public init() {}
36+
37+
public func run() async throws {
38+
let stats = try await ClientDiskUsage.get()
39+
40+
if format == .json {
41+
let encoder = JSONEncoder()
42+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
43+
let data = try encoder.encode(stats)
44+
guard let jsonString = String(data: data, encoding: .utf8) else {
45+
throw ContainerizationError(
46+
.internalError,
47+
message: "Failed to encode JSON output"
48+
)
49+
}
50+
print(jsonString)
51+
return
52+
}
53+
54+
printTable(stats: stats)
55+
}
56+
57+
private func printTable(stats: DiskUsageStats) {
58+
var rows: [[String]] = []
59+
60+
// Header row
61+
rows.append(["TYPE", "TOTAL", "ACTIVE", "SIZE", "RECLAIMABLE"])
62+
63+
// Images row
64+
rows.append([
65+
"Images",
66+
"\(stats.images.total)",
67+
"\(stats.images.active)",
68+
formatSize(stats.images.sizeInBytes),
69+
formatReclaimable(stats.images.reclaimable, total: stats.images.sizeInBytes),
70+
])
71+
72+
// Containers row
73+
rows.append([
74+
"Containers",
75+
"\(stats.containers.total)",
76+
"\(stats.containers.active)",
77+
formatSize(stats.containers.sizeInBytes),
78+
formatReclaimable(stats.containers.reclaimable, total: stats.containers.sizeInBytes),
79+
])
80+
81+
// Volumes row
82+
rows.append([
83+
"Local Volumes",
84+
"\(stats.volumes.total)",
85+
"\(stats.volumes.active)",
86+
formatSize(stats.volumes.sizeInBytes),
87+
formatReclaimable(stats.volumes.reclaimable, total: stats.volumes.sizeInBytes),
88+
])
89+
90+
let tableFormatter = TableOutput(rows: rows)
91+
print(tableFormatter.format())
92+
}
93+
94+
private func formatSize(_ bytes: UInt64) -> String {
95+
if bytes == 0 {
96+
return "0 B"
97+
}
98+
let formatter = ByteCountFormatter()
99+
formatter.countStyle = .file
100+
return formatter.string(fromByteCount: Int64(bytes))
101+
}
102+
103+
private func formatReclaimable(_ reclaimable: UInt64, total: UInt64) -> String {
104+
let sizeStr = formatSize(reclaimable)
105+
106+
if total == 0 {
107+
return "\(sizeStr) (0%)"
108+
}
109+
110+
// Cap at 100% in case reclaimable > total (shouldn't happen but be defensive)
111+
let percentage = min(100, Int(round(Double(reclaimable) / Double(total) * 100.0)))
112+
return "\(sizeStr) (\(percentage)%)"
113+
}
114+
}
115+
}

Sources/Helpers/APIServer/APIServer+Start.swift

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,13 @@ extension APIServer {
6767
)
6868
initializeHealthCheckService(log: log, routes: &routes)
6969
try initializeKernelService(log: log, routes: &routes)
70-
try initializeVolumeService(containersService: containersService, log: log, routes: &routes)
70+
let volumesService = try initializeVolumeService(containersService: containersService, log: log, routes: &routes)
71+
try initializeDiskUsageService(
72+
containersService: containersService,
73+
volumesService: volumesService,
74+
log: log,
75+
routes: &routes
76+
)
7177

7278
let server = XPCServer(
7379
identifier: "com.apple.container.apiserver",
@@ -254,7 +260,7 @@ extension APIServer {
254260
containersService: ContainersService,
255261
log: Logger,
256262
routes: inout [XPCRoute: XPCServer.RouteHandler]
257-
) throws {
263+
) throws -> VolumesService {
258264
log.info("initializing volume service")
259265

260266
let resourceRoot = appRoot.appendingPathComponent("volumes")
@@ -266,6 +272,26 @@ extension APIServer {
266272
routes[XPCRoute.volumeList] = harness.list
267273
routes[XPCRoute.volumeInspect] = harness.inspect
268274
routes[XPCRoute.volumePrune] = harness.prune
275+
276+
return service
277+
}
278+
279+
private func initializeDiskUsageService(
280+
containersService: ContainersService,
281+
volumesService: VolumesService,
282+
log: Logger,
283+
routes: inout [XPCRoute: XPCServer.RouteHandler]
284+
) throws {
285+
log.info("initializing disk usage service")
286+
287+
let service = DiskUsageService(
288+
containersService: containersService,
289+
volumesService: volumesService,
290+
log: log
291+
)
292+
let harness = DiskUsageHarness(service: service, log: log)
293+
294+
routes[XPCRoute.systemDiskUsage] = harness.get
269295
}
270296
}
271297
}

Sources/Helpers/Images/ImagesHelper.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ extension ImagesHelper {
9999
routes[ImagesServiceXPCRoute.imageLoad.rawValue] = harness.load
100100
routes[ImagesServiceXPCRoute.imageUnpack.rawValue] = harness.unpack
101101
routes[ImagesServiceXPCRoute.imagePrune.rawValue] = harness.prune
102+
routes[ImagesServiceXPCRoute.imageDiskUsage.rawValue] = harness.calculateDiskUsage
102103
routes[ImagesServiceXPCRoute.snapshotDelete.rawValue] = harness.deleteSnapshot
103104
routes[ImagesServiceXPCRoute.snapshotGet.rawValue] = harness.getSnapshot
104105
}

0 commit comments

Comments
 (0)