From 93ca12b0b224d062b22c6477b4438d504966d3b5 Mon Sep 17 00:00:00 2001 From: saehejkang Date: Sun, 7 Dec 2025 19:39:01 -0800 Subject: [PATCH 1/2] refactor image prune --- .../ContainerCommands/Image/ImagePrune.swift | 42 +++++----- .../Images/TestCLIImagesCommand.swift | 78 +++++++++++++++++++ 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/Sources/ContainerCommands/Image/ImagePrune.swift b/Sources/ContainerCommands/Image/ImagePrune.swift index aa2ee5de..a0de4caf 100644 --- a/Sources/ContainerCommands/Image/ImagePrune.swift +++ b/Sources/ContainerCommands/Image/ImagePrune.swift @@ -35,7 +35,7 @@ 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() @@ -43,40 +43,40 @@ extension Application { 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 { diff --git a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift index 5bcb4fbc..4cfb08b8 100644 --- a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift +++ b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift @@ -19,7 +19,12 @@ import ContainerizationOCI import Foundation import Testing +@Suite(.serialized) class TestCLIImagesCommand: CLITest { + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + @Test func testPull() throws { do { try doPull(imageName: alpine) @@ -370,4 +375,77 @@ class TestCLIImagesCommand: 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") + } } From 3efae51d6d15a4754a5381dfa1b3829dc44b187e Mon Sep 17 00:00:00 2001 From: saehejkang Date: Thu, 11 Dec 2025 17:04:32 -0800 Subject: [PATCH 2/2] move tests + update makefile --- Makefile | 2 +- .../Images/TestCLIImagesCommand.swift | 78 ------------------- Tests/CLITests/TestCLINoParallelCases.swift | 78 +++++++++++++++++++ 3 files changed, 79 insertions(+), 79 deletions(-) diff --git a/Makefile b/Makefile index 5cc37b63..27ddd583 100644 --- a/Makefile +++ b/Makefile @@ -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} ; \ diff --git a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift index 4cfb08b8..5bcb4fbc 100644 --- a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift +++ b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift @@ -19,12 +19,7 @@ import ContainerizationOCI import Foundation import Testing -@Suite(.serialized) class TestCLIImagesCommand: CLITest { - private func getTestName() -> String { - Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() - } - @Test func testPull() throws { do { try doPull(imageName: alpine) @@ -375,77 +370,4 @@ class TestCLIImagesCommand: 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") - } } diff --git a/Tests/CLITests/TestCLINoParallelCases.swift b/Tests/CLITests/TestCLINoParallelCases.swift index 24472d5d..b10559c0 100644 --- a/Tests/CLITests/TestCLINoParallelCases.swift +++ b/Tests/CLITests/TestCLINoParallelCases.swift @@ -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]) @@ -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") + } }