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
4 changes: 2 additions & 2 deletions Sources/ContainerClient/Core/ContainerConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ public struct ContainerConfiguration: Sendable, Codable {
do {
networks = try container.decode([AttachmentConfiguration].self, forKey: .networks)
} catch {
let networkIds = try container.decode([String].self, forKey: .networks)
networks = try Utility.getAttachmentConfigurations(containerId: id, networkIds: networkIds)
let networkArgs = try container.decode([Flags.NetworkArg].self, forKey: .networks)
networks = try Utility.getAttachmentConfigurations(containerId: id, networks: networkArgs)
}
} else {
networks = []
Expand Down
30 changes: 28 additions & 2 deletions Sources/ContainerClient/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,32 @@ import ContainerizationError
import Foundation

public struct Flags {
public struct NetworkArg: ExpressibleByArgument, Decodable {
var networkId: String
var ip: String?
var invalidArgs: [String] = []

public init?(argument: String) {
let networkParts = argument.split(separator: ":", maxSplits: 1)
self.networkId = String(networkParts[0])
if networkParts.count == 2 {
let args = networkParts[1].split(separator: ",")
for arg in args {
let parts = arg.split(separator: "=", maxSplits: 1)
guard parts.count == 2 else {
self.invalidArgs.append(String(arg))
continue
}
if parts[0] == "ip" {
self.ip = String(parts[1])
} else {
self.invalidArgs.append(String(arg))
}
}
}
}
}

public struct Global: ParsableArguments {
public init() {}

Expand Down Expand Up @@ -151,8 +177,8 @@ public struct Flags {
@Option(name: .long, help: "Use the specified name as the container ID")
public var name: String?

@Option(name: [.customLong("network")], help: "Attach the container to a network")
public var networks: [String] = []
@Option(name: [.customLong("network")], help: "Attach the container to a network (format: network_id[:ip=<>])")
public var networks: [NetworkArg] = []

@Flag(name: [.customLong("no-dns")], help: "Do not configure DNS in the container")
public var dnsDisabled = false
Expand Down
16 changes: 10 additions & 6 deletions Sources/ContainerClient/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ public struct Utility {

config.virtualization = management.virtualization

config.networks = try getAttachmentConfigurations(containerId: config.id, networkIds: management.networks)
config.networks = try getAttachmentConfigurations(containerId: config.id, networks: management.networks)
for attachmentConfiguration in config.networks {
let network: NetworkState = try await ClientNetwork.get(id: attachmentConfiguration.network)
guard case .running(_, _) = network else {
Expand Down Expand Up @@ -217,7 +217,7 @@ public struct Utility {
return (config, kernel)
}

static func getAttachmentConfigurations(containerId: String, networkIds: [String]) throws -> [AttachmentConfiguration] {
static func getAttachmentConfigurations(containerId: String, networks: [Flags.NetworkArg]) throws -> [AttachmentConfiguration] {
// make an FQDN for the first interface
let fqdn: String?
if !containerId.contains(".") {
Expand All @@ -232,18 +232,22 @@ public struct Utility {
fqdn = "\(containerId)."
}

guard networkIds.isEmpty else {
guard networks.isEmpty 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")
}

// attach the first network using the fqdn, and the rest using just the container ID
return networkIds.enumerated().map { item in
return try networks.enumerated().map { item in
guard item.element.invalidArgs.isEmpty else {
throw ContainerizationError(
.invalidArgument, message: "invalid network arguments \(item.element.networkId): \(item.element.invalidArgs.joined(separator: ", "))")
}
guard item.offset == 0 else {
return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: containerId))
return AttachmentConfiguration(network: item.element.networkId, options: AttachmentOptions(hostname: containerId, ip: item.element.ip))
}
return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: fqdn ?? containerId))
return AttachmentConfiguration(network: item.element.networkId, options: AttachmentOptions(hostname: fqdn ?? containerId, ip: item.element.ip))
}
}
// if no networks specified, attach to the default network
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,18 @@ actor AttachmentAllocator {
}

/// Allocate a network address for a host.
func allocate(hostname: String) async throws -> UInt32 {
func allocate(hostname: String, staticIndex: UInt32? = nil) async throws -> UInt32 {
// Client is responsible for ensuring two containers don't use same hostname, so provide existing IP if hostname exists
if let index = hostnames[hostname] {
return index
}

if let staticIndex {
try allocator.reserve(staticIndex)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

When trying to reserve gateway ip 192.168.64.1:

Error: internalError: "failed to bootstrap container" (cause: "internalError: "failed to bootstrap container 66336145-44ba-4e26-8954-39eecd7f73fa (cause: "unknown: "cannot create index using address 3232251905"")"")

When trying to reserve already allocated ip 192.168.64.12:

Error: internalError: "failed to bootstrap container" (cause: "internalError: "failed to bootstrap container b7bd2b0f-00db-4cd3-8970-fd55864d6089 (cause: "unknown: "cannot choose already-allocated address 3232251916"")"")

hostnames[hostname] = staticIndex
return staticIndex
}

let index = try allocator.allocate()
hostnames[hostname] = index

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ public struct AttachmentOptions: Codable, Sendable {
/// The hostname associated with the attachment.
public let hostname: String

public init(hostname: String) {
public let ip: String?

public init(hostname: String, ip: String? = nil) {
self.hostname = hostname
self.ip = ip
}
}
13 changes: 12 additions & 1 deletion Sources/Services/ContainerNetworkService/NetworkClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,12 @@ extension NetworkClient {
return state
}

public func allocate(hostname: String) async throws -> (attachment: Attachment, additionalData: XPCMessage?) {
public func allocate(hostname: String, ip: String?) async throws -> (attachment: Attachment, additionalData: XPCMessage?) {
let request = XPCMessage(route: NetworkRoutes.allocate.rawValue)
request.set(key: NetworkKeys.hostname.rawValue, value: hostname)
if let ip {
request.set(key: NetworkKeys.ip.rawValue, value: ip)
}

let client = createClient()

Expand Down Expand Up @@ -120,6 +123,14 @@ extension XPCMessage {
return hostname
}

func ip() throws -> String? {
let ip = self.string(key: NetworkKeys.ip.rawValue)
guard let ip else {
return nil
}
return ip
}

func state() throws -> NetworkState {
let data = self.dataNoCopy(key: NetworkKeys.state.rawValue)
guard let data else {
Expand Down
1 change: 1 addition & 0 deletions Sources/Services/ContainerNetworkService/NetworkKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ public enum NetworkKeys: String {
case hostname
case network
case state
case ip
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ public actor NetworkService: Sendable {
}

let hostname = try message.hostname()
let index = try await allocator.allocate(hostname: hostname)
var index: UInt32
if let staticIpString = try message.ip() {
let staticIp = try IPv4Address(staticIpString)
index = try await allocator.allocate(hostname: hostname, staticIndex: staticIp.value)
} else {
index = try await allocator.allocate(hostname: hostname)
}
let subnet = try CIDRAddress(status.address)
let ip = IPv4Address(fromValue: index)
let attachment = Attachment(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ public actor SandboxService {
for index in 0..<config.networks.count {
let network = config.networks[index]
let client = NetworkClient(id: network.network)
let (attachment, additionalData) = try await client.allocate(hostname: network.options.hostname)
let (attachment, additionalData) = try await client.allocate(hostname: network.options.hostname, ip: network.options.ip)
attachments.append(attachment)

let interface = try self.interfaceStrategy.toInterface(
Expand Down
46 changes: 46 additions & 0 deletions Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,52 @@ class TestCLINetwork: CLITest {
}
}

@available(macOS 26, *)
@Test func testNetworkStaticIp() async throws {
do {
let name = getLowercasedTestName()
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)")
}

let networkInspectOutput = try inspectNetwork(name)
guard let networkInspectStatus = networkInspectOutput.status else {
throw CLIError.invalidOutput("network inspect output invalid, missing status")
}
let network = try CIDRAddress(networkInspectStatus.address)
var addressBytes = network.address.networkBytes
addressBytes[3] = UInt8.random(in: 10...100)
let staticIp = try IPv4Address(fromNetworkBytes: addressBytes).description
Comment on lines +104 to +111
Copy link
Contributor Author

@siikamiika siikamiika Oct 12, 2025

Choose a reason for hiding this comment

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

Getting a static ip for testing is a bit difficult without a static subnet (#737) but should work


defer {
_ = try? run(arguments: networkDeleteArgs)
}
try doLongRun(
name: name,
image: "docker.io/library/alpine",
args: ["--network", "\(name):ip=\(staticIp)"],
containerArgs: ["sleep", "infinity"])
defer {
try? doStop(name: name)
}

let container = try inspectContainer(name)
#expect(container.networks.count > 0)
let cidrAddress = try CIDRAddress(container.networks[0].address)
#expect(cidrAddress.address.description == staticIp, "expected static ip \(staticIp), got \(cidrAddress.address.description)")

try doStop(name: name)
} catch {
Issue.record("failed to use a static IP in network \(error)")
return
}
}

@available(macOS 26, *)
@Test func testNetworkDeleteWithContainer() async throws {
do {
Expand Down
31 changes: 31 additions & 0 deletions Tests/CLITests/Utilities/CLITest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ class CLITest {
case invalidInput(String)
case invalidOutput(String)
case containerNotFound(String)
case networkNotFound(String)
case containerRunFailed(String)
case binaryNotFound
case binaryAttributesNotFound(Error)
Expand Down Expand Up @@ -353,6 +354,36 @@ class CLITest {
return io[0].name
}

func inspectNetwork(_ name: String) throws -> NetworkInspectOutput {
let response = try run(arguments: [
"network",
"inspect",
name,
])
let cmdStatus = response.status
guard cmdStatus == 0 else {
throw CLIError.executionFailed("network inspect failed: exit \(cmdStatus)")
}

let output = response.output
guard let jsonData = output.data(using: .utf8) else {
throw CLIError.invalidOutput("network inspect output invalid")
}

let decoder = JSONDecoder()

typealias inspectOutputs = [NetworkInspectOutput]

let io = try decoder.decode(inspectOutputs.self, from: jsonData)
guard io.count > 0 else {
throw CLIError.networkNotFound(name)
}
guard io.count == 1 else {
throw CLIError.invalidOutput("network inspect output invalid, multiple networks with same name")
}
return io[0]
}

func doPull(imageName: String, args: [String]? = nil) throws {
var pullArgs = [
"image",
Expand Down
2 changes: 1 addition & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ container run [OPTIONS] IMAGE [COMMAND] [ARG...]
* `-l, --label <label>`: Add a key=value label to the container
* `--mount <mount>`: Add a mount to the container (format: type=<>,source=<>,target=<>,readonly)
* `--name <name>`: Use the specified name as the container ID
* `--network <network>`: Attach the container to a network
* `--network <network>`: Attach the container to a network (format: network_id[:ip=<>])
* `--no-dns`: Do not configure DNS in the container
* `--os <os>`: Set OS if image can target multiple operating systems (default: linux)
* `-p, --publish <spec>`: Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol])
Expand Down