mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
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. ```
This commit is contained in:
@@ -136,6 +136,7 @@ integration: init-block
|
||||
@echo "Removing any existing containers"
|
||||
@bin/container rm --all
|
||||
@echo "Starting CLI integration tests"
|
||||
@$(SWIFT) test -c $(BUILD_CONFIGURATION) --filter TestCLINetwork
|
||||
@$(SWIFT) test -c $(BUILD_CONFIGURATION) --filter TestCLIRunLifecycle
|
||||
@$(SWIFT) test -c $(BUILD_CONFIGURATION) --filter TestCLIExecCommand
|
||||
@$(SWIFT) test -c $(BUILD_CONFIGURATION) --filter TestCLIRunCommand
|
||||
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "4ac93777a9a369fb7c46f1af4cd15c516926a8f4b23679f1ee2bc40b9a422313",
|
||||
"originHash" : "cc0718ffe17715cc940f40d92b33532b6dd1767065ccc6039f7174402ba5930f",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "async-http-client",
|
||||
@@ -15,8 +15,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/containerization.git",
|
||||
"state" : {
|
||||
"revision" : "4b05e5f2313e881ee048f7063b30e73070fbd1b1",
|
||||
"version" : "0.1.1"
|
||||
"revision" : "1a59de82b052a86edd9e2bd6f023d212a061b0b4",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -240,8 +240,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-syntax.git",
|
||||
"state" : {
|
||||
"revision" : "0687f71944021d616d34d922343dcef086855920",
|
||||
"version" : "600.0.1"
|
||||
"revision" : "fd6373fad9cf3b3bc4e3c06d92f0cfd60007fa8e",
|
||||
"version" : "602.0.0-prerelease-2025-06-26"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+6
-3
@@ -26,7 +26,7 @@ if let path = ProcessInfo.processInfo.environment["CONTAINERIZATION_PATH"] {
|
||||
scDependency = .package(path: path)
|
||||
scVersion = "latest"
|
||||
} else {
|
||||
scVersion = "0.1.1"
|
||||
scVersion = "0.2.0"
|
||||
scDependency = .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion))
|
||||
}
|
||||
|
||||
@@ -295,10 +295,13 @@ let package = Package(
|
||||
.testTarget(
|
||||
name: "CLITests",
|
||||
dependencies: [
|
||||
.product(name: "ContainerizationOS", package: "containerization"),
|
||||
.product(name: "AsyncHTTPClient", package: "async-http-client"),
|
||||
.product(name: "Containerization", package: "containerization"),
|
||||
"ContainerClient",
|
||||
.product(name: "ContainerizationExtras", package: "containerization"),
|
||||
.product(name: "ContainerizationOS", package: "containerization"),
|
||||
"ContainerBuild",
|
||||
"ContainerClient",
|
||||
"ContainerNetworkService",
|
||||
],
|
||||
path: "Tests/CLITests"
|
||||
),
|
||||
|
||||
@@ -77,11 +77,8 @@ struct Application: AsyncParsableCommand {
|
||||
]
|
||||
),
|
||||
CommandGroup(
|
||||
name: "System",
|
||||
subcommands: [
|
||||
BuilderCommand.self,
|
||||
SystemCommand.self,
|
||||
]
|
||||
name: "Other",
|
||||
subcommands: Self.otherCommands()
|
||||
),
|
||||
],
|
||||
// Hidden command to handle plugins on unrecognized input.
|
||||
@@ -112,42 +109,6 @@ struct Application: AsyncParsableCommand {
|
||||
return PluginLoader(pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, defaultResourcePath: statePath, log: log)
|
||||
}()
|
||||
|
||||
func validate() throws {
|
||||
// Not really a "validation", but a cheat to run this before
|
||||
// any of the commands do their business.
|
||||
let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"]
|
||||
if self.global.debug || debugEnvVar != nil {
|
||||
log.logLevel = .debug
|
||||
}
|
||||
// Ensure we're not running under Rosetta.
|
||||
if try isTranslated() {
|
||||
throw ValidationError(
|
||||
"""
|
||||
`container` is currently running under Rosetta Translation, which could be
|
||||
caused by your terminal application. Please ensure this is turned off.
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func restoreCursorAtExit() {
|
||||
let signalHandler: @convention(c) (Int32) -> Void = { signal in
|
||||
let exitCode = ExitCode(signal + 128)
|
||||
Application.exit(withError: exitCode)
|
||||
}
|
||||
// Termination by Ctrl+C.
|
||||
signal(SIGINT, signalHandler)
|
||||
// Termination using `kill`.
|
||||
signal(SIGTERM, signalHandler)
|
||||
// Normal and explicit exit.
|
||||
atexit {
|
||||
if let progressConfig = try? ProgressConfig() {
|
||||
let progressBar = ProgressBar(config: progressConfig)
|
||||
progressBar.resetCursor()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func main() async throws {
|
||||
restoreCursorAtExit()
|
||||
|
||||
@@ -261,6 +222,57 @@ struct Application: AsyncParsableCommand {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
func validate() throws {
|
||||
// Not really a "validation", but a cheat to run this before
|
||||
// any of the commands do their business.
|
||||
let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"]
|
||||
if self.global.debug || debugEnvVar != nil {
|
||||
log.logLevel = .debug
|
||||
}
|
||||
// Ensure we're not running under Rosetta.
|
||||
if try isTranslated() {
|
||||
throw ValidationError(
|
||||
"""
|
||||
`container` is currently running under Rosetta Translation, which could be
|
||||
caused by your terminal application. Please ensure this is turned off.
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func otherCommands() -> [any ParsableCommand.Type] {
|
||||
guard #available(macOS 26, *) else {
|
||||
return [
|
||||
BuilderCommand.self,
|
||||
SystemCommand.self,
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
BuilderCommand.self,
|
||||
NetworkCommand.self,
|
||||
SystemCommand.self,
|
||||
]
|
||||
}
|
||||
|
||||
private static func restoreCursorAtExit() {
|
||||
let signalHandler: @convention(c) (Int32) -> Void = { signal in
|
||||
let exitCode = ExitCode(signal + 128)
|
||||
Application.exit(withError: exitCode)
|
||||
}
|
||||
// Termination by Ctrl+C.
|
||||
signal(SIGINT, signalHandler)
|
||||
// Termination using `kill`.
|
||||
signal(SIGTERM, signalHandler)
|
||||
// Normal and explicit exit.
|
||||
atexit {
|
||||
if let progressConfig = try? ProgressConfig() {
|
||||
let progressBar = ProgressBar(config: progressConfig)
|
||||
progressBar.resetCursor()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Application {
|
||||
|
||||
@@ -45,7 +45,7 @@ extension Application {
|
||||
if containerIDs.count > 0 && all {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "explicitly supplied container IDs conflicts with the --all flag"
|
||||
message: "explicitly supplied container ID(s) conflict with the --all flag"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
|
||||
//
|
||||
// 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
|
||||
|
||||
extension Application {
|
||||
struct NetworkCommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "network",
|
||||
abstract: "Manage container networks",
|
||||
subcommands: [
|
||||
NetworkCreate.self,
|
||||
NetworkDelete.self,
|
||||
NetworkList.self,
|
||||
NetworkInspect.self,
|
||||
],
|
||||
aliases: ["n"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
|
||||
//
|
||||
// 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 ContainerNetworkService
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
struct NetworkCreate: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "create",
|
||||
abstract: "Create a new network")
|
||||
|
||||
@Argument(help: "Network name")
|
||||
var name: String
|
||||
|
||||
@OptionGroup
|
||||
var global: Flags.Global
|
||||
|
||||
func run() async throws {
|
||||
let config = NetworkConfiguration(id: self.name, mode: .nat)
|
||||
let state = try await ClientNetwork.create(configuration: config)
|
||||
print(state.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
|
||||
//
|
||||
// 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 ContainerNetworkService
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
struct NetworkDelete: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "delete",
|
||||
abstract: "Delete one or more networks",
|
||||
aliases: ["rm"])
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Remove all networks")
|
||||
var all = false
|
||||
|
||||
@OptionGroup
|
||||
var global: Flags.Global
|
||||
|
||||
@Argument(help: "Network names")
|
||||
var networkNames: [String] = []
|
||||
|
||||
func validate() throws {
|
||||
if networkNames.count == 0 && !all {
|
||||
throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied")
|
||||
}
|
||||
if networkNames.count > 0 && all {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "explicitly supplied network name(s) conflict with the --all flag"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
let uniqueNetworkNames = Set<String>(networkNames)
|
||||
let networks: [NetworkState]
|
||||
|
||||
if all {
|
||||
networks = try await ClientNetwork.list()
|
||||
} else {
|
||||
networks = try await ClientNetwork.list()
|
||||
.filter { c in
|
||||
uniqueNetworkNames.contains(c.id)
|
||||
}
|
||||
|
||||
// If one of the networks requested isn't present lets throw. We don't need to do
|
||||
// this for --all as --all should be perfectly usable with no networks to remove,
|
||||
// otherwise it'd be quite clunky.
|
||||
if networks.count != uniqueNetworkNames.count {
|
||||
let missing = uniqueNetworkNames.filter { id in
|
||||
!networks.contains { n in
|
||||
n.id == id
|
||||
}
|
||||
}
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "failed to delete one or more networks: \(missing)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if uniqueNetworkNames.contains(ClientNetwork.defaultNetworkName) {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "cannot delete the default network"
|
||||
)
|
||||
}
|
||||
|
||||
var failed = [String]()
|
||||
try await withThrowingTaskGroup(of: NetworkState?.self) { group in
|
||||
for network in networks {
|
||||
group.addTask {
|
||||
do {
|
||||
// delete atomically disables the IP allocator, then deletes
|
||||
// the allocator disable fails if any IPs are still in use
|
||||
try await ClientNetwork.delete(id: network.id)
|
||||
print(network.id)
|
||||
return nil
|
||||
} catch {
|
||||
log.error("failed to delete network \(network.id): \(error)")
|
||||
return network
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for try await network in group {
|
||||
guard let network else {
|
||||
continue
|
||||
}
|
||||
failed.append(network.id)
|
||||
}
|
||||
}
|
||||
|
||||
if failed.count > 0 {
|
||||
throw ContainerizationError(.internalError, message: "delete failed for one or more networks: \(failed)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
|
||||
//
|
||||
// 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 ContainerNetworkService
|
||||
import Foundation
|
||||
import SwiftProtobuf
|
||||
|
||||
extension Application {
|
||||
struct NetworkInspect: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "inspect",
|
||||
abstract: "Display information about one or more networks")
|
||||
|
||||
@OptionGroup
|
||||
var global: Flags.Global
|
||||
|
||||
@Argument(help: "Networks to inspect")
|
||||
var networks: [String]
|
||||
|
||||
func run() async throws {
|
||||
let objects: [any Codable] = try await ClientNetwork.list().filter {
|
||||
networks.contains($0.id)
|
||||
}.map {
|
||||
PrintableNetwork($0)
|
||||
}
|
||||
print(try objects.jsonArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
|
||||
//
|
||||
// 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 ContainerNetworkService
|
||||
import ContainerizationExtras
|
||||
import Foundation
|
||||
import SwiftProtobuf
|
||||
|
||||
extension Application {
|
||||
struct NetworkList: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "List networks",
|
||||
aliases: ["ls"])
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Only output the network name")
|
||||
var quiet = false
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListFormat = .table
|
||||
|
||||
@OptionGroup
|
||||
var global: Flags.Global
|
||||
|
||||
func run() async throws {
|
||||
let networks = try await ClientNetwork.list()
|
||||
try printNetworks(networks: networks, format: format)
|
||||
}
|
||||
|
||||
private func createHeader() -> [[String]] {
|
||||
[["NETWORK", "STATE", "SUBNET"]]
|
||||
}
|
||||
|
||||
private func printNetworks(networks: [NetworkState], format: ListFormat) throws {
|
||||
if format == .json {
|
||||
let printables = networks.map {
|
||||
PrintableNetwork($0)
|
||||
}
|
||||
let data = try JSONEncoder().encode(printables)
|
||||
print(String(data: data, encoding: .utf8)!)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if self.quiet {
|
||||
networks.forEach {
|
||||
print($0.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var rows = createHeader()
|
||||
for network in networks {
|
||||
rows.append(network.asRow)
|
||||
}
|
||||
|
||||
let formatter = TableOutput(rows: rows)
|
||||
print(formatter.format())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NetworkState {
|
||||
var asRow: [String] {
|
||||
switch self {
|
||||
case .created(_):
|
||||
return [self.id, self.state, "none"]
|
||||
case .running(_, let status):
|
||||
return [self.id, self.state, status.address]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PrintableNetwork: Codable {
|
||||
let id: String
|
||||
let state: String
|
||||
let config: NetworkConfiguration
|
||||
let status: NetworkStatus?
|
||||
|
||||
init(_ network: NetworkState) {
|
||||
self.id = network.id
|
||||
self.state = network.state
|
||||
switch network {
|
||||
case .created(let config):
|
||||
self.config = config
|
||||
self.status = nil
|
||||
case .running(let config, let status):
|
||||
self.config = config
|
||||
self.status = status
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ extension Application {
|
||||
try await client.ping()
|
||||
} catch let err as RegistryClient.Error {
|
||||
switch err {
|
||||
case .invalidStatus(url: _, .unauthorized), .invalidStatus(url: _, .forbidden):
|
||||
case .invalidStatus(url: _, .unauthorized, _), .invalidStatus(url: _, .forbidden, _):
|
||||
break
|
||||
default:
|
||||
throw err
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
public struct Flags {
|
||||
@@ -121,6 +122,9 @@ public struct Flags {
|
||||
})
|
||||
public var kernel: String?
|
||||
|
||||
@Option(name: [.customLong("network")], help: "Attach the container to a network")
|
||||
public var networks: [String] = []
|
||||
|
||||
@Option(name: .customLong("cidfile"), help: "Write the container ID to the path provided")
|
||||
public var cidfile = ""
|
||||
|
||||
|
||||
@@ -150,14 +150,28 @@ public struct Utility {
|
||||
mounts.append(contentsOf: volumes)
|
||||
config.mounts = mounts
|
||||
|
||||
let network = try await ClientNetwork.get(id: ClientNetwork.defaultNetworkName)
|
||||
guard case .running(_, let networkStatus) = network else {
|
||||
throw ContainerizationError(.invalidState, message: "default network is not running")
|
||||
if management.networks.isEmpty {
|
||||
config.networks = [ClientNetwork.defaultNetworkName]
|
||||
} else {
|
||||
// networks may only be specified for macOS 26+
|
||||
guard #available(macOS 26, *) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "non-default network configuration requires macOS 26 or newer")
|
||||
}
|
||||
config.networks = management.networks
|
||||
}
|
||||
|
||||
var networkStatuses: [NetworkStatus] = []
|
||||
for networkName in config.networks {
|
||||
let network: NetworkState = try await ClientNetwork.get(id: networkName)
|
||||
guard case .running(_, let networkStatus) = network else {
|
||||
throw ContainerizationError(.invalidState, message: "network \(networkName) is not running")
|
||||
}
|
||||
networkStatuses.append(networkStatus)
|
||||
}
|
||||
|
||||
let nameservers: [String]
|
||||
config.networks = [network.id]
|
||||
if management.dnsNameservers.isEmpty {
|
||||
let subnet = try CIDRAddress(networkStatus.address)
|
||||
let subnet = try CIDRAddress(networkStatuses[0].address)
|
||||
let nameserver = IPv4Address(fromValue: subnet.lower.value + 1).description
|
||||
nameservers = [nameserver]
|
||||
} else {
|
||||
|
||||
@@ -23,7 +23,8 @@ import Containerization
|
||||
/// container to container networking, but it is the only approach that
|
||||
/// works for macOS Sequoia.
|
||||
struct IsolatedInterfaceStrategy: InterfaceStrategy {
|
||||
public func toInterface(attachment: Attachment, additionalData: XPCMessage?) -> Interface {
|
||||
NATInterface(address: attachment.address, gateway: attachment.gateway)
|
||||
public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) -> Interface {
|
||||
let gateway = interfaceIndex == 0 ? attachment.gateway : nil
|
||||
return NATInterface(address: attachment.address, gateway: gateway)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ struct NonisolatedInterfaceStrategy: InterfaceStrategy {
|
||||
self.log = log
|
||||
}
|
||||
|
||||
public func toInterface(attachment: Attachment, additionalData: XPCMessage?) throws -> Interface {
|
||||
public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) throws -> Interface {
|
||||
guard let additionalData else {
|
||||
throw ContainerizationError(.invalidState, message: "network state does not contain custom network reference")
|
||||
}
|
||||
@@ -43,6 +43,7 @@ struct NonisolatedInterfaceStrategy: InterfaceStrategy {
|
||||
}
|
||||
|
||||
log.info("creating NATNetworkInterface with network reference")
|
||||
return NATNetworkInterface(address: attachment.address, gateway: attachment.gateway, reference: networkRef)
|
||||
let gateway = interfaceIndex == 0 ? attachment.gateway : nil
|
||||
return NATNetworkInterface(address: attachment.address, gateway: gateway, reference: networkRef)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ extension ImagesService {
|
||||
do {
|
||||
return try await body(authentication)
|
||||
} catch let err as RegistryClient.Error {
|
||||
guard case .invalidStatus(_, let status) = err else {
|
||||
guard case .invalidStatus(_, let status, _) = err else {
|
||||
throw err
|
||||
}
|
||||
guard status == .unauthorized || status == .forbidden else {
|
||||
|
||||
@@ -25,9 +25,10 @@ public protocol InterfaceStrategy: Sendable {
|
||||
/// - Parameters:
|
||||
/// - attachment: General attachment information that is common
|
||||
/// for all networks.
|
||||
/// - interfaceIndex: The zero-based index of the interface.
|
||||
/// - additionalData: If present, attachment information that is
|
||||
/// specific for the network to which the container will attach.
|
||||
///
|
||||
/// - Returns: An XPC message with no parameters.
|
||||
func toInterface(attachment: Attachment, additionalData: XPCMessage?) throws -> Interface
|
||||
func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) throws -> Interface
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ public actor SandboxService {
|
||||
let hostname = index == 0 ? fqdn : config.id
|
||||
let (attachment, additionalData) = try await client.allocate(hostname: hostname)
|
||||
attachments.append(attachment)
|
||||
let interface = try self.interfaceStrategy.toInterface(attachment: attachment, additionalData: additionalData)
|
||||
let interface = try self.interfaceStrategy.toInterface(attachment: attachment, interfaceIndex: index, additionalData: additionalData)
|
||||
container.interfaces.append(interface)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
|
||||
//
|
||||
// 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 AsyncHTTPClient
|
||||
import ContainerClient
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
class TestCLINetwork: CLITest {
|
||||
private static let retries = 10
|
||||
private static let retryDelaySeconds = Int64(3)
|
||||
|
||||
@available(macOS 26, *)
|
||||
@Test func testNetworkCreateAndUse() async throws {
|
||||
do {
|
||||
let name = Test.current!.name.trimmingCharacters(in: ["(", ")"])
|
||||
let networkDeleteArgs = ["network", "delete", name]
|
||||
_ = try? run(arguments: networkDeleteArgs)
|
||||
|
||||
let networkCreateArgs = ["network", "create", name]
|
||||
let result = try run(arguments: networkCreateArgs)
|
||||
if result.status != 0 {
|
||||
throw CLIError.executionFailed("command failed: \(result.error)")
|
||||
}
|
||||
defer {
|
||||
_ = try? run(arguments: networkDeleteArgs)
|
||||
}
|
||||
let port = UInt16.random(in: 50000..<60000)
|
||||
try doLongRun(
|
||||
name: name,
|
||||
image: "docker.io/library/python:latest",
|
||||
args: ["--network", name],
|
||||
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"])
|
||||
defer {
|
||||
try? doStop(name: name)
|
||||
}
|
||||
|
||||
let container = try inspectContainer(name)
|
||||
#expect(container.networks.count > 0)
|
||||
let cidrAddress = try CIDRAddress(container.networks[0].address)
|
||||
let url = "http://\(cidrAddress.address):\(port)"
|
||||
var request = HTTPClientRequest(url: url)
|
||||
request.method = .GET
|
||||
let client = getClient()
|
||||
defer { _ = client.shutdown() }
|
||||
var retriesRemaining = Self.retries
|
||||
var success = false
|
||||
while !success && retriesRemaining > 0 {
|
||||
do {
|
||||
let response = try await client.execute(request, timeout: .seconds(Self.retryDelaySeconds))
|
||||
try #require(response.status == .ok)
|
||||
success = true
|
||||
} catch {
|
||||
print("request to \(url) failed, error \(error)")
|
||||
try await Task.sleep(for: .seconds(Self.retryDelaySeconds))
|
||||
}
|
||||
retriesRemaining -= 1
|
||||
}
|
||||
#expect(success, "Request to \(url) failed after \(Self.retries - retriesRemaining) retries")
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed to run container \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func getClient() -> HTTPClient {
|
||||
var httpConfiguration = HTTPClient.Configuration()
|
||||
let proxyConfig: HTTPClient.Configuration.Proxy? = {
|
||||
let proxyEnv = ProcessInfo.processInfo.environment["HTTP_PROXY"]
|
||||
guard let proxyEnv else {
|
||||
return nil
|
||||
}
|
||||
guard let url = URL(string: proxyEnv), let host = url.host(), let port = url.port else {
|
||||
return nil
|
||||
}
|
||||
return .server(host: host, port: port)
|
||||
}()
|
||||
httpConfiguration.proxy = proxyConfig
|
||||
return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
//
|
||||
|
||||
import ContainerClient
|
||||
import ContainerNetworkService
|
||||
import Containerization
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
@@ -250,6 +251,7 @@ class CLITest {
|
||||
struct inspectOutput: Codable {
|
||||
let status: String
|
||||
let configuration: ContainerConfiguration
|
||||
let networks: [ContainerNetworkService.Attachment]
|
||||
}
|
||||
|
||||
func getContainerStatus(_ name: String) throws -> String {
|
||||
|
||||
+49
-1
@@ -145,12 +145,60 @@ Use the `list` command with the `--format` option to display information for all
|
||||
]
|
||||
</pre>
|
||||
|
||||
## Create and use a separate isolated network
|
||||
|
||||
> [!NOTE]
|
||||
> This feature is available on macOS 26 and later.
|
||||
|
||||
Running `container system start` creates a vmnet network named `default` to which your containers will attach unless you specify otherwise.
|
||||
|
||||
You can create a separate isolated network using `container network create`.
|
||||
|
||||
This command creates a network named `foo`:
|
||||
|
||||
```bash
|
||||
container network create foo
|
||||
```
|
||||
|
||||
The `foo` network, the default network, and any other networks you create are isolated from one another. A container on one network has no connectivity to containers on other networks.
|
||||
|
||||
Run `container network list` to see the networks that exist:
|
||||
|
||||
```console
|
||||
% container network list
|
||||
NETWORK STATE SUBNET
|
||||
default running 192.168.64.0/24
|
||||
foo running 192.168.65.0/24
|
||||
%
|
||||
```
|
||||
|
||||
Run a container that is attached to that network using the `--network` flag:
|
||||
|
||||
```console
|
||||
container run -d --name my-web-server --network foo --rm web-test
|
||||
```
|
||||
|
||||
Use `container ls` to see that the container is on the `foo` subnet:
|
||||
|
||||
```console
|
||||
% container ls
|
||||
ID IMAGE OS ARCH STATE ADDR
|
||||
my-web-server web-test:latest linux arm64 running 192.168.65.2
|
||||
```
|
||||
|
||||
You can delete networks that you create once no containers are attached:
|
||||
|
||||
```bash
|
||||
container stop my-web-server
|
||||
container network delete foo
|
||||
```
|
||||
|
||||
## View container logs
|
||||
|
||||
The `container logs` command displays the output from your containerized application:
|
||||
|
||||
<pre>
|
||||
% container run -d --dns-domain test --name my-web-server --rm registry.example.com/fido/web-test:latest
|
||||
% container run -d --name my-web-server --rm registry.example.com/fido/web-test:latest
|
||||
my-web-server
|
||||
% curl http://my-web-server.test
|
||||
<!DOCTYPE html><html><head><title>Hello</title></head><body><h1>Hello, world!</h1></body></html>
|
||||
|
||||
@@ -73,6 +73,10 @@ Currently, memory pages freed to the Linux operating system by processes running
|
||||
|
||||
The vmnet framework in macOS 15 can only provide networks where the attached containers are isolated from one another. Container-to-container communication over the virtual network is not possible.
|
||||
|
||||
#### Multiple networks
|
||||
|
||||
In macOS 15, all containers attach to the default vmnet network. The `container network` commands are not available on macOS 15, and using the `--network` option for `container run` or `container create` will result in an error.
|
||||
|
||||
#### Container IP addresses
|
||||
|
||||
In macOS 15, limitations in the vmnet framework mean that the container network can only be created when the first container starts. Since the network XPC helper provides IP addresses to containers, and the helper has to start before the first container, it is possible for the network helper and vmnet to disagree on the subnet address, resulting in containers that are completely cut off from the network.
|
||||
|
||||
Reference in New Issue
Block a user