Skip to content

Commit 0733a81

Browse files
authored
[volumes]: refactor prune command (apple#940)
- Refactor the `volume prune` command to follow a client-side approach. The `volumeDiskUsage` is calculated in the service file, so it made sense to leave that there. - Relates to the discussion from apple#914
1 parent 42528e6 commit 0733a81

File tree

7 files changed

+51
-69
lines changed

7 files changed

+51
-69
lines changed

Sources/ContainerClient/Core/ClientVolume.swift

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,18 +81,13 @@ public struct ClientVolume {
8181
return try JSONDecoder().decode(Volume.self, from: responseData)
8282
}
8383

84-
public static func prune() async throws -> ([String], UInt64) {
84+
public static func volumeDiskUsage(name: String) async throws -> UInt64 {
8585
let client = XPCClient(service: serviceIdentifier)
86-
let message = XPCMessage(route: .volumePrune)
86+
let message = XPCMessage(route: .volumeDiskUsage)
87+
message.set(key: .volumeName, value: name)
8788
let reply = try await client.send(message)
8889

89-
guard let responseData = reply.dataNoCopy(key: .volumes) else {
90-
return ([], 0)
91-
}
92-
93-
let volumeNames = try JSONDecoder().decode([String].self, from: responseData)
9490
let size = reply.uint64(key: .volumeSize)
95-
return (volumeNames, size)
91+
return size
9692
}
97-
9893
}

Sources/ContainerClient/Core/XPC+.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,8 @@ public enum XPCRoute: String {
155155
case volumeDelete
156156
case volumeList
157157
case volumeInspect
158-
case volumePrune
159158

159+
case volumeDiskUsage
160160
case systemDiskUsage
161161

162162
case ping

Sources/ContainerCommands/Volume/VolumePrune.swift

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,43 @@ extension Application.VolumeCommand {
2929
var global: Flags.Global
3030

3131
public func run() async throws {
32-
let (volumeNames, size) = try await ClientVolume.prune()
33-
let formatter = ByteCountFormatter()
34-
let freed = formatter.string(fromByteCount: Int64(size))
35-
36-
if volumeNames.isEmpty {
37-
print("No volumes to prune")
38-
} else {
39-
print("Pruned volumes:")
40-
for name in volumeNames {
41-
print(name)
32+
let allVolumes = try await ClientVolume.list()
33+
34+
// Find all volumes not used by any container
35+
let containers = try await ClientContainer.list()
36+
var volumesInUse = Set<String>()
37+
for container in containers {
38+
for mount in container.configuration.mounts {
39+
if mount.isVolume, let volumeName = mount.volumeName {
40+
volumesInUse.insert(volumeName)
41+
}
42+
}
43+
}
44+
45+
let volumesToPrune = allVolumes.filter { volume in
46+
!volumesInUse.contains(volume.name)
47+
}
48+
49+
var prunedVolumes = [String]()
50+
var totalSize: UInt64 = 0
51+
52+
for volume in volumesToPrune {
53+
do {
54+
let actualSize = try await ClientVolume.volumeDiskUsage(name: volume.name)
55+
totalSize += actualSize
56+
try await ClientVolume.delete(name: volume.name)
57+
prunedVolumes.append(volume.name)
58+
} catch {
59+
log.error("Failed to prune volume \(volume.name): \(error)")
4260
}
43-
print()
4461
}
62+
63+
for name in prunedVolumes {
64+
print(name)
65+
}
66+
67+
let formatter = ByteCountFormatter()
68+
let freed = formatter.string(fromByteCount: Int64(totalSize))
4569
print("Reclaimed \(freed) in disk space")
4670
}
4771
}

Sources/Helpers/APIServer/APIServer+Start.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ extension APIServer {
271271
routes[XPCRoute.volumeDelete] = harness.delete
272272
routes[XPCRoute.volumeList] = harness.list
273273
routes[XPCRoute.volumeInspect] = harness.inspect
274-
routes[XPCRoute.volumePrune] = harness.prune
274+
routes[XPCRoute.volumeDiskUsage] = harness.diskUsage
275275

276276
return service
277277
}

Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,13 @@ public struct VolumesHarness: Sendable {
9494
}
9595

9696
@Sendable
97-
public func prune(_ message: XPCMessage) async throws -> XPCMessage {
98-
let (volumeNames, size) = try await service.prune()
99-
let data = try JSONEncoder().encode(volumeNames)
97+
public func diskUsage(_ message: XPCMessage) async throws -> XPCMessage {
98+
guard let name = message.string(key: .volumeName) else {
99+
throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty")
100+
}
101+
let size = try await service.volumeDiskUsage(name: name)
100102

101103
let reply = message.reply()
102-
reply.set(key: .volumes, value: data)
103104
reply.set(key: .volumeSize, value: size)
104105
return reply
105106
}

Sources/Services/ContainerAPIService/Volumes/VolumesService.swift

Lines changed: 4 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -72,48 +72,10 @@ public actor VolumesService {
7272
}
7373
}
7474

75-
public func prune() async throws -> ([String], UInt64) {
76-
try await lock.withLock { _ in
77-
let allVolumes = try await self.store.list()
78-
79-
// do entire prune operation atomically with container list
80-
return try await self.containersService.withContainerList { containers in
81-
var inUseSet = Set<String>()
82-
for container in containers {
83-
for mount in container.configuration.mounts {
84-
if mount.isVolume, let volumeName = mount.volumeName {
85-
inUseSet.insert(volumeName)
86-
}
87-
}
88-
}
89-
90-
let volumesToPrune = allVolumes.filter { volume in
91-
!inUseSet.contains(volume.name)
92-
}
93-
94-
var prunedNames = [String]()
95-
var totalSize: UInt64 = 0
96-
97-
for volume in volumesToPrune {
98-
do {
99-
// calculate actual disk usage before deletion
100-
let volumePath = self.volumePath(for: volume.name)
101-
let actualSize = self.calculateDirectorySize(at: volumePath)
102-
103-
try await self.store.delete(volume.name)
104-
try self.removeVolumeDirectory(for: volume.name)
105-
106-
prunedNames.append(volume.name)
107-
totalSize += actualSize
108-
self.log.info("Pruned volume", metadata: ["name": "\(volume.name)", "size": "\(actualSize)"])
109-
} catch {
110-
self.log.error("failed to prune volume \(volume.name): \(error)")
111-
}
112-
}
113-
114-
return (prunedNames, totalSize)
115-
}
116-
}
75+
/// Calculate disk usage for a single volume
76+
public func volumeDiskUsage(name: String) async throws -> UInt64 {
77+
let volumePath = self.volumePath(for: name)
78+
return self.calculateDirectorySize(at: volumePath)
11779
}
11880

11981
/// Calculate disk usage for volumes

Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ class TestCLIVolumes: CLITest {
332332
throw CLIError.executionFailed("volume prune failed: \(error)")
333333
}
334334

335-
#expect(output.contains("0 B") || output.contains("No volumes to prune"), "should show no space reclaimed or no volumes message")
335+
#expect(output.contains("Zero KB"), "should show no space reclaimed")
336336
}
337337

338338
@Test func testVolumePruneUnusedVolumes() throws {

0 commit comments

Comments
 (0)