From b17fd7531f86057b6f1c08b8f96ba69964c1cc3f Mon Sep 17 00:00:00 2001 From: Pavel Sviderski Date: Thu, 13 Feb 2025 11:50:34 +1000 Subject: [PATCH] feat: detect conflicing service port on a container --- internal/api/container.go | 51 +++++++++- internal/api/container_test.go | 177 +++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 1 deletion(-) diff --git a/internal/api/container.go b/internal/api/container.go index 1d6ca21e..16edc876 100644 --- a/internal/api/container.go +++ b/internal/api/container.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "github.com/docker/docker/api/types" "regexp" "strings" @@ -12,6 +13,14 @@ const ( LabelServiceName = "uncloud.service.name" LabelServiceMode = "uncloud.service.mode" LabelServicePorts = "uncloud.service.ports" + + StateCreated = "created" + StateDead = "dead" + StateExited = "exited" + StatePaused = "paused" + StateRemoving = "removing" + StateRestarting = "restarting" + StateRunning = "running" ) type Container struct { @@ -43,6 +52,9 @@ func (c *Container) ServicePorts() ([]PortSpec, error) { if !ok { return nil, nil } + if strings.TrimSpace(encoded) == "" { + return nil, nil + } publishPorts := strings.Split(encoded, ",") ports := make([]PortSpec, len(publishPorts)) @@ -70,7 +82,7 @@ var runningStatusRegex = regexp.MustCompile(`^Up [^(]+(?:\(([^)]+)\))?$`) // Healthy determines if the container is running and healthy based on its status string. // A running container with no health check configured is considered healthy. func (c *Container) Healthy() bool { - if c.State != "running" { + if c.State != StateRunning { return false } @@ -88,3 +100,40 @@ func (c *Container) Healthy() bool { // If the health status in parentheses is "healthy", the container is considered healthy. return matches[1] == types.Healthy } + +// Stopped determines if the container is stopped and doesn't try to restart. +func (c *Container) Stopped() bool { + return c.State == StateCreated || c.State == StateDead || c.State == StateExited +} + +// ConflictingServicePorts returns a list of service ports that conflict with the given ports. +func (c *Container) ConflictingServicePorts(ports []PortSpec) ([]PortSpec, error) { + svcPorts, err := c.ServicePorts() + if err != nil { + return nil, fmt.Errorf("get service ports: %w", err) + } + + var conflicting []PortSpec + for _, p := range ports { + if p.Mode != PortModeHost { + continue + } + + // Two host ports conflict if they have the same published port number and protocol, and either: + // * At least one host IP is not set (meaning it uses all interfaces) + // * Both host IPs are identical + for _, svcPort := range svcPorts { + if svcPort.Mode != PortModeHost || + svcPort.PublishedPort != p.PublishedPort || + svcPort.Protocol != p.Protocol { + continue + } + + if !svcPort.HostIP.IsValid() || !p.HostIP.IsValid() || svcPort.HostIP.Compare(p.HostIP) == 0 { + conflicting = append(conflicting, p) + } + } + } + + return conflicting, nil +} diff --git a/internal/api/container_test.go b/internal/api/container_test.go index cbe47580..0d66b713 100644 --- a/internal/api/container_test.go +++ b/internal/api/container_test.go @@ -3,6 +3,8 @@ package api import ( "github.com/docker/docker/api/types" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/netip" "testing" ) @@ -90,3 +92,178 @@ func TestContainer_Healthy(t *testing.T) { assert.False(t, c.Healthy()) }) } + +func TestContainer_ConflictingServicePorts(t *testing.T) { + tests := []struct { + name string + containerPorts string + checkPorts []PortSpec + want []PortSpec + wantErr bool + }{ + { + name: "no conflicts when container has no ports", + containerPorts: "", + checkPorts: []PortSpec{ + {Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP}, + }, + want: nil, + wantErr: false, + }, + { + name: "host mode ports with same published port and protocol conflict", + containerPorts: "8080:80/tcp@host", + checkPorts: []PortSpec{ + {Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP}, + }, + want: []PortSpec{ + {Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP}, + }, + wantErr: false, + }, + { + name: "host mode ports with same port but different protocols don't conflict", + containerPorts: "8080:80/tcp@host", + checkPorts: []PortSpec{ + {Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolUDP}, + }, + want: nil, + wantErr: false, + }, + { + name: "multiple protocols on same port don't conflict", + containerPorts: "8080:80/tcp@host,8080:80/udp@host", + checkPorts: []PortSpec{ + {Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolUDP}, + }, + want: []PortSpec{ + {Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolUDP}, + }, + wantErr: false, + }, + { + name: "host mode ports with different published ports don't conflict", + containerPorts: "8080:80/tcp@host", + checkPorts: []PortSpec{ + {Mode: PortModeHost, PublishedPort: 8081, ContainerPort: 80, Protocol: ProtocolTCP}, + }, + want: nil, + wantErr: false, + }, + { + name: "host mode ports with same published port but different host IPs don't conflict", + containerPorts: "127.0.0.1:8080:80/tcp@host", + checkPorts: []PortSpec{ + { + Mode: PortModeHost, + HostIP: netip.MustParseAddr("127.0.0.2"), + PublishedPort: 8080, + ContainerPort: 80, + Protocol: ProtocolTCP, + }, + }, + want: nil, + wantErr: false, + }, + { + name: "host mode ports with same published port, protocol, and host IP conflict", + containerPorts: "127.0.0.1:8080:80/tcp@host", + checkPorts: []PortSpec{ + { + Mode: PortModeHost, + HostIP: netip.MustParseAddr("127.0.0.1"), + PublishedPort: 8080, + ContainerPort: 80, + Protocol: ProtocolTCP, + }, + }, + want: []PortSpec{ + { + Mode: PortModeHost, + HostIP: netip.MustParseAddr("127.0.0.1"), + PublishedPort: 8080, + ContainerPort: 80, + Protocol: ProtocolTCP, + }, + }, + wantErr: false, + }, + { + name: "host mode port with no host IP conflicts with specific host IP on same port and protocol", + containerPorts: "8080:80/tcp@host", + checkPorts: []PortSpec{ + { + Mode: PortModeHost, + HostIP: netip.MustParseAddr("127.0.0.1"), + PublishedPort: 8080, + ContainerPort: 80, + Protocol: ProtocolTCP, + }, + }, + want: []PortSpec{ + { + Mode: PortModeHost, + HostIP: netip.MustParseAddr("127.0.0.1"), + PublishedPort: 8080, + ContainerPort: 80, + Protocol: ProtocolTCP, + }, + }, + wantErr: false, + }, + { + name: "host mode port with no host IP doesn't conflict with different protocol", + containerPorts: "8080:80/tcp@host", + checkPorts: []PortSpec{ + { + Mode: PortModeHost, + HostIP: netip.MustParseAddr("127.0.0.1"), + PublishedPort: 8080, + ContainerPort: 80, + Protocol: ProtocolUDP, + }, + }, + want: nil, + wantErr: false, + }, + { + name: "ingress mode ports don't conflict with host mode ports", + containerPorts: "8080:80/tcp", + checkPorts: []PortSpec{ + {Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP}, + }, + want: nil, + wantErr: false, + }, + { + name: "container with invalid port spec returns error", + containerPorts: "invalid:port:spec", + checkPorts: []PortSpec{ + {Mode: PortModeHost, PublishedPort: 8080, ContainerPort: 80, Protocol: ProtocolTCP}, + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + container := &Container{ + Container: types.Container{ + Labels: map[string]string{ + LabelServicePorts: tt.containerPorts, + }, + }, + } + + got, err := container.ConflictingServicePorts(tt.checkPorts) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +}