Skip to content
Open
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
1 change: 1 addition & 0 deletions Sources/ContainerCommands/Network/NetworkCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ extension Application {
NetworkDelete.self,
NetworkList.self,
NetworkInspect.self,
NetworkPrune.self,
],
aliases: ["n"]
)
Expand Down
67 changes: 67 additions & 0 deletions Sources/ContainerCommands/Network/NetworkPrune.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//===----------------------------------------------------------------------===//
// 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<String>()
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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I purposefully left the check for if a network is running because the issue did not call for it. Furthermore, if the network is not being used by any containers, I feel should it not be pruned, no matter the state?

Copy link
Contributor

@jglogan jglogan Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Furthermore, if the network is not being used by any containers, I feel should it not be pruned, no matter the state?

I'm not sure I understand this. If the network is not used by any containers, and it's not the default network, we should prune it, correct?

The code seems correct. I'm just confused about the reference to state. At present the network state model is simple - in the steady state it's running, or it doesn't exist.

If you're referring to container state, then yes, container state shouldn't matter. If the container exists (running or not) and references network foo, then network foo should not be pruned.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the network is not used by any containers, and it's not the default network, we should prune it, correct?

Correct

If you're referring to container state, then yes, container state shouldn't matter. If the container exists (running or not) and references network foo, then network foo should not be pruned.

I may have gotten the states mixed up 😅

}

var prunedNetworks = [String]()

for network in networksToPrune {
do {
try await ClientNetwork.delete(id: network.id)
prunedNetworks.append(network.id)
log.info("Pruned network", metadata: ["name": "\(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)
}
}
}
}
172 changes: 172 additions & 0 deletions Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
}
}
14 changes: 14 additions & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,20 @@ container network delete [--all] [--debug] [<network-names> ...]

* `-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.
Expand Down
Loading