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
2 changes: 1 addition & 1 deletion Sources/ContainerClient/Core/ContainerConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ 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)
networks = try Utility.getAttachmentConfigurations(containerId: id, networkIds: networkIds, macAddress: nil)
}
} else {
networks = []
Expand Down
3 changes: 3 additions & 0 deletions Sources/ContainerClient/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ public struct Flags {
@Option(name: .long, help: "Use the specified name as the container ID")
public var name: String?

@Option(name: .long, help: "Set MAC address for the container (format: XX:XX:XX:XX:XX:XX)")
public var macAddress: String?

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

Expand Down
23 changes: 18 additions & 5 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,7 +184,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, networkIds: management.networks, macAddress: management.macAddress)
for attachmentConfiguration in config.networks {
let network: NetworkState = try await ClientNetwork.get(id: attachmentConfiguration.network)
guard case .running(_, _) = network else {
Expand Down Expand Up @@ -213,7 +221,12 @@ public struct Utility {
return (config, kernel)
}

static func getAttachmentConfigurations(containerId: String, networkIds: [String]) throws -> [AttachmentConfiguration] {
static func getAttachmentConfigurations(containerId: String, networkIds: [String], macAddress: String?) throws -> [AttachmentConfiguration] {
// Validate MAC address format if provided
if let mac = macAddress {
try validMACAddress(mac)
}

// make an FQDN for the first interface
let fqdn: String?
if !containerId.contains(".") {
Expand All @@ -237,13 +250,13 @@ public struct Utility {
// attach the first network using the fqdn, and the rest using just the container ID
return networkIds.enumerated().map { item in
guard item.offset == 0 else {
return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: containerId))
return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: containerId, macAddress: item.offset == 0 ? macAddress : nil))
Copy link
Contributor

Choose a reason for hiding this comment

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

(disclaimer: I'm not a maintainer and I'm not requesting a change, just interested in this feature because I'm implementing an adjacent feature to --network in #751)

Do you know how docker or podman handle this if there are multiple networks? I think if you do something like --network net-1 --network net-2 --mac-address ff:ff:ff:ff:ff:ff this will set the MAC on net-1.

Copy link
Author

@DSS3113 DSS3113 Oct 12, 2025

Choose a reason for hiding this comment

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

Thanks for the question! Yes, multiple --network flags are supported with a single --mac-address flag. I tested this and here's the behavior:

container run --network net-1 --network net-2 --mac-address 02:42:ac:11:00:02 ubuntu
net-1 (first network): Gets the specified MAC address 02:42:ac:11:00:02
net-2 (subsequent networks): Gets auto-generated MAC address

I am not sure about podman but docker simply does not support inputting multiple --network flags in the run command when a single --mac-address is specified. [Edited]

docker run -d --name test-mac-container --mac-address 02:42:ac:11:00:99 --network test-net-1 --network test-net-2 --network test-net-3 alpine sleep 300

docker: Error response from daemon: Container cannot be connected to network endpoints: test-net-1, test-net-2, test-net-3.
See 'docker run --help'.

Copy link
Contributor

@siikamiika siikamiika Oct 13, 2025

Choose a reason for hiding this comment

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

That's interesting, according to Docker docs multiple networks should be supported. Could it be related to the --mac-address flag even though the error message is a bit vague? (or maybe that's what you meant)

I don't have Docker installed at the moment, but I tried how Podman works in a VM:

# by default podman uses host networking
# podman also has a default bridge network that can be used with either --network=bridge or --network=podman, same as --network=default or no --network in apple/container

# create two custom named bridge networks:
$ podman network create net-1 # gets the network 10.89.0.0/24
$ podman network create net-2 # gets the network 10.89.1.0/24

$ podman run --rm -it --network=bridge --mac-address=02:00:00:00:00:01 alpine # default bridge network gets static mac (command fails with just --mac-address but that's due to the network type)
$ podman run --rm -it --network=net-1 --mac-address=02:00:00:00:00:01 alpine # custom bridge network net-1 gets static mac
$ podman run --rm -it --network=net-1 --network=net-2 alpine  # starts (random mac for both)
$ podman run --rm -it --mac-address=02:00:00:00:00:01 --network=net-1 --network=net-2 alpine # fails
Error: --mac-address can only be set for a single network: invalid argument
$ podman run --rm -it --network=net-1:mac=02:00:00:00:00:01 --network=net-2 alpine # net-1 gets static mac, net-2 gets random mac
$ podman run --rm -it --network=net-1:mac=02:00:00:00:00:01 --network=net-2:mac=02:00:00:00:00:02 alpine # both get static mac

Copy link
Author

@DSS3113 DSS3113 Oct 14, 2025

Choose a reason for hiding this comment

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

You're right, I apologize. I should've clarified that the docker example failing was in the case when the --mac-address flag is specified with multiple networks.

}
return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: fqdn ?? containerId))
return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: 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: macAddress))]
}

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 @@ -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, macAddress: network.options.macAddress)
attachments.append(attachment)

let interface = try self.interfaceStrategy.toInterface(
Expand Down
12 changes: 12 additions & 0 deletions Tests/CLITests/Subcommands/Containers/TestCLICreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,16 @@ 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, macAddress: expectedMAC)
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")")
try doRemove(name: name)
}
}
}
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: ["--mac-address", 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: ["--mac-address", 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
8 changes: 7 additions & 1 deletion Tests/CLITests/Utilities/CLITest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ class CLITest {
image: String? = nil,
args: [String]? = nil,
volumes: [String] = [],
networks: [String] = []
networks: [String] = [],
macAddress: String? = nil
) throws {
let image = image ?? alpine
let args: [String] = args ?? ["sleep", "infinity"]
Expand All @@ -273,6 +274,11 @@ class CLITest {
arguments += ["--network", network]
}

// Add MAC address
if let macAddress = macAddress {
arguments += ["--mac-address", macAddress]
}

arguments += [image] + args

let (_, error, status) = try run(arguments: arguments)
Expand Down
36 changes: 36 additions & 0 deletions Tests/ContainerClientTests/UtilityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,40 @@ struct UtilityTests {
#expect(result["standalone"] == "")
#expect(result["key2"] == "value2")
}

@Test("Valid MAC address with colons")
func testValidMACAddressWithColons() throws {
try Utility.validMACAddress("02:42:ac:11:00:02")
try Utility.validMACAddress("AA:BB:CC:DD:EE:FF")
try Utility.validMACAddress("00:00:00:00:00:00")
try Utility.validMACAddress("ff:ff:ff:ff:ff:ff")
}

@Test("Valid MAC address with hyphens")
func testValidMACAddressWithHyphens() throws {
try Utility.validMACAddress("02-42-ac-11-00-02")
try Utility.validMACAddress("AA-BB-CC-DD-EE-FF")
}

@Test("Invalid MAC address format")
func testInvalidMACAddressFormat() {
#expect(throws: Error.self) {
try Utility.validMACAddress("invalid")
}
#expect(throws: Error.self) {
try Utility.validMACAddress("02:42:ac:11:00") // Too short
}
#expect(throws: Error.self) {
try Utility.validMACAddress("02:42:ac:11:00:02:03") // Too long
}
#expect(throws: Error.self) {
try Utility.validMACAddress("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ") // Invalid hex
}
#expect(throws: Error.self) {
try Utility.validMACAddress("02:42:ac:11:00:") // Incomplete
}
#expect(throws: Error.self) {
try Utility.validMACAddress("02.42.ac.11.00.02") // Wrong separator
}
}
}
4 changes: 4 additions & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ container run [OPTIONS] IMAGE [COMMAND] [ARG...]
* `--entrypoint <cmd>`: Override the entrypoint of the image
* `-k, --kernel <path>`: Set a custom kernel path
* `-l, --label <label>`: Add a key=value label to the container
* `--mac-address <mac-address>`: Set MAC address for the container (format: XX:XX:XX:XX:XX:XX)
* `--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
Expand Down Expand Up @@ -72,6 +73,9 @@ container run -d --name web -p 8080:80 nginx:latest

# set environment variables and limit resources
container run -e NODE_ENV=production --cpus 2 --memory 1G node:18

# run a container with a specific MAC address
container run --mac-address 02:42:ac:11:00:02 ubuntu:latest
```

### `container build`
Expand Down
24 changes: 24 additions & 0 deletions docs/how-to.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,30 @@ A `curl` to `localhost:8000` outputs:
</html>
```

## Set a custom MAC address for your container

Use the `--mac-address` option to specify a custom MAC address for your container's network interface. This is useful for:
- Network testing scenarios requiring predictable MAC addresses
- Consistent network configuration across container restarts

The MAC address must be in the format `XX:XX:XX:XX:XX:XX` (with colons or hyphens as separators):

```bash
container run --mac-address 02:42:ac:11:00:02 ubuntu:latest
```

To verify the MAC address is set correctly, run `ip addr show` inside the container:

```console
% container run --rm --mac-address 02:42:ac:11:00:02 ubuntu:latest ip addr show eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
inet 192.168.64.2/24 brd 192.168.64.255 scope global eth0
valid_lft forever preferred_lft forever
```

If you don't specify a MAC address, the system will auto-generate one for you.

## Mount your host SSH authentication socket in your container

Use the `--ssh` option to mount the macOS SSH authentication socket into your container, so that you can clone private git repositories and perform other tasks requiring passwordless SSH authentication.
Expand Down