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/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/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") + } }