diff --git a/internal/api/port.go b/internal/api/port.go index 91d244dd..3442aad1 100644 --- a/internal/api/port.go +++ b/internal/api/port.go @@ -33,6 +33,46 @@ type PortSpec struct { Mode string } +func (p *PortSpec) Validate() error { + if p.ContainerPort == 0 { + return fmt.Errorf("container port must be non-zero") + } + + switch p.Protocol { + case ProtocolHTTP, ProtocolHTTPS, ProtocolTCP, ProtocolUDP: + default: + return fmt.Errorf("invalid protocol: '%s'", p.Protocol) + } + + switch p.Mode { + case PortModeIngress: + if p.HostIP.IsValid() { + return fmt.Errorf("host IP cannot be specified in %s mode", PortModeIngress) + } + if p.Hostname != "" && p.Protocol != ProtocolHTTP && p.Protocol != ProtocolHTTPS { + return fmt.Errorf("hostname is only valid with '%s' or '%s' protocols", ProtocolHTTP, ProtocolHTTPS) + } + if p.Hostname == "" && (p.Protocol == ProtocolHTTP || p.Protocol == ProtocolHTTPS) { + return fmt.Errorf("hostname is required with '%s' or '%s' protocols", ProtocolHTTP, ProtocolHTTPS) + } + case PortModeHost: + if p.PublishedPort == 0 { + return fmt.Errorf("published port is required in %s mode", PortModeHost) + } + if p.Protocol != ProtocolTCP && p.Protocol != ProtocolUDP { + return fmt.Errorf("unsupported protocol '%s' in %s mode, only '%s' and '%s' are supported", + p.Protocol, PortModeHost, ProtocolTCP, ProtocolUDP) + } + if p.Hostname != "" { + return fmt.Errorf("hostname cannot be specified in %s mode", PortModeHost) + } + default: + return fmt.Errorf("invalid mode: '%s'", p.Mode) + } + + return nil +} + func ParsePortSpec(port string) (PortSpec, error) { spec := PortSpec{ Protocol: ProtocolTCP, // Default protocol. @@ -147,7 +187,7 @@ func ParsePortSpec(port string) (PortSpec, error) { } } - return spec, nil + return spec, spec.Validate() } // splitPortParts splits a port specification [hostname|host_ip:][published_port:]container_port into its parts. diff --git a/internal/api/service_test.go b/internal/api/service_test.go index 14b1cdaa..8648eb64 100644 --- a/internal/api/service_test.go +++ b/internal/api/service_test.go @@ -26,15 +26,6 @@ func TestParsePortSpec(t *testing.T) { Mode: PortModeIngress, }, }, - { - name: "container port zero", - port: "0", - expected: PortSpec{ - ContainerPort: 0, - Protocol: ProtocolTCP, - Mode: PortModeIngress, - }, - }, { name: "published and container port", port: "8000:8080", @@ -82,34 +73,6 @@ func TestParsePortSpec(t *testing.T) { Mode: PortModeIngress, }, }, - { - name: "http protocol", - port: "8080/http", - expected: PortSpec{ - ContainerPort: 8080, - Protocol: ProtocolHTTP, - Mode: PortModeIngress, - }, - }, - { - name: "https protocol", - port: "8080/https", - expected: PortSpec{ - ContainerPort: 8080, - Protocol: ProtocolHTTPS, - Mode: PortModeIngress, - }, - }, - { - name: "published port https", - port: "8000:8080/https", - expected: PortSpec{ - PublishedPort: 8000, - ContainerPort: 8080, - Protocol: ProtocolHTTPS, - Mode: PortModeIngress, - }, - }, { name: "hostname and container port", port: "app.example.com:8080", @@ -154,24 +117,6 @@ func TestParsePortSpec(t *testing.T) { }, // Host mode. - { - name: "host mode", - port: "8080@host", - expected: PortSpec{ - ContainerPort: 8080, - Protocol: ProtocolTCP, - Mode: PortModeHost, - }, - }, - { - name: "host mode with protocol", - port: "8080/udp@host", - expected: PortSpec{ - ContainerPort: 8080, - Protocol: ProtocolUDP, - Mode: PortModeHost, - }, - }, { name: "host mode published with protocol", port: "80:8080/udp@host", @@ -227,6 +172,11 @@ func TestParsePortSpec(t *testing.T) { port: "invalid", wantErr: "invalid container port", }, + { + name: "container port zero", + port: "0", + wantErr: "container port must be non-zero", + }, { name: "out of range container port", port: "100500", @@ -267,6 +217,37 @@ func TestParsePortSpec(t *testing.T) { port: "test:invalid:8080", wantErr: "invalid published port", }, + { + name: "missing hostname with http", + port: "8080/http", + wantErr: "hostname is required", + }, + { + name: "missing hostname with http", + port: "8080/https", + wantErr: "hostname is required", + }, + { + name: "missing hostname with published https port", + port: "8000:8080/https", + wantErr: "hostname is required", + }, + { + name: "hostname with tcp protocol", + port: "app.example.com:8080/tcp", + wantErr: "hostname is only valid with 'http' or 'https' protocols", + }, + + { + name: "missing published port in host mode", + port: "8080@host", + wantErr: "published port is required in host mode", + }, + { + name: "missing published port with protocol in host mode", + port: "8080/udp@host", + wantErr: "published port is required in host mode", + }, { name: "invalid host IPv4", port: "300.0.0.1:80:8080@host", @@ -287,6 +268,16 @@ func TestParsePortSpec(t *testing.T) { port: "2001:db8::1234:5678:80:8080@host", wantErr: "invalid host IP", }, + { + name: "http in host mode", + port: "80:8080/http@host", + wantErr: "unsupported protocol 'http' in host mode, only 'tcp' and 'udp' are supported", + }, + { + name: "https in host mode", + port: "80:8080/https@host", + wantErr: "unsupported protocol 'https' in host mode, only 'tcp' and 'udp' are supported", + }, { name: "hostname in host mode", port: "app.example.com:8080@host", @@ -297,11 +288,6 @@ func TestParsePortSpec(t *testing.T) { port: "app.example.com:invalid:8080@host", wantErr: "invalid published port", }, - { - name: "hostname with tcp protocol", - port: "app.example.com:8080/tcp", - wantErr: "hostname is only valid with 'http' or 'https' protocols", - }, } for _, tt := range tests { @@ -320,3 +306,228 @@ func TestParsePortSpec(t *testing.T) { }) } } + +func TestPortSpec_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec PortSpec + wantErr string + }{ + // Valid ingress mode. + { + name: "ingress mode tcp", + spec: PortSpec{ + ContainerPort: 8080, + Protocol: ProtocolTCP, + Mode: PortModeIngress, + }, + }, + { + name: "ingress mode with published tcp port", + spec: PortSpec{ + PublishedPort: 80, + ContainerPort: 8080, + Protocol: ProtocolTCP, + Mode: PortModeIngress, + }, + }, + { + name: "ingress mode udp", + spec: PortSpec{ + ContainerPort: 8080, + Protocol: ProtocolUDP, + Mode: PortModeIngress, + }, + }, + { + name: "ingress mode with published udp port", + spec: PortSpec{ + PublishedPort: 80, + ContainerPort: 8080, + Protocol: ProtocolUDP, + Mode: PortModeIngress, + }, + }, + { + name: "ingress mode with hostname and http", + spec: PortSpec{ + Hostname: "app.example.com", + ContainerPort: 8080, + Protocol: ProtocolHTTP, + Mode: PortModeIngress, + }, + }, + { + name: "ingress mode with hostname and https", + spec: PortSpec{ + Hostname: "app.example.com", + ContainerPort: 8080, + Protocol: ProtocolHTTPS, + Mode: PortModeIngress, + }, + }, + { + name: "ingress mode with hostname and published port", + spec: PortSpec{ + Hostname: "app.example.com", + PublishedPort: 6443, + ContainerPort: 8080, + Protocol: ProtocolHTTPS, + Mode: PortModeIngress, + }, + }, + + // Valid host mode. + { + name: "host mode", + spec: PortSpec{ + PublishedPort: 80, + ContainerPort: 8080, + Protocol: ProtocolTCP, + Mode: PortModeHost, + }, + }, + { + name: "host mode with IPv4", + spec: PortSpec{ + HostIP: netip.MustParseAddr("127.0.0.1"), + PublishedPort: 80, + ContainerPort: 8080, + Protocol: ProtocolTCP, + Mode: PortModeHost, + }, + }, + { + name: "host mode with IPv6", + spec: PortSpec{ + HostIP: netip.MustParseAddr("2001:db8::1234:5678"), + PublishedPort: 80, + ContainerPort: 8080, + Protocol: ProtocolUDP, + Mode: PortModeHost, + }, + }, + + // Error cases. + { + name: "missing container port", + spec: PortSpec{ + Protocol: ProtocolTCP, + Mode: PortModeIngress, + }, + wantErr: "container port must be non-zero", + }, + { + name: "invalid protocol", + spec: PortSpec{ + ContainerPort: 8080, + Protocol: "invalid", + Mode: PortModeIngress, + }, + wantErr: "invalid protocol: 'invalid'", + }, + { + name: "invalid mode", + spec: PortSpec{ + ContainerPort: 8080, + Protocol: ProtocolTCP, + Mode: "invalid", + }, + wantErr: "invalid mode: 'invalid'", + }, + { + name: "hostname with non-http protocol", + spec: PortSpec{ + Hostname: "app.example.com", + ContainerPort: 8080, + Protocol: ProtocolTCP, + Mode: PortModeIngress, + }, + wantErr: "hostname is only valid with 'http' or 'https' protocols", + }, + { + name: "missing hostname with http", + spec: PortSpec{ + ContainerPort: 8080, + Protocol: ProtocolHTTP, + Mode: PortModeIngress, + }, + wantErr: "hostname is required with 'http' or 'https' protocols", + }, + { + name: "missing hostname with https", + spec: PortSpec{ + ContainerPort: 8080, + Protocol: ProtocolHTTPS, + Mode: PortModeIngress, + }, + wantErr: "hostname is required with 'http' or 'https' protocols", + }, + { + name: "host IP in ingress mode", + spec: PortSpec{ + HostIP: netip.MustParseAddr("127.0.0.1"), + ContainerPort: 8080, + Protocol: ProtocolTCP, + Mode: PortModeIngress, + }, + wantErr: "host IP cannot be specified in ingress mode", + }, + { + name: "zero published port in host mode", + spec: PortSpec{ + ContainerPort: 8080, + Protocol: ProtocolTCP, + Mode: PortModeHost, + }, + wantErr: "published port is required in host mode", + }, + { + name: "hostname in host mode", + spec: PortSpec{ + Hostname: "app.example.com", + PublishedPort: 80, + ContainerPort: 8080, + Protocol: ProtocolTCP, + Mode: PortModeHost, + }, + wantErr: "hostname cannot be specified in host mode", + }, + { + name: "http in host mode", + spec: PortSpec{ + PublishedPort: 80, + ContainerPort: 8080, + Protocol: ProtocolHTTP, + Mode: PortModeHost, + }, + wantErr: "unsupported protocol 'http' in host mode", + }, + { + name: "https in host mode", + spec: PortSpec{ + PublishedPort: 80, + ContainerPort: 8080, + Protocol: ProtocolHTTPS, + Mode: PortModeHost, + }, + wantErr: "unsupported protocol 'https' in host mode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.spec.Validate() + if tt.wantErr != "" { + require.Error(t, err, tt.wantErr) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + }) + } +}