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/TestCLINoParallelCases.swift b/Tests/CLITests/TestCLINoParallelCases.swift index b10559c0..50a95d9d 100644 --- a/Tests/CLITests/TestCLINoParallelCases.swift +++ b/Tests/CLITests/TestCLINoParallelCases.swift @@ -124,4 +124,170 @@ class TestCLINoParallelCases: CLITest { let alpineStillPresent = try isImagePresent(targetImage: alpine) #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 + 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") + } + + @available(macOS 26, *) + @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") + } + + @available(macOS 26, *) + @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") + } + + @available(macOS 26, *) + @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) + } } 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.