From 66606401a8aa7d433eb9342a6b8dad94b0e16c4c Mon Sep 17 00:00:00 2001 From: saehejkang Date: Sun, 7 Dec 2025 12:12:40 -0800 Subject: [PATCH] refactor volume prune command --- .../ContainerClient/Core/ClientVolume.swift | 13 ++---- Sources/ContainerClient/Core/XPC+.swift | 2 +- .../Volume/VolumePrune.swift | 46 ++++++++++++++----- .../Helpers/APIServer/APIServer+Start.swift | 2 +- .../Volumes/VolumesHarness.swift | 9 ++-- .../Volumes/VolumesService.swift | 46 ++----------------- .../Subcommands/Volumes/TestCLIVolumes.swift | 2 +- 7 files changed, 51 insertions(+), 69 deletions(-) diff --git a/Sources/ContainerClient/Core/ClientVolume.swift b/Sources/ContainerClient/Core/ClientVolume.swift index 5483ef4c..b7d01509 100644 --- a/Sources/ContainerClient/Core/ClientVolume.swift +++ b/Sources/ContainerClient/Core/ClientVolume.swift @@ -81,18 +81,13 @@ public struct ClientVolume { return try JSONDecoder().decode(Volume.self, from: responseData) } - public static func prune() async throws -> ([String], UInt64) { + public static func volumeDiskUsage(name: String) async throws -> UInt64 { let client = XPCClient(service: serviceIdentifier) - let message = XPCMessage(route: .volumePrune) + let message = XPCMessage(route: .volumeDiskUsage) + message.set(key: .volumeName, value: name) let reply = try await client.send(message) - guard let responseData = reply.dataNoCopy(key: .volumes) else { - return ([], 0) - } - - let volumeNames = try JSONDecoder().decode([String].self, from: responseData) let size = reply.uint64(key: .volumeSize) - return (volumeNames, size) + return size } - } diff --git a/Sources/ContainerClient/Core/XPC+.swift b/Sources/ContainerClient/Core/XPC+.swift index dade31ca..bccbdbae 100644 --- a/Sources/ContainerClient/Core/XPC+.swift +++ b/Sources/ContainerClient/Core/XPC+.swift @@ -155,8 +155,8 @@ public enum XPCRoute: String { case volumeDelete case volumeList case volumeInspect - case volumePrune + case volumeDiskUsage case systemDiskUsage case ping diff --git a/Sources/ContainerCommands/Volume/VolumePrune.swift b/Sources/ContainerCommands/Volume/VolumePrune.swift index 34ae7477..8b22a825 100644 --- a/Sources/ContainerCommands/Volume/VolumePrune.swift +++ b/Sources/ContainerCommands/Volume/VolumePrune.swift @@ -29,19 +29,43 @@ extension Application.VolumeCommand { var global: Flags.Global public func run() async throws { - let (volumeNames, size) = try await ClientVolume.prune() - let formatter = ByteCountFormatter() - let freed = formatter.string(fromByteCount: Int64(size)) - - if volumeNames.isEmpty { - print("No volumes to prune") - } else { - print("Pruned volumes:") - for name in volumeNames { - print(name) + let allVolumes = try await ClientVolume.list() + + // Find all volumes not used by any container + let containers = try await ClientContainer.list() + var volumesInUse = Set() + for container in containers { + for mount in container.configuration.mounts { + if mount.isVolume, let volumeName = mount.volumeName { + volumesInUse.insert(volumeName) + } + } + } + + let volumesToPrune = allVolumes.filter { volume in + !volumesInUse.contains(volume.name) + } + + var prunedVolumes = [String]() + var totalSize: UInt64 = 0 + + for volume in volumesToPrune { + do { + let actualSize = try await ClientVolume.volumeDiskUsage(name: volume.name) + totalSize += actualSize + try await ClientVolume.delete(name: volume.name) + prunedVolumes.append(volume.name) + } catch { + log.error("Failed to prune volume \(volume.name): \(error)") } - print() } + + for name in prunedVolumes { + print(name) + } + + let formatter = ByteCountFormatter() + let freed = formatter.string(fromByteCount: Int64(totalSize)) print("Reclaimed \(freed) in disk space") } } diff --git a/Sources/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index a048d655..8bf36335 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -271,7 +271,7 @@ extension APIServer { routes[XPCRoute.volumeDelete] = harness.delete routes[XPCRoute.volumeList] = harness.list routes[XPCRoute.volumeInspect] = harness.inspect - routes[XPCRoute.volumePrune] = harness.prune + routes[XPCRoute.volumeDiskUsage] = harness.diskUsage return service } diff --git a/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift b/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift index e145b18a..d694abab 100644 --- a/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift +++ b/Sources/Services/ContainerAPIService/Volumes/VolumesHarness.swift @@ -94,12 +94,13 @@ public struct VolumesHarness: Sendable { } @Sendable - public func prune(_ message: XPCMessage) async throws -> XPCMessage { - let (volumeNames, size) = try await service.prune() - let data = try JSONEncoder().encode(volumeNames) + public func diskUsage(_ message: XPCMessage) async throws -> XPCMessage { + guard let name = message.string(key: .volumeName) else { + throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty") + } + let size = try await service.volumeDiskUsage(name: name) let reply = message.reply() - reply.set(key: .volumes, value: data) reply.set(key: .volumeSize, value: size) return reply } diff --git a/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift b/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift index 3c09784e..21a7bc75 100644 --- a/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift +++ b/Sources/Services/ContainerAPIService/Volumes/VolumesService.swift @@ -72,48 +72,10 @@ public actor VolumesService { } } - public func prune() async throws -> ([String], UInt64) { - try await lock.withLock { _ in - let allVolumes = try await self.store.list() - - // do entire prune operation atomically with container list - return try await self.containersService.withContainerList { containers in - var inUseSet = Set() - for container in containers { - for mount in container.configuration.mounts { - if mount.isVolume, let volumeName = mount.volumeName { - inUseSet.insert(volumeName) - } - } - } - - let volumesToPrune = allVolumes.filter { volume in - !inUseSet.contains(volume.name) - } - - var prunedNames = [String]() - var totalSize: UInt64 = 0 - - for volume in volumesToPrune { - do { - // calculate actual disk usage before deletion - let volumePath = self.volumePath(for: volume.name) - let actualSize = self.calculateDirectorySize(at: volumePath) - - try await self.store.delete(volume.name) - try self.removeVolumeDirectory(for: volume.name) - - prunedNames.append(volume.name) - totalSize += actualSize - self.log.info("Pruned volume", metadata: ["name": "\(volume.name)", "size": "\(actualSize)"]) - } catch { - self.log.error("failed to prune volume \(volume.name): \(error)") - } - } - - return (prunedNames, totalSize) - } - } + /// Calculate disk usage for a single volume + public func volumeDiskUsage(name: String) async throws -> UInt64 { + let volumePath = self.volumePath(for: name) + return self.calculateDirectorySize(at: volumePath) } /// Calculate disk usage for volumes diff --git a/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift b/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift index ad423195..75deefce 100644 --- a/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift +++ b/Tests/CLITests/Subcommands/Volumes/TestCLIVolumes.swift @@ -332,7 +332,7 @@ class TestCLIVolumes: CLITest { throw CLIError.executionFailed("volume prune failed: \(error)") } - #expect(output.contains("0 B") || output.contains("No volumes to prune"), "should show no space reclaimed or no volumes message") + #expect(output.contains("Zero KB"), "should show no space reclaimed") } @Test func testVolumePruneUnusedVolumes() throws {