Skip to content
Merged
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
66 changes: 66 additions & 0 deletions Sources/ContainerCommands/Network/NetworkPrune.swift
Original file line number Diff line number Diff line change
@@ -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<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)
}

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)
}
}
}
}
166 changes: 166 additions & 0 deletions Tests/CLITests/TestCLINoParallelCases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
11 changes: 11 additions & 0 deletions Tests/CLITests/Utilities/CLITest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
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