Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 11 additions & 24 deletions Sources/ContainerCommands/Container/ContainerPrune.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,8 +16,6 @@

import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import Foundation

extension Application {
Expand All@@ -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")
Expand Down
64 changes: 13 additions & 51 deletions Sources/ContainerCommands/Image/ImagePrune.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,6 @@

import ArgumentParser
import ContainerAPIClient
import ContainerizationOCI
import Foundation

extension Application {
Expand All@@ -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<String>()
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
}
}
}
}
43 changes: 10 additions & 33 deletions Sources/ContainerCommands/Network/NetworkPrune.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<String>()
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)
}
}
Expand Down
1 change: 1 addition & 0 deletions Sources/ContainerCommands/System/SystemCommand.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,7 @@ extension Application {
SystemKernel.self,
SystemLogs.self,
SystemProperty.self,
SystemPrune.self,
SystemStart.self,
SystemStatus.self,
SystemStop.self,
Expand Down
145 changes: 145 additions & 0 deletions Sources/ContainerCommands/System/SystemPrune.swift
Original file line numberDiff line numberDiff line change
@@ -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")
}
}
}
Loading