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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ api-docs/
workdir/
installer/
.venv/
.claude/
.clitests/
test_results/
*.pid
Expand Down
4 changes: 3 additions & 1 deletion Sources/ContainerClient/Core/ContainerConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ public struct ContainerConfiguration: Sendable, Codable {
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)
// Parse old network IDs as simple network names without properties
let parsedNetworks = networkIds.map { Parser.ParsedNetwork(name: $0, macAddress: nil) }
networks = try Utility.getAttachmentConfigurations(containerId: id, networks: parsedNetworks)
}
} else {
networks = []
Expand Down
2 changes: 1 addition & 1 deletion Sources/ContainerClient/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ 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")
@Option(name: [.customLong("network")], help: "Attach the container to a network (format: <name>[,mac=XX:XX:XX:XX:XX:XX])")
public var networks: [String] = []

@Flag(name: [.customLong("no-dns")], help: "Do not configure DNS in the container")
Expand Down
77 changes: 77 additions & 0 deletions Sources/ContainerClient/Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,83 @@ public struct Parser {
}
}

// MARK: Networks

/// Parsed network attachment with optional properties
public struct ParsedNetwork {
public let name: String
public let macAddress: String?

public init(name: String, macAddress: String? = nil) {
self.name = name
self.macAddress = macAddress
}
}

/// Parse network attachment with optional properties
/// Format: network_name[,mac=XX:XX:XX:XX:XX:XX]
/// Example: "backend,mac=02:42:ac:11:00:02"
public static func network(_ networkSpec: String) throws -> ParsedNetwork {
guard !networkSpec.isEmpty else {
throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty")
}

let parts = networkSpec.split(separator: ",", omittingEmptySubsequences: false)

guard !parts.isEmpty else {
throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty")
}

let networkName = String(parts[0])
if networkName.isEmpty {
throw ContainerizationError(.invalidArgument, message: "network name cannot be empty")
}

var macAddress: String?

// Parse properties if any
for part in parts.dropFirst() {
let keyVal = part.split(separator: "=", maxSplits: 2, omittingEmptySubsequences: false)

let key: String
let value: String

if keyVal.count == 2 {
key = String(keyVal[0])
value = String(keyVal[1])
} else if keyVal.count == 1 {
// Property without '=' separator
throw ContainerizationError(
.invalidArgument,
message: "invalid property format '\(part)' in network specification '\(networkSpec)'"
)
} else {
throw ContainerizationError(
.invalidArgument,
message: "invalid property format '\(part)' in network specification '\(networkSpec)'"
)
}

switch key {
case "mac":
if value.isEmpty {
throw ContainerizationError(
.invalidArgument,
message: "mac address value cannot be empty"
)
}
macAddress = value
default:
throw ContainerizationError(
.invalidArgument,
message: "unknown network property '\(key)'. Available properties: mac"
)
}
}

return ParsedNetwork(name: networkName, macAddress: macAddress)
}

// MARK: DNS

public static func isValidDomainName(_ name: String) -> Bool {
Expand Down
37 changes: 30 additions & 7 deletions Sources/ContainerClient/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ public struct Utility {
}
}

public static func validMACAddress(_ macAddress: String) throws {
let pattern = #"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"#
let regex = try Regex(pattern)
if try regex.firstMatch(in: macAddress) == nil {
throw ContainerizationError(.invalidArgument, message: "invalid MAC address format \(macAddress), expected format: XX:XX:XX:XX:XX:XX")
}
}

public static func containerConfigFromFlags(
id: String,
image: String,
Expand Down Expand Up @@ -176,13 +184,15 @@ public struct Utility {

config.virtualization = management.virtualization

// Parse network specifications with properties
let parsedNetworks = try management.networks.map { try Parser.network($0) }
if management.networks.contains(ClientNetwork.noNetworkName) {
guard management.networks.count == 1 else {
throw ContainerizationError(.unsupported, message: "no other networks may be created along with network \(ClientNetwork.noNetworkName)")
}
config.networks = []
} else {
config.networks = try getAttachmentConfigurations(containerId: config.id, networkIds: management.networks)
config.networks = try getAttachmentConfigurations(containerId: config.id, networks: parsedNetworks)
for attachmentConfiguration in config.networks {
let network: NetworkState = try await ClientNetwork.get(id: attachmentConfiguration.network)
guard case .running(_, _) = network else {
Expand Down Expand Up @@ -220,7 +230,14 @@ public struct Utility {
return (config, kernel)
}

static func getAttachmentConfigurations(containerId: String, networkIds: [String]) throws -> [AttachmentConfiguration] {
static func getAttachmentConfigurations(containerId: String, networks: [Parser.ParsedNetwork]) throws -> [AttachmentConfiguration] {
// Validate MAC addresses if provided
for network in networks {
if let mac = network.macAddress {
try validMACAddress(mac)
}
}

// make an FQDN for the first interface
let fqdn: String?
if !containerId.contains(".") {
Expand All @@ -235,22 +252,28 @@ 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 networks.enumerated().map { item in
guard item.offset == 0 else {
return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: containerId))
return AttachmentConfiguration(
network: item.element.name,
options: AttachmentOptions(hostname: containerId, macAddress: item.element.macAddress)
)
}
return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: fqdn ?? containerId))
return AttachmentConfiguration(
network: item.element.name,
options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: item.element.macAddress)
)
}
}
// if no networks specified, attach to the default network
return [AttachmentConfiguration(network: ClientNetwork.defaultNetworkName, options: AttachmentOptions(hostname: fqdn ?? containerId))]
return [AttachmentConfiguration(network: ClientNetwork.defaultNetworkName, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil))]
}

private static func getKernel(management: Flags.Management) async throws -> Kernel {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ import Containerization
struct IsolatedInterfaceStrategy: InterfaceStrategy {
public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) -> Interface {
let gateway = interfaceIndex == 0 ? attachment.gateway : nil
return NATInterface(address: attachment.address, gateway: gateway)
return NATInterface(address: attachment.address, gateway: gateway, macAddress: attachment.macAddress)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,6 @@ struct NonisolatedInterfaceStrategy: InterfaceStrategy {

log.info("creating NATNetworkInterface with network reference")
let gateway = interfaceIndex == 0 ? attachment.gateway : nil
return NATNetworkInterface(address: attachment.address, gateway: gateway, reference: networkRef)
return NATNetworkInterface(address: attachment.address, gateway: gateway, reference: networkRef, macAddress: attachment.macAddress)
}
}
5 changes: 4 additions & 1 deletion Sources/Services/ContainerNetworkService/Attachment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ public struct Attachment: Codable, Sendable {
public let address: String
/// The IPv4 gateway address.
public let gateway: String
/// The MAC address associated with the attachment (optional).
public let macAddress: String?

public init(network: String, hostname: String, address: String, gateway: String) {
public init(network: String, hostname: String, address: String, gateway: String, macAddress: String? = nil) {
self.network = network
self.hostname = hostname
self.address = address
self.gateway = gateway
self.macAddress = macAddress
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ public struct AttachmentOptions: Codable, Sendable {
/// The hostname associated with the attachment.
public let hostname: String

public init(hostname: String) {
/// The MAC address associated with the attachment (optional).
public let macAddress: String?

public init(hostname: String, macAddress: String? = nil) {
self.hostname = hostname
self.macAddress = macAddress
}
}
5 changes: 4 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, macAddress: String? = nil) async throws -> (attachment: Attachment, additionalData: XPCMessage?) {
let request = XPCMessage(route: NetworkRoutes.allocate.rawValue)
request.set(key: NetworkKeys.hostname.rawValue, value: hostname)
if let macAddress = macAddress {
request.set(key: NetworkKeys.macAddress.rawValue, value: macAddress)
}

let client = createClient()

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 @@ -19,6 +19,7 @@ public enum NetworkKeys: String {
case allocatorDisabled
case attachment
case hostname
case macAddress
case network
case state
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,21 +59,24 @@ public actor NetworkService: Sendable {
}

let hostname = try message.hostname()
let macAddress = message.string(key: NetworkKeys.macAddress.rawValue)
let index = try await allocator.allocate(hostname: hostname)
let subnet = try CIDRAddress(status.address)
let ip = IPv4Address(fromValue: index)
let attachment = Attachment(
network: state.id,
hostname: hostname,
address: try CIDRAddress(ip, prefixLength: subnet.prefixLength).description,
gateway: status.gateway
gateway: status.gateway,
macAddress: macAddress
)
log?.info(
"allocated attachment",
metadata: [
"hostname": "\(hostname)",
"address": "\(attachment.address)",
"gateway": "\(attachment.gateway)",
"macAddress": "\(macAddress ?? "auto")",
])
let reply = message.reply()
try reply.setAttachment(attachment)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,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, macAddress: network.options.macAddress)
attachments.append(attachment)

let interface = try self.interfaceStrategy.toInterface(
Expand Down
17 changes: 17 additions & 0 deletions Tests/CLITests/Subcommands/Containers/TestCLICreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,21 @@ class TestCLICreateCommand: CLITest {
try doRemove(name: name)
}
}

@Test func testCreateWithMACAddress() throws {
let name = getTestName()
let expectedMAC = "02:42:ac:11:00:03"
#expect(throws: Never.self, "expected container create with MAC address to succeed") {
try doCreate(name: name, networks: ["default,mac=\(expectedMAC)"])
try doStart(name: name)
defer {
try? doStop(name: name)
try? doRemove(name: name)
}
try waitForContainerRunning(name)
let inspectResp = try inspectContainer(name)
#expect(inspectResp.networks.count > 0, "expected at least one network attachment")
#expect(inspectResp.networks[0].macAddress == expectedMAC, "expected MAC address \(expectedMAC), got \(inspectResp.networks[0].macAddress ?? "nil")")
}
}
}
43 changes: 32 additions & 11 deletions Tests/CLITests/Subcommands/Run/TestCLIRunOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,13 @@ class TestCLIRunCommand: CLITest {
}

@Test func testRunCommand() throws {
do {
let name = getTestName()
try doLongRun(name: name, args: [])
defer {
try? doStop(name: name)
}
let _ = try doExec(name: name, cmd: ["date"])
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
let name = getTestName()
try doLongRun(name: name, args: [])
defer {
try? doStop(name: name)
}
let _ = try doExec(name: name, cmd: ["date"])
try doStop(name: name)
}

@Test func testRunCommandCWD() throws {
Expand Down Expand Up @@ -527,6 +522,32 @@ class TestCLIRunCommand: CLITest {
}
}

@Test func testRunCommandMACAddress() throws {
do {
let name = getTestName()
let expectedMAC = "02:42:ac:11:00:02"
try doLongRun(name: name, args: ["--network", "default,mac=\(expectedMAC)"])
defer {
try? doStop(name: name)
}
var output = try doExec(name: name, cmd: ["ip", "addr", "show", "eth0"])
output = output.lowercased()
#expect(output.contains(expectedMAC.lowercased()), "expected MAC address \(expectedMAC) to be set, but got output: \(output)")
try doStop(name: name)
} catch {
Issue.record("failed to run container with custom MAC address \(error)")
return
}
}

@Test func testRunCommandInvalidMACAddress() throws {
let name = getTestName()
let invalidMAC = "invalid-mac"
#expect(throws: (any Error).self) {
try doLongRun(name: name, args: ["--network", "default,mac=\(invalidMAC)"])
}
}

func getDefaultDomain() throws -> String? {
let (output, err, status) = try run(arguments: ["system", "property", "get", "dns.domain"])
try #require(status == 0, "default DNS domain retrieval returned status \(status): \(err)")
Expand Down
2 changes: 1 addition & 1 deletion Tests/CLITests/Utilities/CLITest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ class CLITest {
arguments += ["-v", volume]
}

// Add networks
// Add networks (can include properties like "network,mac=XX:XX:XX:XX:XX:XX")
for network in networks {
arguments += ["--network", network]
}
Expand Down
Loading