Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ integration: init-block
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIVolumes || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIKernelSet || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIAnonymousVolumes || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --no-parallel --filter TestCLINoParallelCases || exit_code=1 ; \
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLINoParallelCases || exit_code=1 ; \
echo Ensuring apiserver stopped after the CLI integration tests ; \
scripts/ensure-container-stopped.sh ; \
exit $${exit_code} ; \
Expand Down
42 changes: 21 additions & 21 deletions Sources/ContainerCommands/Image/ImagePrune.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,48 +35,48 @@ extension Application {
public func run() async throws {
let allImages = try await ClientImage.list()

let imagesToDelete: [ClientImage]
let imagesToPrune: [ClientImage]
if all {
// Find all images not used by any container
let containers = try await ClientContainer.list()
var imagesInUse = Set<String>()
for container in containers {
imagesInUse.insert(container.configuration.image.reference)
}
imagesToDelete = allImages.filter { image in
imagesToPrune = allImages.filter { image in
!imagesInUse.contains(image.reference)
}
} else {
// Find dangling images (images with no tag)
imagesToDelete = allImages.filter { image in
imagesToPrune = allImages.filter { image in
!hasTag(image.reference)
}
}

for image in imagesToDelete {
try await ClientImage.delete(reference: image.reference, garbageCollect: false)
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 \(image.reference): \(error)")
}
}

let (deletedDigests, size) = try await ClientImage.cleanupOrphanedBlobs()

for image in imagesToPrune {
print("untagged \(image.reference)")
}
for digest in deletedDigests {
print("deleted \(digest)")
}

let formatter = ByteCountFormatter()
formatter.countStyle = .file

if imagesToDelete.isEmpty && deletedDigests.isEmpty {
print("No images to prune")
print("Reclaimed Zero KB in disk space")
} else {
print("Deleted images:")
for image in imagesToDelete {
print("untagged: \(image.reference)")
}
for digest in deletedDigests {
print("deleted: \(digest)")
}
print()
let freed = formatter.string(fromByteCount: Int64(size))
print("Reclaimed \(freed) in disk space")
}
let freed = formatter.string(fromByteCount: Int64(size))
print("Reclaimed \(freed) in disk space")
}

private func hasTag(_ reference: String) -> Bool {
Expand Down
78 changes: 78 additions & 0 deletions Tests/CLITests/TestCLINoParallelCases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import Foundation
import Testing

/// Tests that need total control over environment to avoid conflicts.
@Suite(.serialized)
class TestCLINoParallelCases: CLITest {
private func getTestName() -> String {
Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased()
}

@Test func testImageSingleConcurrentDownload() throws {
// removing this image during parallel tests breaks stuff!
_ = try? run(arguments: ["image", "rm", alpine])
Expand All @@ -46,4 +51,77 @@ class TestCLINoParallelCases: CLITest {
return
}
}

@Test func testImagePruneNoImages() throws {
// Prune with no images should succeed
let (_, output, error, status) = try run(arguments: ["image", "prune"])
if status != 0 {
throw CLIError.executionFailed("image prune failed: \(error)")
}

#expect(output.contains("Zero KB"), "should show no space reclaimed")
}

@Test func testImagePruneUnusedImages() throws {
// 1. Pull the images
try doPull(imageName: alpine)
try doPull(imageName: busybox)

// 2. Verify the images are present
let alpinePresent = try isImagePresent(targetImage: alpine)
#expect(alpinePresent, "expected to see image \(alpine) pulled")
let busyBoxPresent = try isImagePresent(targetImage: busybox)
#expect(busyBoxPresent, "expected to see image \(busybox) pulled")

// 3. Prune with the -a flag should remove all unused images
let (_, output, error, status) = try run(arguments: ["image", "prune", "-a"])
if status != 0 {
throw CLIError.executionFailed("image prune failed: \(error)")
}
#expect(output.contains(alpine), "should prune alpine image")
#expect(output.contains(busybox), "should prune busybox image")

// 4. Verify the images are gone
let alpineRemoved = try !isImagePresent(targetImage: alpine)
#expect(alpineRemoved, "expected image \(alpine) to be removed")
let busyboxRemoved = try !isImagePresent(targetImage: busybox)
#expect(busyboxRemoved, "expected image \(busybox) to be removed")
}

@Test func testImagePruneDanglingImages() throws {
let name = getTestName()
let containerName = "\(name)_container"

// 1. Pull the images
try doPull(imageName: alpine)
try doPull(imageName: busybox)

// 2. Verify the images are present
let alpinePresent = try isImagePresent(targetImage: alpine)
#expect(alpinePresent, "expected to see image \(alpine) pulled")
let busyBoxPresent = try isImagePresent(targetImage: busybox)
#expect(busyBoxPresent, "expected to see image \(busybox) pulled")

// 3. Create a running container based on alpine
try doLongRun(
name: containerName,
image: alpine
)
try waitForContainerRunning(containerName)

// 4. Prune should only remove the dangling image
let (_, output, error, status) = try run(arguments: ["image", "prune", "-a"])
if status != 0 {
throw CLIError.executionFailed("image prune failed: \(error)")
}
#expect(output.contains(busybox), "should prune busybox image")

// 5. Verify the busybox image is gone
let busyboxRemoved = try !isImagePresent(targetImage: busybox)
#expect(busyboxRemoved, "expected image \(busybox) to be removed")

// 6. Verify the alpine image still exists
let alpineStillPresent = try isImagePresent(targetImage: alpine)
#expect(alpineStillPresent, "expected image \(alpine) to remain")
}
}