feat: detect conflicing service port on a container

This commit is contained in:
Pavel Sviderski
2025-02-13 11:50:34 +10:00
parent 9859be50a6
commit b17fd7531f
2 changed files with 227 additions and 1 deletions
+50 -1
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"fmt"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"regexp" "regexp"
"strings" "strings"
@@ -12,6 +13,14 @@ const (
LabelServiceName = "uncloud.service.name" LabelServiceName = "uncloud.service.name"
LabelServiceMode = "uncloud.service.mode" LabelServiceMode = "uncloud.service.mode"
LabelServicePorts = "uncloud.service.ports" LabelServicePorts = "uncloud.service.ports"
StateCreated = "created"
StateDead = "dead"
StateExited = "exited"
StatePaused = "paused"
StateRemoving = "removing"
StateRestarting = "restarting"
StateRunning = "running"
) )
type Container struct { type Container struct {
@@ -43,6 +52,9 @@ func (c *Container) ServicePorts() ([]PortSpec, error) {
if !ok { if !ok {
return nil, nil return nil, nil
} }
if strings.TrimSpace(encoded) == "" {
return nil, nil
}
publishPorts := strings.Split(encoded, ",") publishPorts := strings.Split(encoded, ",")
ports := make([]PortSpec, len(publishPorts)) 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. // 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. // A running container with no health check configured is considered healthy.
func (c *Container) Healthy() bool { func (c *Container) Healthy() bool {
if c.State != "running" { if c.State != StateRunning {
return false return false
} }
@@ -88,3 +100,40 @@ func (c *Container) Healthy() bool {
// If the health status in parentheses is "healthy", the container is considered healthy. // If the health status in parentheses is "healthy", the container is considered healthy.
return matches[1] == types.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
}
+177
View File
@@ -3,6 +3,8 @@ package api
import ( import (
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/netip"
"testing" "testing"
) )
@@ -90,3 +92,178 @@ func TestContainer_Healthy(t *testing.T) {
assert.False(t, c.Healthy()) 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)
})
}
}