From 1b035f83c963bbb18d477577e141c85b5ea1bef0 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 11 Jul 2026 10:19:43 +1000 Subject: [PATCH] Stabilize container MAC addresses --- agent/internal/container/network.go | 26 +++++++++++++++++++ agent/internal/container/runtime.go | 6 ++++- agent/internal/container/runtime_test.go | 32 +++++++++++++++++++++++- 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 agent/internal/container/network.go diff --git a/agent/internal/container/network.go b/agent/internal/container/network.go new file mode 100644 index 0000000..f659f0e --- /dev/null +++ b/agent/internal/container/network.go @@ -0,0 +1,26 @@ +package container + +import ( + "fmt" + "net/netip" + "strings" +) + +// StableMACAddress derives a locally administered unicast MAC address from an +// IPv4 address. Containers that keep the same static IP must also keep the same +// MAC so hosts do not retain a stale neighbor entry across stop/recreate cycles. +func StableMACAddress(ipAddress string) string { + ip, err := netip.ParseAddr(strings.TrimSpace(ipAddress)) + if err != nil || !ip.Is4() { + return "" + } + + octets := ip.As4() + return fmt.Sprintf( + "02:42:%02x:%02x:%02x:%02x", + octets[0], + octets[1], + octets[2], + octets[3], + ) +} diff --git a/agent/internal/container/runtime.go b/agent/internal/container/runtime.go index 660b66b..8cb55e6 100644 --- a/agent/internal/container/runtime.go +++ b/agent/internal/container/runtime.go @@ -132,6 +132,8 @@ func Deploy(config *DeployConfig) (*DeployResult, error) { } func buildPodmanRunArgs(config *DeployConfig, image string) []string { + networkMAC := StableMACAddress(config.IPAddress) + args := []string{ "run", "-d", "--name", config.Name, @@ -155,9 +157,11 @@ func buildPodmanRunArgs(config *DeployConfig, image string) []string { "--label", fmt.Sprintf("techulus.service.name=%s", config.ServiceName), "--label", fmt.Sprintf("techulus.deployment.id=%s", config.DeploymentID), ) - if config.IPAddress != "" { args = append(args, "--network", NetworkName, "--ip", config.IPAddress) + if networkMAC != "" { + args = append(args, "--mac-address", networkMAC) + } if config.PublishLocalPorts { for _, pm := range config.PortMappings { portMapping := fmt.Sprintf("127.0.0.1:%d:%d", pm.HostPort, pm.ContainerPort) diff --git a/agent/internal/container/runtime_test.go b/agent/internal/container/runtime_test.go index 790d75f..1fcf4b1 100644 --- a/agent/internal/container/runtime_test.go +++ b/agent/internal/container/runtime_test.go @@ -19,13 +19,43 @@ func TestBuildPodmanRunArgsPublishesLoopbackPortsWithStaticIP(t *testing.T) { }, }, "docker.io/library/nginx:latest") - for _, want := range []string{"--network", NetworkName, "--ip", "10.200.1.2", "-p", "127.0.0.1:30080:80"} { + for _, want := range []string{ + "--network", + NetworkName, + "--ip", + "10.200.1.2", + "--mac-address", + "02:42:0a:c8:01:02", + "-p", + "127.0.0.1:30080:80", + } { if !slices.Contains(args, want) { t.Fatalf("args missing %q: %+v", want, args) } } } +func TestStableMACAddress(t *testing.T) { + tests := []struct { + name string + ipAddress string + want string + }{ + {name: "private IPv4", ipAddress: "10.200.7.4", want: "02:42:0a:c8:07:04"}, + {name: "trims whitespace", ipAddress: " 10.200.1.2 ", want: "02:42:0a:c8:01:02"}, + {name: "invalid", ipAddress: "not-an-ip", want: ""}, + {name: "IPv6", ipAddress: "fd00::1", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := StableMACAddress(tt.ipAddress); got != tt.want { + t.Fatalf("StableMACAddress(%q) = %q, want %q", tt.ipAddress, got, tt.want) + } + }) + } +} + func TestBuildPodmanRunArgsDoesNotPublishStaticIPPortsByDefault(t *testing.T) { args := buildPodmanRunArgs(&DeployConfig{ Name: "svc-dep",