Skip to content

Commit 3b5c253

Browse files
authored
Adds container network for macOS 26. (#243)
See discussion below for example. For multiple network interfaces in a single container we'll want to integrate against a containerization that includes apple/containerization#156. The change bumps the containerization dependency to 0.2.0 and addresses the breaking API changes. ```console % container network OVERVIEW: Manage container networks USAGE: container network <subcommand> OPTIONS: --version Show the version. -h, --help Show help information. SUBCOMMANDS: create Create a new network delete, rm Delete one or more networks list, ls List networks inspect Display information about one or more networks See 'container help network <subcommand>' for detailed help. ```
1 parent 3fcd0dd commit 3b5c253

File tree

22 files changed

+596
-64
lines changed

22 files changed

+596
-64
lines changed

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ integration: init-block
136136
@echo "Removing any existing containers"
137137
@bin/container rm --all
138138
@echo "Starting CLI integration tests"
139+
@$(SWIFT) test -c $(BUILD_CONFIGURATION) --filter TestCLINetwork
139140
@$(SWIFT) test -c $(BUILD_CONFIGURATION) --filter TestCLIRunLifecycle
140141
@$(SWIFT) test -c $(BUILD_CONFIGURATION) --filter TestCLIExecCommand
141142
@$(SWIFT) test -c $(BUILD_CONFIGURATION) --filter TestCLIRunCommand

Package.resolved

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ if let path = ProcessInfo.processInfo.environment["CONTAINERIZATION_PATH"] {
2626
scDependency = .package(path: path)
2727
scVersion = "latest"
2828
} else {
29-
scVersion = "0.1.1"
29+
scVersion = "0.2.0"
3030
scDependency = .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion))
3131
}
3232

@@ -295,10 +295,13 @@ let package = Package(
295295
.testTarget(
296296
name: "CLITests",
297297
dependencies: [
298-
.product(name: "ContainerizationOS", package: "containerization"),
298+
.product(name: "AsyncHTTPClient", package: "async-http-client"),
299299
.product(name: "Containerization", package: "containerization"),
300-
"ContainerClient",
300+
.product(name: "ContainerizationExtras", package: "containerization"),
301+
.product(name: "ContainerizationOS", package: "containerization"),
301302
"ContainerBuild",
303+
"ContainerClient",
304+
"ContainerNetworkService",
302305
],
303306
path: "Tests/CLITests"
304307
),

Sources/CLI/Application.swift

Lines changed: 53 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,8 @@ struct Application: AsyncParsableCommand {
7777
]
7878
),
7979
CommandGroup(
80-
name: "System",
81-
subcommands: [
82-
BuilderCommand.self,
83-
SystemCommand.self,
84-
]
80+
name: "Other",
81+
subcommands: Self.otherCommands()
8582
),
8683
],
8784
// Hidden command to handle plugins on unrecognized input.
@@ -112,42 +109,6 @@ struct Application: AsyncParsableCommand {
112109
return PluginLoader(pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, defaultResourcePath: statePath, log: log)
113110
}()
114111

115-
func validate() throws {
116-
// Not really a "validation", but a cheat to run this before
117-
// any of the commands do their business.
118-
let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"]
119-
if self.global.debug || debugEnvVar != nil {
120-
log.logLevel = .debug
121-
}
122-
// Ensure we're not running under Rosetta.
123-
if try isTranslated() {
124-
throw ValidationError(
125-
"""
126-
`container` is currently running under Rosetta Translation, which could be
127-
caused by your terminal application. Please ensure this is turned off.
128-
"""
129-
)
130-
}
131-
}
132-
133-
private static func restoreCursorAtExit() {
134-
let signalHandler: @convention(c) (Int32) -> Void = { signal in
135-
let exitCode = ExitCode(signal + 128)
136-
Application.exit(withError: exitCode)
137-
}
138-
// Termination by Ctrl+C.
139-
signal(SIGINT, signalHandler)
140-
// Termination using `kill`.
141-
signal(SIGTERM, signalHandler)
142-
// Normal and explicit exit.
143-
atexit {
144-
if let progressConfig = try? ProgressConfig() {
145-
let progressBar = ProgressBar(config: progressConfig)
146-
progressBar.resetCursor()
147-
}
148-
}
149-
}
150-
151112
public static func main() async throws {
152113
restoreCursorAtExit()
153114

@@ -261,6 +222,57 @@ struct Application: AsyncParsableCommand {
261222
return -1
262223
}
263224
}
225+
226+
func validate() throws {
227+
// Not really a "validation", but a cheat to run this before
228+
// any of the commands do their business.
229+
let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"]
230+
if self.global.debug || debugEnvVar != nil {
231+
log.logLevel = .debug
232+
}
233+
// Ensure we're not running under Rosetta.
234+
if try isTranslated() {
235+
throw ValidationError(
236+
"""
237+
`container` is currently running under Rosetta Translation, which could be
238+
caused by your terminal application. Please ensure this is turned off.
239+
"""
240+
)
241+
}
242+
}
243+
244+
private static func otherCommands() -> [any ParsableCommand.Type] {
245+
guard #available(macOS 26, *) else {
246+
return [
247+
BuilderCommand.self,
248+
SystemCommand.self,
249+
]
250+
}
251+
252+
return [
253+
BuilderCommand.self,
254+
NetworkCommand.self,
255+
SystemCommand.self,
256+
]
257+
}
258+
259+
private static func restoreCursorAtExit() {
260+
let signalHandler: @convention(c) (Int32) -> Void = { signal in
261+
let exitCode = ExitCode(signal + 128)
262+
Application.exit(withError: exitCode)
263+
}
264+
// Termination by Ctrl+C.
265+
signal(SIGINT, signalHandler)
266+
// Termination using `kill`.
267+
signal(SIGTERM, signalHandler)
268+
// Normal and explicit exit.
269+
atexit {
270+
if let progressConfig = try? ProgressConfig() {
271+
let progressBar = ProgressBar(config: progressConfig)
272+
progressBar.resetCursor()
273+
}
274+
}
275+
}
264276
}
265277

266278
extension Application {

Sources/CLI/Container/ContainerDelete.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ extension Application {
4545
if containerIDs.count > 0 && all {
4646
throw ContainerizationError(
4747
.invalidArgument,
48-
message: "explicitly supplied container IDs conflicts with the --all flag"
48+
message: "explicitly supplied container ID(s) conflict with the --all flag"
4949
)
5050
}
5151
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
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+
19+
extension Application {
20+
struct NetworkCommand: AsyncParsableCommand {
21+
static let configuration = CommandConfiguration(
22+
commandName: "network",
23+
abstract: "Manage container networks",
24+
subcommands: [
25+
NetworkCreate.self,
26+
NetworkDelete.self,
27+
NetworkList.self,
28+
NetworkInspect.self,
29+
],
30+
aliases: ["n"]
31+
)
32+
}
33+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
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 ContainerNetworkService
20+
import ContainerizationError
21+
import Foundation
22+
import TerminalProgress
23+
24+
extension Application {
25+
struct NetworkCreate: AsyncParsableCommand {
26+
static let configuration = CommandConfiguration(
27+
commandName: "create",
28+
abstract: "Create a new network")
29+
30+
@Argument(help: "Network name")
31+
var name: String
32+
33+
@OptionGroup
34+
var global: Flags.Global
35+
36+
func run() async throws {
37+
let config = NetworkConfiguration(id: self.name, mode: .nat)
38+
let state = try await ClientNetwork.create(configuration: config)
39+
print(state.id)
40+
}
41+
}
42+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
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 ContainerNetworkService
20+
import ContainerizationError
21+
import Foundation
22+
23+
extension Application {
24+
struct NetworkDelete: AsyncParsableCommand {
25+
static let configuration = CommandConfiguration(
26+
commandName: "delete",
27+
abstract: "Delete one or more networks",
28+
aliases: ["rm"])
29+
30+
@Flag(name: .shortAndLong, help: "Remove all networks")
31+
var all = false
32+
33+
@OptionGroup
34+
var global: Flags.Global
35+
36+
@Argument(help: "Network names")
37+
var networkNames: [String] = []
38+
39+
func validate() throws {
40+
if networkNames.count == 0 && !all {
41+
throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied")
42+
}
43+
if networkNames.count > 0 && all {
44+
throw ContainerizationError(
45+
.invalidArgument,
46+
message: "explicitly supplied network name(s) conflict with the --all flag"
47+
)
48+
}
49+
}
50+
51+
mutating func run() async throws {
52+
let uniqueNetworkNames = Set<String>(networkNames)
53+
let networks: [NetworkState]
54+
55+
if all {
56+
networks = try await ClientNetwork.list()
57+
} else {
58+
networks = try await ClientNetwork.list()
59+
.filter { c in
60+
uniqueNetworkNames.contains(c.id)
61+
}
62+
63+
// If one of the networks requested isn't present lets throw. We don't need to do
64+
// this for --all as --all should be perfectly usable with no networks to remove,
65+
// otherwise it'd be quite clunky.
66+
if networks.count != uniqueNetworkNames.count {
67+
let missing = uniqueNetworkNames.filter { id in
68+
!networks.contains { n in
69+
n.id == id
70+
}
71+
}
72+
throw ContainerizationError(
73+
.notFound,
74+
message: "failed to delete one or more networks: \(missing)"
75+
)
76+
}
77+
}
78+
79+
if uniqueNetworkNames.contains(ClientNetwork.defaultNetworkName) {
80+
throw ContainerizationError(
81+
.invalidArgument,
82+
message: "cannot delete the default network"
83+
)
84+
}
85+
86+
var failed = [String]()
87+
try await withThrowingTaskGroup(of: NetworkState?.self) { group in
88+
for network in networks {
89+
group.addTask {
90+
do {
91+
// delete atomically disables the IP allocator, then deletes
92+
// the allocator disable fails if any IPs are still in use
93+
try await ClientNetwork.delete(id: network.id)
94+
print(network.id)
95+
return nil
96+
} catch {
97+
log.error("failed to delete network \(network.id): \(error)")
98+
return network
99+
}
100+
}
101+
}
102+
103+
for try await network in group {
104+
guard let network else {
105+
continue
106+
}
107+
failed.append(network.id)
108+
}
109+
}
110+
111+
if failed.count > 0 {
112+
throw ContainerizationError(.internalError, message: "delete failed for one or more networks: \(failed)")
113+
}
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)