From 98cef1a6fa68d71eee5dc3716b99d2269d6a21c8 Mon Sep 17 00:00:00 2001 From: ChengHao Yang <17496418+tico88612@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:04:03 +0800 Subject: [PATCH 1/5] Refactor container prune logic to ContainerAPIService This commit is prepare for the system prune, preventing the duplicate logic in the ContainerCommands. Signed-off-by: ChengHao Yang <17496418+tico88612@users.noreply.github.com> --- .../Container/ContainerPrune.swift | 35 +++++----------- .../Client/ContainerClient+Prune.swift | 41 +++++++++++++++++++ .../Client/PruneResult.swift | 40 ++++++++++++++++++ 3 files changed, 92 insertions(+), 24 deletions(-) create mode 100644 Sources/Services/ContainerAPIService/Client/ContainerClient+Prune.swift create mode 100644 Sources/Services/ContainerAPIService/Client/PruneResult.swift diff --git a/Sources/ContainerCommands/Container/ContainerPrune.swift b/Sources/ContainerCommands/Container/ContainerPrune.swift index 13bfbe7f8..5e0c2a699 100644 --- a/Sources/ContainerCommands/Container/ContainerPrune.swift +++ b/Sources/ContainerCommands/Container/ContainerPrune.swift @@ -16,8 +16,6 @@ import ArgumentParser import ContainerAPIClient -import ContainerResource -import ContainerizationError import Foundation extension Application { @@ -34,32 +32,21 @@ extension Application { public func run() async throws { let client = ContainerClient() - let filters = ContainerListFilters(status: .stopped).withoutMachines() - let containersToPrune = try await client.list(filters: filters) - - var prunedContainerIds = [String]() - var totalSize: UInt64 = 0 - - for container in containersToPrune { - do { - let actualSize = try await client.diskUsage(id: container.id) - totalSize += actualSize - try await client.delete(id: container.id) - prunedContainerIds.append(container.id) - } catch { - log.error( - "failed to prune container", - metadata: [ - "id": "\(container.id)", - "error": "\(error)", - ]) - } + let result = try await client.prune() + + for failure in result.failed { + log.error( + "failed to prune container", + metadata: [ + "id": "\(failure.id)", + "error": "\(failure.error)", + ]) } let formatter = ByteCountFormatter() - let freed = formatter.string(fromByteCount: Int64(totalSize)) + let freed = formatter.string(fromByteCount: Int64(result.reclaimedBytes)) - for name in prunedContainerIds { + for name in result.pruned { print(name) } log.info("Reclaimed \(freed) in disk space") diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient+Prune.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient+Prune.swift new file mode 100644 index 000000000..7d1aee80a --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient+Prune.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource + +extension ContainerClient { + /// Remove all stopped containers, returning the outcome of the operation. + public func prune() async throws -> PruneResult { + let filters = ContainerListFilters(status: .stopped).withoutMachines() + let containersToPrune = try await list(filters: filters) + var prunedContainerIds = [String]() + var failed = [PruneResult.Failure]() + var totalSize: UInt64 = 0 + + for container in containersToPrune { + do { + let actualSize = try await diskUsage(id: container.id) + totalSize += actualSize + try await delete(id: container.id) + prunedContainerIds.append(container.id) + } catch { + failed.append(PruneResult.Failure(id: container.id, error: error)) + } + } + + return PruneResult(pruned: prunedContainerIds, failed: failed, reclaimedBytes: totalSize) + } +} diff --git a/Sources/Services/ContainerAPIService/Client/PruneResult.swift b/Sources/Services/ContainerAPIService/Client/PruneResult.swift new file mode 100644 index 000000000..940e661d3 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/PruneResult.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// The outcome of a prune operation on containers, images, volumes, or networks. +public struct PruneResult: Sendable { + public struct Failure: Sendable { + public let id: String + public let error: any Error + + public init(id: String, error: any Error) { + self.id = id + self.error = error + } + } + + public let pruned: [String] + + public let failed: [Failure] + + public let reclaimedBytes: UInt64 + + public init(pruned: [String], failed: [Failure], reclaimedBytes: UInt64) { + self.pruned = pruned + self.failed = failed + self.reclaimedBytes = reclaimedBytes + } +} From beea9d698ebc91dee01c54f3d36e5a453d114b15 Mon Sep 17 00:00:00 2001 From: ChengHao Yang <17496418+tico88612@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:08:21 +0800 Subject: [PATCH 2/5] Refactor volume prune logic move to ContainerAPIService Client Signed-off-by: ChengHao Yang <17496418+tico88612@users.noreply.github.com> --- .../Volume/VolumePrune.swift | 48 ++++------------ .../Client/ClientVolume+Prune.swift | 55 +++++++++++++++++++ 2 files changed, 66 insertions(+), 37 deletions(-) create mode 100644 Sources/Services/ContainerAPIService/Client/ClientVolume+Prune.swift diff --git a/Sources/ContainerCommands/Volume/VolumePrune.swift b/Sources/ContainerCommands/Volume/VolumePrune.swift index cad239158..f34094ff8 100644 --- a/Sources/ContainerCommands/Volume/VolumePrune.swift +++ b/Sources/ContainerCommands/Volume/VolumePrune.swift @@ -29,49 +29,23 @@ extension Application.VolumeCommand { public var logOptions: Flags.Logging public func run() async throws { - let allVolumes = try await ClientVolume.list() - - // Find all volumes not used by any container - let client = ContainerClient() - let containers = try await client.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", - metadata: [ - "id": "\(volume.name)", - "error": "\(error)", - ]) - } + let result = try await ClientVolume.prune() + + for failure in result.failed { + log.error( + "failed to prune volume", + metadata: [ + "id": "\(failure.id)", + "error": "\(failure.error)", + ]) } - for name in prunedVolumes { + for name in result.pruned { print(name) } let formatter = ByteCountFormatter() - let freed = formatter.string(fromByteCount: Int64(totalSize)) + let freed = formatter.string(fromByteCount: Int64(result.reclaimedBytes)) log.info("Reclaimed \(freed) in disk space") } } diff --git a/Sources/Services/ContainerAPIService/Client/ClientVolume+Prune.swift b/Sources/Services/ContainerAPIService/Client/ClientVolume+Prune.swift new file mode 100644 index 000000000..fe9e72dc5 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ClientVolume+Prune.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +extension ClientVolume { + /// Remove volumes with no container references, returning the outcome. + public static func prune() async throws -> PruneResult { + let allVolumes = try await list() + + // Find all volumes not used by any container. + let client = ContainerClient() + let containers = try await client.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 failed = [PruneResult.Failure]() + var totalSize: UInt64 = 0 + + for volume in volumesToPrune { + do { + let actualSize = try await volumeDiskUsage(name: volume.name) + totalSize += actualSize + try await delete(name: volume.name) + prunedVolumes.append(volume.name) + } catch { + failed.append(PruneResult.Failure(id: volume.name, error: error)) + } + } + + return PruneResult(pruned: prunedVolumes, failed: failed, reclaimedBytes: totalSize) + } +} From 68c8592ea7102bf4815bc6f4cd00d34dde516299 Mon Sep 17 00:00:00 2001 From: ChengHao Yang <17496418+tico88612@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:41:18 +0800 Subject: [PATCH 3/5] Refactor network prune logic move to ContainerAPIService Client Signed-off-by: ChengHao Yang <17496418+tico88612@users.noreply.github.com> --- .../Network/NetworkPrune.swift | 43 ++++----------- .../Client/NetworkClient+Prune.swift | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+), 33 deletions(-) create mode 100644 Sources/Services/ContainerAPIService/Client/NetworkClient+Prune.swift diff --git a/Sources/ContainerCommands/Network/NetworkPrune.swift b/Sources/ContainerCommands/Network/NetworkPrune.swift index 55748cf20..f9839775c 100644 --- a/Sources/ContainerCommands/Network/NetworkPrune.swift +++ b/Sources/ContainerCommands/Network/NetworkPrune.swift @@ -31,41 +31,18 @@ extension Application.NetworkCommand { public func run() async throws { let networkClient = NetworkClient() - let client = ContainerClient() - let allContainers = try await client.list() - let allNetworks = try await networkClient.list() - - var networksInUse = Set() - for container in allContainers { - for network in container.configuration.networks { - networksInUse.insert(network.network) - } - } - - let networksToPrune = allNetworks.filter { network in - !network.isBuiltin && !networksInUse.contains(network.id) - } - - var prunedNetworks = [String]() - - for network in networksToPrune { - do { - try await networkClient.delete(id: network.id) - prunedNetworks.append(network.id) - } catch { - // Note: This failure may occur due to a race condition between the network/ - // container collection above and a container run command that attaches to a - // network listed in the networksToPrune collection. - log.error( - "failed to prune network", - metadata: [ - "id": "\(network.id)", - "error": "\(error)", - ]) - } + let result = try await networkClient.prune() + + for failure in result.failed { + log.error( + "failed to prune network", + metadata: [ + "id": "\(failure.id)", + "error": "\(failure.error)", + ]) } - for name in prunedNetworks { + for name in result.pruned { print(name) } } diff --git a/Sources/Services/ContainerAPIService/Client/NetworkClient+Prune.swift b/Sources/Services/ContainerAPIService/Client/NetworkClient+Prune.swift new file mode 100644 index 000000000..dfa94c828 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/NetworkClient+Prune.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +extension NetworkClient { + /// Remove networks with no container connections, returning the outcome. + public func prune() async throws -> PruneResult { + let client = ContainerClient() + let allContainers = try await client.list() + let allNetworks = try await list() + + var networksInUse = Set() + for container in allContainers { + for network in container.configuration.networks { + networksInUse.insert(network.network) + } + } + + let networksToPrune = allNetworks.filter { network in + !network.isBuiltin && !networksInUse.contains(network.id) + } + + var prunedNetworks = [String]() + var failed = [PruneResult.Failure]() + + for network in networksToPrune { + do { + try await delete(id: network.id) + prunedNetworks.append(network.id) + } catch { + // Note: This failure may occur due to a race condition between the network/ + // container collection above and a container run command that attaches to a + // network listed in the networksToPrune collection. + failed.append(PruneResult.Failure(id: network.id, error: error)) + } + } + + return PruneResult(pruned: prunedNetworks, failed: failed, reclaimedBytes: 0) + } +} From fceb61f8c452ddc2b8cef14255069db2b3e44c14 Mon Sep 17 00:00:00 2001 From: ChengHao Yang <17496418+tico88612@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:04:57 +0800 Subject: [PATCH 4/5] Refactor image prune logic move to ContainerAPIService Client Signed-off-by: ChengHao Yang <17496418+tico88612@users.noreply.github.com> --- .../ContainerCommands/Image/ImagePrune.swift | 64 ++++----------- .../Client/ClientImage+Prune.swift | 77 +++++++++++++++++++ .../Client/PruneResult.swift | 6 +- 3 files changed, 95 insertions(+), 52 deletions(-) create mode 100644 Sources/Services/ContainerAPIService/Client/ClientImage+Prune.swift diff --git a/Sources/ContainerCommands/Image/ImagePrune.swift b/Sources/ContainerCommands/Image/ImagePrune.swift index 420a4eec4..fba000d71 100644 --- a/Sources/ContainerCommands/Image/ImagePrune.swift +++ b/Sources/ContainerCommands/Image/ImagePrune.swift @@ -16,7 +16,6 @@ import ArgumentParser import ContainerAPIClient -import ContainerizationOCI import Foundation extension Application { @@ -33,65 +32,28 @@ extension Application { var all: Bool = false public func run() async throws { - let allImages = try await ClientImage.list() - - let imagesToPrune: [ClientImage] - if all { - // Find all images not used by any container - let client = ContainerClient() - let containers = try await client.list() - var imagesInUse = Set() - for container in containers { - imagesInUse.insert(container.configuration.image.reference) - } - imagesToPrune = allImages.filter { image in - !imagesInUse.contains(image.reference) - } - } else { - // Find dangling images (images with no tag) - imagesToPrune = allImages.filter { image in - !hasTag(image.reference) - } - } - - var prunedImages = [String]() - - for image in imagesToPrune { - do { - try await ClientImage.delete(reference: image.reference, garbageCollect: false) - prunedImages.append(image.reference) - } catch { - log.error( - "failed to prune image", - metadata: [ - "ref": "\(image.reference)", - "error": "\(error)", - ]) - } + let result = try await ClientImage.prune(all: all) + + for failure in result.failed { + log.error( + "failed to prune image", + metadata: [ + "ref": "\(failure.id)", + "error": "\(failure.error)", + ]) } - let (deletedDigests, size) = try await ClientImage.cleanUpOrphanedBlobs() - - for image in imagesToPrune { - print("untagged \(image.reference)") + for reference in result.pruned { + print("untagged \(reference)") } - for digest in deletedDigests { + for digest in result.deletedDigests { print("deleted \(digest)") } let formatter = ByteCountFormatter() formatter.countStyle = .file - let freed = formatter.string(fromByteCount: Int64(size)) + let freed = formatter.string(fromByteCount: Int64(result.reclaimedBytes)) log.info("Reclaimed \(freed) in disk space") } - - private func hasTag(_ reference: String) -> Bool { - do { - let ref = try ContainerizationOCI.Reference.parse(reference) - return ref.tag != nil && !ref.tag!.isEmpty - } catch { - return false - } - } } } diff --git a/Sources/Services/ContainerAPIService/Client/ClientImage+Prune.swift b/Sources/Services/ContainerAPIService/Client/ClientImage+Prune.swift new file mode 100644 index 000000000..31bd4892b --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ClientImage+Prune.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOCI + +extension ClientImage { + /// Remove unused images, returning the outcome of the operation. + /// + /// When `all` is `true`, every image not referenced by a container is removed; + /// otherwise only dangling (untagged) images are removed. Orphaned blobs are + /// garbage collected afterwards and reported via ``PruneResult/deletedDigests``. + public static func prune(all: Bool) async throws -> PruneResult { + let allImages = try await list() + + let imagesToPrune: [ClientImage] + if all { + // Find all images not used by any container. + let client = ContainerClient() + let containers = try await client.list() + var imagesInUse = Set() + for container in containers { + imagesInUse.insert(container.configuration.image.reference) + } + imagesToPrune = allImages.filter { image in + !imagesInUse.contains(image.reference) + } + } else { + // Find dangling images (images with no tag). + imagesToPrune = allImages.filter { image in + !hasTag(image.reference) + } + } + + var prunedImages = [String]() + var failed = [PruneResult.Failure]() + + for image in imagesToPrune { + do { + try await delete(reference: image.reference, garbageCollect: false) + prunedImages.append(image.reference) + } catch { + failed.append(PruneResult.Failure(id: image.reference, error: error)) + } + } + + let (deletedDigests, size) = try await cleanUpOrphanedBlobs() + + return PruneResult( + pruned: prunedImages, + failed: failed, + reclaimedBytes: size, + deletedDigests: deletedDigests + ) + } + + private static func hasTag(_ reference: String) -> Bool { + do { + let ref = try ContainerizationOCI.Reference.parse(reference) + return ref.tag != nil && !ref.tag!.isEmpty + } catch { + return false + } + } +} diff --git a/Sources/Services/ContainerAPIService/Client/PruneResult.swift b/Sources/Services/ContainerAPIService/Client/PruneResult.swift index 940e661d3..99aaa6bc1 100644 --- a/Sources/Services/ContainerAPIService/Client/PruneResult.swift +++ b/Sources/Services/ContainerAPIService/Client/PruneResult.swift @@ -32,9 +32,13 @@ public struct PruneResult: Sendable { public let reclaimedBytes: UInt64 - public init(pruned: [String], failed: [Failure], reclaimedBytes: UInt64) { + /// Blob digests removed during garbage collection. Only populated by image prune. + public let deletedDigests: [String] + + public init(pruned: [String], failed: [Failure], reclaimedBytes: UInt64, deletedDigests: [String] = []) { self.pruned = pruned self.failed = failed self.reclaimedBytes = reclaimedBytes + self.deletedDigests = deletedDigests } } From 12fb9720748c3ed433f8f9b04a492f04a9c574ae Mon Sep 17 00:00:00 2001 From: ChengHao Yang <17496418+tico88612@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:45:55 +0800 Subject: [PATCH 5/5] Add container system prune Signed-off-by: ChengHao Yang <17496418+tico88612@users.noreply.github.com> --- .../System/SystemCommand.swift | 1 + .../System/SystemPrune.swift | 145 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 Sources/ContainerCommands/System/SystemPrune.swift diff --git a/Sources/ContainerCommands/System/SystemCommand.swift b/Sources/ContainerCommands/System/SystemCommand.swift index 10b77caef..756d4ac46 100644 --- a/Sources/ContainerCommands/System/SystemCommand.swift +++ b/Sources/ContainerCommands/System/SystemCommand.swift @@ -29,6 +29,7 @@ extension Application { SystemKernel.self, SystemLogs.self, SystemProperty.self, + SystemPrune.self, SystemStart.self, SystemStatus.self, SystemStop.self, diff --git a/Sources/ContainerCommands/System/SystemPrune.swift b/Sources/ContainerCommands/System/SystemPrune.swift new file mode 100644 index 000000000..436f80f7e --- /dev/null +++ b/Sources/ContainerCommands/System/SystemPrune.swift @@ -0,0 +1,145 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import ContainerAPIClient +import Foundation + +extension Application { + public struct SystemPrune: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "prune", + abstract: "Remove stopped containers, unused networks, dangling images, and unused volumes" + ) + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @Flag(name: .shortAndLong, help: "Remove all unused images, not just dangling ones") + var all: Bool = false + + @Flag(name: .long, help: "Also remove volumes not used by any container") + var volumes: Bool = false + + @Flag(name: .shortAndLong, help: "Do not prompt for confirmation") + var force: Bool = false + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + /// A machine-readable summary of everything removed by a system prune. + struct Report: Codable { + var deletedContainers: [String] + var deletedNetworks: [String] + var deletedImages: [String] + var deletedImageDigests: [String] + var deletedVolumes: [String] + var reclaimedBytes: UInt64 + } + + public func run() async throws { + guard force || confirm() else { + return + } + + // Prune in dependency order: removing stopped containers first frees the + // images, volumes, and networks they referenced so those can be reclaimed too. + let containerResult = try await ContainerClient().prune() + let networkResult = try await NetworkClient().prune() + let imageResult = try await ClientImage.prune(all: all) + let volumeResult = volumes ? try await ClientVolume.prune() : nil + + log(failures: containerResult.failed, kind: "container") + log(failures: networkResult.failed, kind: "network") + log(failures: imageResult.failed, kind: "image") + if let volumeResult { + log(failures: volumeResult.failed, kind: "volume") + } + + let report = Report( + deletedContainers: containerResult.pruned, + deletedNetworks: networkResult.pruned, + deletedImages: imageResult.pruned, + deletedImageDigests: imageResult.deletedDigests, + deletedVolumes: volumeResult?.pruned ?? [], + reclaimedBytes: containerResult.reclaimedBytes + imageResult.reclaimedBytes + + (volumeResult?.reclaimedBytes ?? 0) + ) + + try Output.render(payload: report, format: format, jsonOptions: .pretty) { + pruneSummary(report) + } + } + + /// Prompt the user before performing the destructive prune. Defaults to "no". + private func confirm() -> Bool { + print("WARNING! This will remove:") + print(" - all stopped containers") + print(" - all networks not used by at least one container") + if all { + print(" - all images without at least one container associated to them") + } else { + print(" - all dangling images") + } + if volumes { + print(" - all volumes not used by at least one container") + } + print("Are you sure you want to continue? [y/N] ", terminator: "") + + guard let answer = readLine(strippingNewline: true)?.lowercased() else { + return false + } + return answer == "y" || answer == "yes" + } + + private func log(failures: [PruneResult.Failure], kind: String) { + for failure in failures { + log.error( + "failed to prune \(kind)", + metadata: [ + "id": "\(failure.id)", + "error": "\(failure.error)", + ]) + } + } + + private func pruneSummary(_ report: Report) -> String { + var sections = [String]() + + func section(_ title: String, _ lines: [String]) { + guard !lines.isEmpty else { return } + sections.append(([title] + lines).joined(separator: "\n")) + } + + section("Deleted Containers:", report.deletedContainers) + section("Deleted Networks:", report.deletedNetworks) + section( + "Deleted Images:", + report.deletedImages.map { "untagged \($0)" } + + report.deletedImageDigests.map { "deleted \($0)" }) + section("Deleted Volumes:", report.deletedVolumes) + + let formatter = ByteCountFormatter() + formatter.countStyle = .file + let freed = formatter.string(fromByteCount: Int64(report.reclaimedBytes)) + sections.append("Total reclaimed space: \(freed)") + + return sections.joined(separator: "\n\n") + } + } +}