From 93ca12b0b224d062b22c6477b4438d504966d3b5 Mon Sep 17 00:00:00 2001 From: saehejkang Date: Sun, 7 Dec 2025 19:39:01 -0800 Subject: [PATCH 1/5] 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/5] 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") + } } From 49177e1b727785422cc2f882b477098f1720c009 Mon Sep 17 00:00:00 2001 From: saehejkang Date: Fri, 21 Nov 2025 16:14:54 -0800 Subject: [PATCH 3/5] add network prune command + tests --- .../Network/NetworkCommand.swift | 1 + .../Network/NetworkPrune.swift | 66 +++++++ .../Subcommands/Networks/TestCLINetwork.swift | 172 ++++++++++++++++++ docs/command-reference.md | 14 ++ 4 files changed, 253 insertions(+) create mode 100644 Sources/ContainerCommands/Network/NetworkPrune.swift diff --git a/Sources/ContainerCommands/Network/NetworkCommand.swift b/Sources/ContainerCommands/Network/NetworkCommand.swift index 5ef90771..574bfc36 100644 --- a/Sources/ContainerCommands/Network/NetworkCommand.swift +++ b/Sources/ContainerCommands/Network/NetworkCommand.swift @@ -26,6 +26,7 @@ extension Application { NetworkDelete.self, NetworkList.self, NetworkInspect.self, + NetworkPrune.self, ], aliases: ["n"] ) diff --git a/Sources/ContainerCommands/Network/NetworkPrune.swift b/Sources/ContainerCommands/Network/NetworkPrune.swift new file mode 100644 index 00000000..ccbf1313 --- /dev/null +++ b/Sources/ContainerCommands/Network/NetworkPrune.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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 ContainerClient +import Foundation + +extension Application.NetworkCommand { + public struct NetworkPrune: AsyncParsableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "prune", + abstract: "Remove networks with no container connections" + ) + + @OptionGroup + var global: Flags.Global + + public func run() async throws { + let allContainers = try await ClientContainer.list() + let allNetworks = try await ClientNetwork.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.id != ClientNetwork.defaultNetworkName && !networksInUse.contains(network.id) + } + + var prunedNetworks = [String]() + + for network in networksToPrune { + do { + try await ClientNetwork.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 \(network.id): \(error)") + } + } + + for name in prunedNetworks { + print(name) + } + } + } +} diff --git a/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift b/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift index 70c94c10..9203a775 100644 --- a/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift +++ b/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift @@ -22,6 +22,7 @@ import ContainerizationOS import Foundation import Testing +@Suite(.serialized) class TestCLINetwork: CLITest { private static let retries = 10 private static let retryDelaySeconds = Int64(3) @@ -34,6 +35,17 @@ class TestCLINetwork: CLITest { getTestName().lowercased() } + func doNetworkCreate(name: String) throws { + let (_, _, error, status) = try run(arguments: ["network", "create", name]) + if status != 0 { + throw CLIError.executionFailed("network create failed: \(error)") + } + } + + func doNetworkDeleteIfExists(name: String) { + let (_, _, _, _) = (try? run(arguments: ["network", "rm", name])) ?? (nil, "", "", 1) + } + @available(macOS 26, *) @Test func testNetworkCreateAndUse() async throws { do { @@ -190,4 +202,164 @@ class TestCLINetwork: CLITest { return } } + + @Test func testNetworkPruneNoNetworks() throws { + // Ensure the testnetworkcreateanduse network is deleted + // Clean up is necessary for testing prune with no networks + doNetworkDeleteIfExists(name: "testnetworkcreateanduse") + + // Prune with no networks should succeed + let (_, _, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusBefore == 0) + let (_, output, error, status) = try run(arguments: ["network", "prune"]) + if status != 0 { + throw CLIError.executionFailed("network prune failed: \(error)") + } + + #expect(output.isEmpty, "should show no networks pruned") + } + + @Test func testNetworkPruneUnusedNetworks() throws { + let name = getTestName() + let network1 = "\(name)_1" + let network2 = "\(name)_2" + + // Clean up any existing resources from previous runs + doNetworkDeleteIfExists(name: network1) + doNetworkDeleteIfExists(name: network2) + + defer { + doNetworkDeleteIfExists(name: network1) + doNetworkDeleteIfExists(name: network2) + } + + try doNetworkCreate(name: network1) + try doNetworkCreate(name: network2) + + // Verify networks are created + let (_, listBefore, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusBefore == 0) + #expect(listBefore.contains(network1)) + #expect(listBefore.contains(network2)) + + // Prune should remove both + let (_, output, error, status) = try run(arguments: ["network", "prune"]) + if status != 0 { + throw CLIError.executionFailed("network prune failed: \(error)") + } + + #expect(output.contains(network1), "should prune network1") + #expect(output.contains(network2), "should prune network2") + + // Verify networks are gone + let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusAfter == 0) + #expect(!listAfter.contains(network1), "network1 should be pruned") + #expect(!listAfter.contains(network2), "network2 should be pruned") + } + + @Test func testNetworkPruneSkipsNetworksInUse() throws { + let name = getTestName() + let containerName = "\(name)_c1" + let networkInUse = "\(name)_inuse" + let networkUnused = "\(name)_unused" + + // Clean up any existing resources from previous runs + try? doStop(name: containerName) + try? doRemove(name: containerName) + doNetworkDeleteIfExists(name: networkInUse) + doNetworkDeleteIfExists(name: networkUnused) + + defer { + try? doStop(name: containerName) + try? doRemove(name: containerName) + doNetworkDeleteIfExists(name: networkInUse) + doNetworkDeleteIfExists(name: networkUnused) + } + + try doNetworkCreate(name: networkInUse) + try doNetworkCreate(name: networkUnused) + + // Verify networks are created + let (_, listBefore, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusBefore == 0) + #expect(listBefore.contains(networkInUse)) + #expect(listBefore.contains(networkUnused)) + + // Creation of container with network connection + let port = UInt16.random(in: 50000..<60000) + try doLongRun( + name: containerName, + image: "docker.io/library/python:alpine", + args: ["--network", networkInUse], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"] + ) + try waitForContainerRunning(containerName) + let container = try inspectContainer(containerName) + #expect(container.networks.count > 0) + + // Prune should only remove the unused network + let (_, _, error, status) = try run(arguments: ["network", "prune"]) + if status != 0 { + throw CLIError.executionFailed("network prune failed: \(error)") + } + + // Verify in-use network still exists + let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusAfter == 0) + #expect(listAfter.contains(networkInUse), "network in use should NOT be pruned") + #expect(!listAfter.contains(networkUnused), "unused network should be pruned") + } + + @Test func testNetworkPruneSkipsNetworkAttachedToStoppedContainer() async throws { + let name = getTestName() + let containerName = "\(name)_c1" + let networkName = "\(name)" + + // Clean up any existing resources from previous runs + try? doStop(name: containerName) + try? doRemove(name: containerName) + doNetworkDeleteIfExists(name: networkName) + + defer { + try? doStop(name: containerName) + try? doRemove(name: containerName) + doNetworkDeleteIfExists(name: networkName) + } + + try doNetworkCreate(name: networkName) + + // Creation of container with network connection + let port = UInt16.random(in: 50000..<60000) + try doLongRun( + name: containerName, + image: "docker.io/library/python:alpine", + args: ["--network", networkName], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"] + ) + try await Task.sleep(for: .seconds(1)) + + // Prune should NOT remove the network (container exists, even if stopped) + let (_, _, error, status) = try run(arguments: ["network", "prune"]) + if status != 0 { + throw CLIError.executionFailed("network prune failed: \(error)") + } + + let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusAfter == 0) + #expect(listAfter.contains(networkName), "network attached to stopped container should NOT be pruned") + + try? doStop(name: containerName) + try? doRemove(name: containerName) + + let (_, _, error2, status2) = try run(arguments: ["network", "prune"]) + if status2 != 0 { + throw CLIError.executionFailed("network prune failed: \(error2)") + } + + // Verify network is gone + let (_, listFinal, _, statusFinal) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusFinal == 0) + #expect(!listFinal.contains(networkName), "network should be pruned after container is deleted") + } } diff --git a/docs/command-reference.md b/docs/command-reference.md index 4bb9c16c..b01b8923 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -682,6 +682,20 @@ container network delete [--all] [--debug] [ ...] * `-a, --all`: Delete all networks +### `container network prune` + +Removes networks not connected to any containers. However, default and system networks are preserved. + +**Usage** + +```bash +container network prune [--debug] +``` + +**Options** + +No options. + ### `container network list (ls)` Lists user-defined networks. From 26cfe12d391cedfc9c9df8c855ca67cc441abd6f Mon Sep 17 00:00:00 2001 From: saehejkang Date: Thu, 11 Dec 2025 17:26:44 -0800 Subject: [PATCH 4/5] move tests to parallel suite + updates --- .../Subcommands/Networks/TestCLINetwork.swift | 172 ------------------ Tests/CLITests/TestCLINoParallelCases.swift | 162 +++++++++++++++++ Tests/CLITests/Utilities/CLITest.swift | 11 ++ 3 files changed, 173 insertions(+), 172 deletions(-) diff --git a/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift b/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift index 9203a775..70c94c10 100644 --- a/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift +++ b/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift @@ -22,7 +22,6 @@ import ContainerizationOS import Foundation import Testing -@Suite(.serialized) class TestCLINetwork: CLITest { private static let retries = 10 private static let retryDelaySeconds = Int64(3) @@ -35,17 +34,6 @@ class TestCLINetwork: CLITest { getTestName().lowercased() } - func doNetworkCreate(name: String) throws { - let (_, _, error, status) = try run(arguments: ["network", "create", name]) - if status != 0 { - throw CLIError.executionFailed("network create failed: \(error)") - } - } - - func doNetworkDeleteIfExists(name: String) { - let (_, _, _, _) = (try? run(arguments: ["network", "rm", name])) ?? (nil, "", "", 1) - } - @available(macOS 26, *) @Test func testNetworkCreateAndUse() async throws { do { @@ -202,164 +190,4 @@ class TestCLINetwork: CLITest { return } } - - @Test func testNetworkPruneNoNetworks() throws { - // Ensure the testnetworkcreateanduse network is deleted - // Clean up is necessary for testing prune with no networks - doNetworkDeleteIfExists(name: "testnetworkcreateanduse") - - // Prune with no networks should succeed - let (_, _, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) - #expect(statusBefore == 0) - let (_, output, error, status) = try run(arguments: ["network", "prune"]) - if status != 0 { - throw CLIError.executionFailed("network prune failed: \(error)") - } - - #expect(output.isEmpty, "should show no networks pruned") - } - - @Test func testNetworkPruneUnusedNetworks() throws { - let name = getTestName() - let network1 = "\(name)_1" - let network2 = "\(name)_2" - - // Clean up any existing resources from previous runs - doNetworkDeleteIfExists(name: network1) - doNetworkDeleteIfExists(name: network2) - - defer { - doNetworkDeleteIfExists(name: network1) - doNetworkDeleteIfExists(name: network2) - } - - try doNetworkCreate(name: network1) - try doNetworkCreate(name: network2) - - // Verify networks are created - let (_, listBefore, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) - #expect(statusBefore == 0) - #expect(listBefore.contains(network1)) - #expect(listBefore.contains(network2)) - - // Prune should remove both - let (_, output, error, status) = try run(arguments: ["network", "prune"]) - if status != 0 { - throw CLIError.executionFailed("network prune failed: \(error)") - } - - #expect(output.contains(network1), "should prune network1") - #expect(output.contains(network2), "should prune network2") - - // Verify networks are gone - let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) - #expect(statusAfter == 0) - #expect(!listAfter.contains(network1), "network1 should be pruned") - #expect(!listAfter.contains(network2), "network2 should be pruned") - } - - @Test func testNetworkPruneSkipsNetworksInUse() throws { - let name = getTestName() - let containerName = "\(name)_c1" - let networkInUse = "\(name)_inuse" - let networkUnused = "\(name)_unused" - - // Clean up any existing resources from previous runs - try? doStop(name: containerName) - try? doRemove(name: containerName) - doNetworkDeleteIfExists(name: networkInUse) - doNetworkDeleteIfExists(name: networkUnused) - - defer { - try? doStop(name: containerName) - try? doRemove(name: containerName) - doNetworkDeleteIfExists(name: networkInUse) - doNetworkDeleteIfExists(name: networkUnused) - } - - try doNetworkCreate(name: networkInUse) - try doNetworkCreate(name: networkUnused) - - // Verify networks are created - let (_, listBefore, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) - #expect(statusBefore == 0) - #expect(listBefore.contains(networkInUse)) - #expect(listBefore.contains(networkUnused)) - - // Creation of container with network connection - let port = UInt16.random(in: 50000..<60000) - try doLongRun( - name: containerName, - image: "docker.io/library/python:alpine", - args: ["--network", networkInUse], - containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"] - ) - try waitForContainerRunning(containerName) - let container = try inspectContainer(containerName) - #expect(container.networks.count > 0) - - // Prune should only remove the unused network - let (_, _, error, status) = try run(arguments: ["network", "prune"]) - if status != 0 { - throw CLIError.executionFailed("network prune failed: \(error)") - } - - // Verify in-use network still exists - let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) - #expect(statusAfter == 0) - #expect(listAfter.contains(networkInUse), "network in use should NOT be pruned") - #expect(!listAfter.contains(networkUnused), "unused network should be pruned") - } - - @Test func testNetworkPruneSkipsNetworkAttachedToStoppedContainer() async throws { - let name = getTestName() - let containerName = "\(name)_c1" - let networkName = "\(name)" - - // Clean up any existing resources from previous runs - try? doStop(name: containerName) - try? doRemove(name: containerName) - doNetworkDeleteIfExists(name: networkName) - - defer { - try? doStop(name: containerName) - try? doRemove(name: containerName) - doNetworkDeleteIfExists(name: networkName) - } - - try doNetworkCreate(name: networkName) - - // Creation of container with network connection - let port = UInt16.random(in: 50000..<60000) - try doLongRun( - name: containerName, - image: "docker.io/library/python:alpine", - args: ["--network", networkName], - containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"] - ) - try await Task.sleep(for: .seconds(1)) - - // Prune should NOT remove the network (container exists, even if stopped) - let (_, _, error, status) = try run(arguments: ["network", "prune"]) - if status != 0 { - throw CLIError.executionFailed("network prune failed: \(error)") - } - - let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) - #expect(statusAfter == 0) - #expect(listAfter.contains(networkName), "network attached to stopped container should NOT be pruned") - - try? doStop(name: containerName) - try? doRemove(name: containerName) - - let (_, _, error2, status2) = try run(arguments: ["network", "prune"]) - if status2 != 0 { - throw CLIError.executionFailed("network prune failed: \(error2)") - } - - // Verify network is gone - let (_, listFinal, _, statusFinal) = try run(arguments: ["network", "list", "--quiet"]) - #expect(statusFinal == 0) - #expect(!listFinal.contains(networkName), "network should be pruned after container is deleted") - } } diff --git a/Tests/CLITests/TestCLINoParallelCases.swift b/Tests/CLITests/TestCLINoParallelCases.swift index b10559c0..2ce27617 100644 --- a/Tests/CLITests/TestCLINoParallelCases.swift +++ b/Tests/CLITests/TestCLINoParallelCases.swift @@ -124,4 +124,166 @@ class TestCLINoParallelCases: CLITest { let alpineStillPresent = try isImagePresent(targetImage: alpine) #expect(alpineStillPresent, "expected image \(alpine) to remain") } + + @Test func testNetworkPruneNoNetworks() throws { + // Ensure the testnetworkcreateanduse network is deleted + // Clean up is necessary for testing prune with no networks + doNetworkDeleteIfExists(name: "testnetworkcreateanduse") + + // Prune with no networks should succeed + let (_, _, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusBefore == 0) + let (_, output, error, status) = try run(arguments: ["network", "prune"]) + if status != 0 { + throw CLIError.executionFailed("network prune failed: \(error)") + } + + #expect(output.isEmpty, "should show no networks pruned") + } + + @Test func testNetworkPruneUnusedNetworks() throws { + let name = getTestName() + let network1 = "\(name)_1" + let network2 = "\(name)_2" + + // Clean up any existing resources from previous runs + doNetworkDeleteIfExists(name: network1) + doNetworkDeleteIfExists(name: network2) + + defer { + doNetworkDeleteIfExists(name: network1) + doNetworkDeleteIfExists(name: network2) + } + + try doNetworkCreate(name: network1) + try doNetworkCreate(name: network2) + + // Verify networks are created + let (_, listBefore, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusBefore == 0) + #expect(listBefore.contains(network1)) + #expect(listBefore.contains(network2)) + + // Prune should remove both + let (_, output, error, status) = try run(arguments: ["network", "prune"]) + if status != 0 { + throw CLIError.executionFailed("network prune failed: \(error)") + } + + #expect(output.contains(network1), "should prune network1") + #expect(output.contains(network2), "should prune network2") + + // Verify networks are gone + let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusAfter == 0) + #expect(!listAfter.contains(network1), "network1 should be pruned") + #expect(!listAfter.contains(network2), "network2 should be pruned") + } + + @Test(.disabled("https://github.com/apple/container/issues/953")) + func testNetworkPruneSkipsNetworksInUse() throws { + let name = getTestName() + let containerName = "\(name)_c1" + let networkInUse = "\(name)_inuse" + let networkUnused = "\(name)_unused" + + // Clean up any existing resources from previous runs + try? doStop(name: containerName) + try? doRemove(name: containerName) + doNetworkDeleteIfExists(name: networkInUse) + doNetworkDeleteIfExists(name: networkUnused) + + defer { + try? doStop(name: containerName) + try? doRemove(name: containerName) + doNetworkDeleteIfExists(name: networkInUse) + doNetworkDeleteIfExists(name: networkUnused) + } + + try doNetworkCreate(name: networkInUse) + try doNetworkCreate(name: networkUnused) + + // Verify networks are created + let (_, listBefore, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusBefore == 0) + #expect(listBefore.contains(networkInUse)) + #expect(listBefore.contains(networkUnused)) + + // Creation of container with network connection + let port = UInt16.random(in: 50000..<60000) + try doLongRun( + name: containerName, + image: "docker.io/library/python:alpine", + args: ["--network", networkInUse], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"] + ) + try waitForContainerRunning(containerName) + let container = try inspectContainer(containerName) + #expect(container.networks.count > 0) + + // Prune should only remove the unused network + let (_, _, error, status) = try run(arguments: ["network", "prune"]) + if status != 0 { + throw CLIError.executionFailed("network prune failed: \(error)") + } + + // Verify in-use network still exists + let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusAfter == 0) + #expect(listAfter.contains(networkInUse), "network in use should NOT be pruned") + #expect(!listAfter.contains(networkUnused), "unused network should be pruned") + } + + @Test(.disabled("https://github.com/apple/container/issues/953")) + func testNetworkPruneSkipsNetworkAttachedToStoppedContainer() async throws { + let name = getTestName() + let containerName = "\(name)_c1" + let networkName = "\(name)" + + // Clean up any existing resources from previous runs + try? doStop(name: containerName) + try? doRemove(name: containerName) + doNetworkDeleteIfExists(name: networkName) + + defer { + try? doStop(name: containerName) + try? doRemove(name: containerName) + doNetworkDeleteIfExists(name: networkName) + } + + try doNetworkCreate(name: networkName) + + // Creation of container with network connection + let port = UInt16.random(in: 50000..<60000) + try doLongRun( + name: containerName, + image: "docker.io/library/python:alpine", + args: ["--network", networkName], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"] + ) + try await Task.sleep(for: .seconds(1)) + + // Prune should NOT remove the network (container exists, even if stopped) + let (_, _, error, status) = try run(arguments: ["network", "prune"]) + if status != 0 { + throw CLIError.executionFailed("network prune failed: \(error)") + } + + let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusAfter == 0) + #expect(listAfter.contains(networkName), "network attached to stopped container should NOT be pruned") + + try? doStop(name: containerName) + try? doRemove(name: containerName) + + let (_, _, error2, status2) = try run(arguments: ["network", "prune"]) + if status2 != 0 { + throw CLIError.executionFailed("network prune failed: \(error2)") + } + + // Verify network is gone + let (_, listFinal, _, statusFinal) = try run(arguments: ["network", "list", "--quiet"]) + #expect(statusFinal == 0) + #expect(!listFinal.contains(networkName), "network should be pruned after container is deleted") + } } diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index 4da0afaf..47478c5c 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -545,4 +545,15 @@ class CLITest { throw CLIError.executionFailed("command failed: \(error)") } } + + func doNetworkCreate(name: String) throws { + let (_, _, error, status) = try run(arguments: ["network", "create", name]) + if status != 0 { + throw CLIError.executionFailed("network create failed: \(error)") + } + } + + func doNetworkDeleteIfExists(name: String) { + let (_, _, _, _) = (try? run(arguments: ["network", "rm", name])) ?? (nil, "", "", 1) + } } From eb3a50211ac4403b94264c5019f1bc9994804eae Mon Sep 17 00:00:00 2001 From: Saehej Kang Date: Tue, 16 Dec 2025 16:20:17 -0800 Subject: [PATCH 5/5] add Mac OS version annotations --- Tests/CLITests/TestCLINoParallelCases.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Tests/CLITests/TestCLINoParallelCases.swift b/Tests/CLITests/TestCLINoParallelCases.swift index 2ce27617..50a95d9d 100644 --- a/Tests/CLITests/TestCLINoParallelCases.swift +++ b/Tests/CLITests/TestCLINoParallelCases.swift @@ -125,6 +125,7 @@ class TestCLINoParallelCases: CLITest { #expect(alpineStillPresent, "expected image \(alpine) to remain") } + @available(macOS 26, *) @Test func testNetworkPruneNoNetworks() throws { // Ensure the testnetworkcreateanduse network is deleted // Clean up is necessary for testing prune with no networks @@ -141,6 +142,7 @@ class TestCLINoParallelCases: CLITest { #expect(output.isEmpty, "should show no networks pruned") } + @available(macOS 26, *) @Test func testNetworkPruneUnusedNetworks() throws { let name = getTestName() let network1 = "\(name)_1" @@ -180,6 +182,7 @@ class TestCLINoParallelCases: CLITest { #expect(!listAfter.contains(network2), "network2 should be pruned") } + @available(macOS 26, *) @Test(.disabled("https://github.com/apple/container/issues/953")) func testNetworkPruneSkipsNetworksInUse() throws { let name = getTestName() @@ -234,6 +237,7 @@ class TestCLINoParallelCases: CLITest { #expect(!listAfter.contains(networkUnused), "unused network should be pruned") } + @available(macOS 26, *) @Test(.disabled("https://github.com/apple/container/issues/953")) func testNetworkPruneSkipsNetworkAttachedToStoppedContainer() async throws { let name = getTestName()