Commit 3d577f8e authored by Yann Hodique's avatar Yann Hodique Committed by GitHub

feat(kurtosis-devnet): improve docker host detection (#13608)

Move the detection logic to a separate package, and make it testable.
Also make room for more detection mechanisms down the road if needed.
parent 6fa5e378
......@@ -7,13 +7,12 @@ import (
"fmt"
"io"
"log"
"net"
"os"
"path/filepath"
"runtime"
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/build"
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/kurtosis"
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/kurtosis/backend"
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/serve"
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/tmpl"
"github.com/urfave/cli/v2"
......@@ -308,40 +307,6 @@ func mainAction(c *cli.Context) error {
return mainFunc(cfg)
}
func defaultDockerHost() string {
if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
// Docker Desktop supports this.
return "host.docker.internal"
}
// On Linux, find docker0 bridge network
ifaces, err := net.Interfaces()
if err != nil {
// we're parsing main flags here, it's ok to panic.
panic(fmt.Errorf("error getting interfaces, consider setting --local-hostname manually: %w", err))
}
for _, iface := range ifaces {
// docker0 is the default bridge network
if iface.Name == "docker0" {
addrs, err := iface.Addrs()
if err != nil || len(addrs) == 0 {
continue
}
// Get the first IP address
ip, _, err := net.ParseCIDR(addrs[0].String())
if err != nil {
continue
}
return ip.String()
}
}
// If we didn't find docker0, we're desperate at this point.
// It might still work if we're on host network mode.
return "localhost"
}
func getFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
......@@ -374,7 +339,7 @@ func getFlags() []cli.Flag {
&cli.StringFlag{
Name: "local-hostname",
Usage: "DNS for localhost from Kurtosis perspective (optional)",
Value: defaultDockerHost(),
Value: backend.DefaultDockerHost(),
},
}
}
......
package backend
import (
"net"
"net/url"
"strings"
)
// IPNetAddr is an interface that allows getting the underlying *net.IPNet
type IPNetAddr interface {
net.Addr
AsIPNet() *net.IPNet
}
// DockerFlavor interface and implementations
type DockerFlavor interface {
GetDockerHost() string
}
type DockerDesktop struct{}
func (d *DockerDesktop) GetDockerHost() string {
return "host.docker.internal"
}
type DockerVM struct {
ipAddress string
networkProvider networkProvider
}
func NewDockerVM(ipAddress string) *DockerVM {
return &DockerVM{
ipAddress: ipAddress,
networkProvider: defaultNetworkProvider{},
}
}
func (d *DockerVM) GetDockerHost() string {
vmIP := net.ParseIP(d.ipAddress)
if vmIP == nil {
return "localhost"
}
ifaces, err := d.networkProvider.Interfaces()
if err != nil {
return "localhost"
}
for _, iface := range ifaces {
addrs, err := iface.Addrs()
if err != nil || len(addrs) == 0 {
continue
}
for _, addr := range addrs {
ipNetAddr, ok := addr.(IPNetAddr)
if !ok {
continue
}
ipNet := ipNetAddr.AsIPNet()
// Skip loopback addresses
if ipNet.IP.IsLoopback() {
continue
}
// Check if this network contains the VM IP
if ipNet.Contains(vmIP) {
// Return our IP address on this interface
if localIP := ipNet.IP.To4(); localIP != nil {
return localIP.String()
}
}
}
}
return "localhost"
}
type DockerLocal struct {
networkProvider networkProvider
}
func NewDockerLocal() *DockerLocal {
return &DockerLocal{
networkProvider: defaultNetworkProvider{},
}
}
func (d *DockerLocal) GetDockerHost() string {
ifaces, err := d.networkProvider.Interfaces()
if err != nil {
return "localhost"
}
for _, iface := range ifaces {
if strings.HasPrefix(iface.Name(), "docker") {
addrs, err := iface.Addrs()
if err != nil || len(addrs) == 0 {
continue
}
// Get the first IP address
ipNetAddr, ok := addrs[0].(IPNetAddr)
if !ok {
continue
}
ipNet := ipNetAddr.AsIPNet()
return ipNet.IP.String()
}
}
return "localhost"
}
type DockerDetector struct {
envProvider envProvider
runtimeProvider runtimeProvider
}
func NewDockerDetector() *DockerDetector {
return &DockerDetector{
envProvider: defaultEnvProvider{},
runtimeProvider: defaultRuntimeProvider{},
}
}
func (d *DockerDetector) DockerFlavor() (DockerFlavor, error) {
// Check DOCKER_HOST environment variable first as it takes precedence
if dockerHost := d.envProvider.Getenv("DOCKER_HOST"); dockerHost != "" {
parsedURL, err := url.Parse(dockerHost)
if err != nil {
return nil, err
}
if d.runtimeProvider.GOOS() == "linux" && parsedURL.Scheme == "unix" {
return NewDockerLocal(), nil
}
if parsedURL.Scheme == "tcp" {
return NewDockerVM(parsedURL.Hostname()), nil
}
}
if d.runtimeProvider.GOOS() == "darwin" || d.runtimeProvider.GOOS() == "windows" {
// TODO: Add actual Docker version check here when needed
// For now, assume Docker Desktop as it's the most common case
return &DockerDesktop{}, nil
}
// On Linux, default to DockerLocal
return NewDockerLocal(), nil
}
func DefaultDockerHost() string {
detector := NewDockerDetector()
flavor, err := detector.DockerFlavor()
if err != nil {
return "localhost"
}
return flavor.GetDockerHost()
}
package backend
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Mock implementations
type mockInterface struct {
name string
addrs []net.Addr
}
func (m *mockInterface) Name() string {
return m.name
}
func (m *mockInterface) Addrs() ([]net.Addr, error) {
return m.addrs, nil
}
type mockNetworkProvider struct {
interfaces []Interface
}
func (m *mockNetworkProvider) Interfaces() ([]Interface, error) {
return m.interfaces, nil
}
type mockEnvProvider struct {
env map[string]string
}
func (m *mockEnvProvider) Getenv(key string) string {
return m.env[key]
}
type mockRuntimeProvider struct {
goos string
}
func (m *mockRuntimeProvider) GOOS() string {
return m.goos
}
// mockIPNet implements net.Addr and can be type asserted to *net.IPNet
type mockIPNet struct {
addr *net.IPNet
}
func newMockIPNet(cidr string) net.Addr {
ip, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
panic(err)
}
// Set the IP in the IPNet to be the specific IP, not the network address
ipNet.IP = ip
return &mockIPNet{addr: ipNet}
}
func (m *mockIPNet) Network() string { return "ip+net" }
func (m *mockIPNet) String() string { return m.addr.IP.String() }
// Make it possible to type assert to *net.IPNet
func (m *mockIPNet) AsIPNet() *net.IPNet {
return m.addr
}
func TestDockerDesktop(t *testing.T) {
flavor := &DockerDesktop{}
assert.Equal(t, "host.docker.internal", flavor.GetDockerHost())
}
func TestDockerVM(t *testing.T) {
tests := []struct {
name string
vmIP string
ifaces []Interface
expected string
}{
{
name: "matching interface found",
vmIP: "192.168.1.100",
ifaces: []Interface{
&mockInterface{
name: "eth0",
addrs: []net.Addr{newMockIPNet("192.168.1.5/24")},
},
},
expected: "192.168.1.5",
},
{
name: "no matching interface",
vmIP: "10.0.0.100",
ifaces: []Interface{
&mockInterface{
name: "eth0",
addrs: []net.Addr{newMockIPNet("192.168.1.5/24")},
},
},
expected: "localhost",
},
{
name: "invalid VM IP",
vmIP: "invalid-ip",
ifaces: []Interface{},
expected: "localhost",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
vm := NewDockerVM(tt.vmIP)
vm.networkProvider = &mockNetworkProvider{interfaces: tt.ifaces}
assert.Equal(t, tt.expected, vm.GetDockerHost())
})
}
}
func TestDockerLocal(t *testing.T) {
tests := []struct {
name string
ifaces []Interface
expected string
}{
{
name: "docker0 interface found",
ifaces: []Interface{
&mockInterface{
name: "docker0",
addrs: []net.Addr{newMockIPNet("172.17.0.1/16")},
},
},
expected: "172.17.0.1",
},
{
name: "docker1 interface found",
ifaces: []Interface{
&mockInterface{
name: "docker1",
addrs: []net.Addr{newMockIPNet("172.18.0.1/16")},
},
},
expected: "172.18.0.1",
},
{
name: "prefers first docker interface",
ifaces: []Interface{
&mockInterface{
name: "eth0",
addrs: []net.Addr{newMockIPNet("192.168.1.5/24")},
},
&mockInterface{
name: "docker0",
addrs: []net.Addr{newMockIPNet("172.17.0.1/16")},
},
&mockInterface{
name: "docker1",
addrs: []net.Addr{newMockIPNet("172.18.0.1/16")},
},
},
expected: "172.17.0.1",
},
{
name: "skips docker interface with no addresses",
ifaces: []Interface{
&mockInterface{
name: "docker0",
addrs: []net.Addr{},
},
&mockInterface{
name: "docker1",
addrs: []net.Addr{newMockIPNet("172.18.0.1/16")},
},
},
expected: "172.18.0.1",
},
{
name: "no docker interface",
ifaces: []Interface{
&mockInterface{
name: "eth0",
addrs: []net.Addr{newMockIPNet("192.168.1.5/24")},
},
},
expected: "localhost",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
local := NewDockerLocal()
local.networkProvider = &mockNetworkProvider{interfaces: tt.ifaces}
assert.Equal(t, tt.expected, local.GetDockerHost())
})
}
}
func TestDockerDetector(t *testing.T) {
tests := []struct {
name string
env map[string]string
goos string
expectType string
expectError bool
}{
{
name: "unix socket",
env: map[string]string{
"DOCKER_HOST": "unix:///var/run/docker.sock",
},
expectType: "DockerLocal",
},
{
name: "tcp host",
env: map[string]string{
"DOCKER_HOST": "tcp://192.168.1.100:2375",
},
expectType: "DockerVM",
},
{
name: "darwin no docker host",
env: map[string]string{},
goos: "darwin",
expectType: "DockerDesktop",
},
{
name: "windows no docker host",
env: map[string]string{},
goos: "windows",
expectType: "DockerDesktop",
},
{
name: "linux no docker host",
env: map[string]string{},
goos: "linux",
expectType: "DockerLocal",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
detector := NewDockerDetector()
detector.envProvider = &mockEnvProvider{env: tt.env}
detector.runtimeProvider = &mockRuntimeProvider{goos: tt.goos}
flavor, err := detector.DockerFlavor()
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
var gotType string
switch flavor.(type) {
case *DockerLocal:
gotType = "DockerLocal"
case *DockerVM:
gotType = "DockerVM"
case *DockerDesktop:
gotType = "DockerDesktop"
}
assert.Equal(t, tt.expectType, gotType)
})
}
}
package backend
import (
"net"
"os"
"runtime"
)
// Interface represents a network interface
type Interface interface {
Addrs() ([]net.Addr, error)
Name() string
}
// Providers for external dependencies
type networkProvider interface {
Interfaces() ([]Interface, error)
}
type envProvider interface {
Getenv(key string) string
}
type runtimeProvider interface {
GOOS() string
}
// netInterfaceWrapper wraps net.Interface to implement our Interface
type netInterfaceWrapper struct {
iface net.Interface
}
func (n *netInterfaceWrapper) Addrs() ([]net.Addr, error) {
addrs, err := n.iface.Addrs()
if err != nil {
return nil, err
}
// Wrap each address in our IPNetAddr interface if it's an *net.IPNet
result := make([]net.Addr, len(addrs))
for i, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok {
result[i] = &realIPNetAddr{addr: ipNet}
} else {
result[i] = addr
}
}
return result, nil
}
func (n *netInterfaceWrapper) Name() string {
return n.iface.Name
}
// realIPNetAddr wraps a real *net.IPNet to implement our IPNetAddr interface
type realIPNetAddr struct {
addr *net.IPNet
}
func (r *realIPNetAddr) Network() string { return r.addr.Network() }
func (r *realIPNetAddr) String() string { return r.addr.String() }
func (r *realIPNetAddr) AsIPNet() *net.IPNet {
return r.addr
}
// Default implementations
type defaultNetworkProvider struct{}
func (d defaultNetworkProvider) Interfaces() ([]Interface, error) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
result := make([]Interface, len(ifaces))
for i, iface := range ifaces {
// Need to create a new variable here to avoid having all wrappers
// point to the last interface in the loop
iface := iface
result[i] = &netInterfaceWrapper{iface: iface}
}
return result, nil
}
type defaultEnvProvider struct{}
func (d defaultEnvProvider) Getenv(key string) string {
return os.Getenv(key)
}
type defaultRuntimeProvider struct{}
func (d defaultRuntimeProvider) GOOS() string {
return runtime.GOOS
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment