Skip to content

Commit 5de8f01

Browse files
committed
[networks]: add prune command (apple#914)
- Closes apple#893
1 parent 8eb8cca commit 5de8f01

File tree

5 files changed

+258
-0
lines changed

5 files changed

+258
-0
lines changed

Sources/ContainerCommands/Network/NetworkCommand.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ extension Application {
2626
NetworkDelete.self,
2727
NetworkList.self,
2828
NetworkInspect.self,
29+
NetworkPrune.self,
2930
],
3031
aliases: ["n"]
3132
)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import ArgumentParser
18+
import ContainerClient
19+
import Foundation
20+
21+
extension Application.NetworkCommand {
22+
public struct NetworkPrune: AsyncParsableCommand {
23+
public init() {}
24+
public static let configuration = CommandConfiguration(
25+
commandName: "prune",
26+
abstract: "Remove networks with no container connections"
27+
)
28+
29+
@OptionGroup
30+
var global: Flags.Global
31+
32+
public func run() async throws {
33+
let allContainers = try await ClientContainer.list()
34+
let allNetworks = try await ClientNetwork.list()
35+
36+
var networksInUse = Set<String>()
37+
for container in allContainers {
38+
for network in container.configuration.networks {
39+
networksInUse.insert(network.network)
40+
}
41+
}
42+
43+
let networksToPrune = allNetworks.filter { network in
44+
network.id != ClientNetwork.defaultNetworkName && !networksInUse.contains(network.id)
45+
}
46+
47+
var prunedNetworks = [String]()
48+
49+
for network in networksToPrune {
50+
do {
51+
try await ClientNetwork.delete(id: network.id)
52+
prunedNetworks.append(network.id)
53+
} catch {
54+
// Note: This failure may occur due to a race condition between the network/
55+
// container collection above and a container run command that attaches to a
56+
// network listed in the networksToPrune collection.
57+
log.error("Failed to prune network \(network.id): \(error)")
58+
}
59+
}
60+
61+
for name in prunedNetworks {
62+
print(name)
63+
}
64+
}
65+
}
66+
}

Tests/CLITests/TestCLINoParallelCases.swift

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,170 @@ class TestCLINoParallelCases: CLITest {
124124
let alpineStillPresent = try isImagePresent(targetImage: alpine)
125125
#expect(alpineStillPresent, "expected image \(alpine) to remain")
126126
}
127+
128+
@available(macOS 26, *)
129+
@Test func testNetworkPruneNoNetworks() throws {
130+
// Ensure the testnetworkcreateanduse network is deleted
131+
// Clean up is necessary for testing prune with no networks
132+
doNetworkDeleteIfExists(name: "testnetworkcreateanduse")
133+
134+
// Prune with no networks should succeed
135+
let (_, _, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"])
136+
#expect(statusBefore == 0)
137+
let (_, output, error, status) = try run(arguments: ["network", "prune"])
138+
if status != 0 {
139+
throw CLIError.executionFailed("network prune failed: \(error)")
140+
}
141+
142+
#expect(output.isEmpty, "should show no networks pruned")
143+
}
144+
145+
@available(macOS 26, *)
146+
@Test func testNetworkPruneUnusedNetworks() throws {
147+
let name = getTestName()
148+
let network1 = "\(name)_1"
149+
let network2 = "\(name)_2"
150+
151+
// Clean up any existing resources from previous runs
152+
doNetworkDeleteIfExists(name: network1)
153+
doNetworkDeleteIfExists(name: network2)
154+
155+
defer {
156+
doNetworkDeleteIfExists(name: network1)
157+
doNetworkDeleteIfExists(name: network2)
158+
}
159+
160+
try doNetworkCreate(name: network1)
161+
try doNetworkCreate(name: network2)
162+
163+
// Verify networks are created
164+
let (_, listBefore, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"])
165+
#expect(statusBefore == 0)
166+
#expect(listBefore.contains(network1))
167+
#expect(listBefore.contains(network2))
168+
169+
// Prune should remove both
170+
let (_, output, error, status) = try run(arguments: ["network", "prune"])
171+
if status != 0 {
172+
throw CLIError.executionFailed("network prune failed: \(error)")
173+
}
174+
175+
#expect(output.contains(network1), "should prune network1")
176+
#expect(output.contains(network2), "should prune network2")
177+
178+
// Verify networks are gone
179+
let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"])
180+
#expect(statusAfter == 0)
181+
#expect(!listAfter.contains(network1), "network1 should be pruned")
182+
#expect(!listAfter.contains(network2), "network2 should be pruned")
183+
}
184+
185+
@available(macOS 26, *)
186+
@Test(.disabled("https://github.com/apple/container/issues/953"))
187+
func testNetworkPruneSkipsNetworksInUse() throws {
188+
let name = getTestName()
189+
let containerName = "\(name)_c1"
190+
let networkInUse = "\(name)_inuse"
191+
let networkUnused = "\(name)_unused"
192+
193+
// Clean up any existing resources from previous runs
194+
try? doStop(name: containerName)
195+
try? doRemove(name: containerName)
196+
doNetworkDeleteIfExists(name: networkInUse)
197+
doNetworkDeleteIfExists(name: networkUnused)
198+
199+
defer {
200+
try? doStop(name: containerName)
201+
try? doRemove(name: containerName)
202+
doNetworkDeleteIfExists(name: networkInUse)
203+
doNetworkDeleteIfExists(name: networkUnused)
204+
}
205+
206+
try doNetworkCreate(name: networkInUse)
207+
try doNetworkCreate(name: networkUnused)
208+
209+
// Verify networks are created
210+
let (_, listBefore, _, statusBefore) = try run(arguments: ["network", "list", "--quiet"])
211+
#expect(statusBefore == 0)
212+
#expect(listBefore.contains(networkInUse))
213+
#expect(listBefore.contains(networkUnused))
214+
215+
// Creation of container with network connection
216+
let port = UInt16.random(in: 50000..<60000)
217+
try doLongRun(
218+
name: containerName,
219+
image: "docker.io/library/python:alpine",
220+
args: ["--network", networkInUse],
221+
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"]
222+
)
223+
try waitForContainerRunning(containerName)
224+
let container = try inspectContainer(containerName)
225+
#expect(container.networks.count > 0)
226+
227+
// Prune should only remove the unused network
228+
let (_, _, error, status) = try run(arguments: ["network", "prune"])
229+
if status != 0 {
230+
throw CLIError.executionFailed("network prune failed: \(error)")
231+
}
232+
233+
// Verify in-use network still exists
234+
let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"])
235+
#expect(statusAfter == 0)
236+
#expect(listAfter.contains(networkInUse), "network in use should NOT be pruned")
237+
#expect(!listAfter.contains(networkUnused), "unused network should be pruned")
238+
}
239+
240+
@available(macOS 26, *)
241+
@Test(.disabled("https://github.com/apple/container/issues/953"))
242+
func testNetworkPruneSkipsNetworkAttachedToStoppedContainer() async throws {
243+
let name = getTestName()
244+
let containerName = "\(name)_c1"
245+
let networkName = "\(name)"
246+
247+
// Clean up any existing resources from previous runs
248+
try? doStop(name: containerName)
249+
try? doRemove(name: containerName)
250+
doNetworkDeleteIfExists(name: networkName)
251+
252+
defer {
253+
try? doStop(name: containerName)
254+
try? doRemove(name: containerName)
255+
doNetworkDeleteIfExists(name: networkName)
256+
}
257+
258+
try doNetworkCreate(name: networkName)
259+
260+
// Creation of container with network connection
261+
let port = UInt16.random(in: 50000..<60000)
262+
try doLongRun(
263+
name: containerName,
264+
image: "docker.io/library/python:alpine",
265+
args: ["--network", networkName],
266+
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"]
267+
)
268+
try await Task.sleep(for: .seconds(1))
269+
270+
// Prune should NOT remove the network (container exists, even if stopped)
271+
let (_, _, error, status) = try run(arguments: ["network", "prune"])
272+
if status != 0 {
273+
throw CLIError.executionFailed("network prune failed: \(error)")
274+
}
275+
276+
let (_, listAfter, _, statusAfter) = try run(arguments: ["network", "list", "--quiet"])
277+
#expect(statusAfter == 0)
278+
#expect(listAfter.contains(networkName), "network attached to stopped container should NOT be pruned")
279+
280+
try? doStop(name: containerName)
281+
try? doRemove(name: containerName)
282+
283+
let (_, _, error2, status2) = try run(arguments: ["network", "prune"])
284+
if status2 != 0 {
285+
throw CLIError.executionFailed("network prune failed: \(error2)")
286+
}
287+
288+
// Verify network is gone
289+
let (_, listFinal, _, statusFinal) = try run(arguments: ["network", "list", "--quiet"])
290+
#expect(statusFinal == 0)
291+
#expect(!listFinal.contains(networkName), "network should be pruned after container is deleted")
292+
}
127293
}

Tests/CLITests/Utilities/CLITest.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,4 +545,15 @@ class CLITest {
545545
throw CLIError.executionFailed("command failed: \(error)")
546546
}
547547
}
548+
549+
func doNetworkCreate(name: String) throws {
550+
let (_, _, error, status) = try run(arguments: ["network", "create", name])
551+
if status != 0 {
552+
throw CLIError.executionFailed("network create failed: \(error)")
553+
}
554+
}
555+
556+
func doNetworkDeleteIfExists(name: String) {
557+
let (_, _, _, _) = (try? run(arguments: ["network", "rm", name])) ?? (nil, "", "", 1)
558+
}
548559
}

docs/command-reference.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,20 @@ container network delete [--all] [--debug] [<network-names> ...]
682682

683683
* `-a, --all`: Delete all networks
684684

685+
### `container network prune`
686+
687+
Removes networks not connected to any containers. However, default and system networks are preserved.
688+
689+
**Usage**
690+
691+
```bash
692+
container network prune [--debug]
693+
```
694+
695+
**Options**
696+
697+
No options.
698+
685699
### `container network list (ls)`
686700

687701
Lists user-defined networks.

0 commit comments

Comments
 (0)