implement PortSpec parsing

This commit is contained in:
Pavel Sviderski
2024-12-10 15:24:34 +10:00
parent 26df6740e3
commit 9cb4b35546
3 changed files with 494 additions and 13 deletions
+170
View File
@@ -0,0 +1,170 @@
package api
import (
"fmt"
"net/netip"
"strconv"
"strings"
)
const (
PortModeIngress = "ingress"
PortModeHost = "host"
ProtocolHTTP = "http"
ProtocolHTTPS = "https"
ProtocolTCP = "tcp"
ProtocolUDP = "udp"
)
type PortSpec struct {
// Hostname specifies the DNS name that will route to this service. Only valid in ingress mode.
Hostname string
// HostIP is the host IP to bind the PublishedPort to. Only valid in host mode.
HostIP netip.Addr
// PublishedPort is the port number exposed outside the container.
// In ingress mode, this is the load balancer port. In host mode, this is the port bound on the host.
PublishedPort uint16
// ContainerPort is the port inside the container that the service listens on.
ContainerPort uint16
// Protocol specifies the network protocol. Default is ProtocolHTTPS if Hostname is set, ProtocolTCP otherwise.
Protocol string
// Mode specifies how the port is published. Default is PortModeIngress.
Mode string
}
func ParsePortSpec(port string) (PortSpec, error) {
spec := PortSpec{
Protocol: ProtocolTCP, // Default protocol.
Mode: PortModeIngress, // Default mode.
}
// Split off mode first.
parts := strings.Split(port, "@")
if len(parts) > 2 {
return spec, fmt.Errorf("too many '@' symbols")
}
if len(parts) == 2 {
if parts[1] != PortModeHost {
return spec, fmt.Errorf("invalid mode: '%s', only 'host' mode is supported", parts[1])
}
spec.Mode = PortModeHost
}
port = parts[0]
// Parse protocol.
parts = strings.Split(port, "/")
if len(parts) > 2 {
return spec, fmt.Errorf("too many '/' symbols")
}
specifiedProtocol := ""
if len(parts) == 2 {
protocol := parts[1]
switch protocol {
case ProtocolHTTP, ProtocolHTTPS, ProtocolTCP, ProtocolUDP:
spec.Protocol = protocol
specifiedProtocol = protocol
default:
return spec, fmt.Errorf("unsupported protocol: '%s'", protocol)
}
}
port = parts[0]
// Parse hostname/host IP and ports.
parts = splitPortParts(port)
var err error
switch len(parts) {
case 1: // Just container port.
if spec.ContainerPort, err = parsePort(parts[0]); err != nil {
return spec, fmt.Errorf("invalid container port '%s': %w", parts[0], err)
}
case 2: // hostname:container_port or [load_balancer_port|host_port]:container_port
if spec.ContainerPort, err = parsePort(parts[1]); err != nil {
return spec, fmt.Errorf("invalid container port '%s': %w", parts[1], err)
}
// Try to parse the first part as port.
if publishedPort, err := parsePort(parts[0]); err == nil {
spec.PublishedPort = publishedPort
} else {
// It's a hostname.
if spec.Mode == PortModeHost {
return spec, fmt.Errorf("hostname cannot be specified in host mode")
}
if parts[0] == "" {
return spec, fmt.Errorf("hostname must not be empty")
}
// TODO: validate hostname?
spec.Hostname = parts[0]
}
case 3: // hostname:load_balancer_port:container_port or host_ip:host_port:container_port
if spec.ContainerPort, err = parsePort(parts[2]); err != nil {
return spec, fmt.Errorf("invalid container port '%s': %w", parts[2], err)
}
if spec.PublishedPort, err = parsePort(parts[1]); err != nil {
return spec, fmt.Errorf("invalid published port '%s': %w", parts[1], err)
}
if spec.Mode == PortModeHost {
// In host mode, the first part must be IP.
ip := parts[0]
// Strip brackets from IPv6 address if present.
if strings.Contains(ip, ":") {
if !strings.HasPrefix(ip, "[") {
return spec, fmt.Errorf(
"invalid host IP '%s': IPv6 address must be enclosed in square brackets", ip)
}
if !strings.HasSuffix(ip, "]") {
return spec, fmt.Errorf("invalid host IP '%s': missing closing bracket", ip)
}
ip = ip[1 : len(ip)-1]
}
if spec.HostIP, err = netip.ParseAddr(ip); err != nil {
return spec, fmt.Errorf("invalid host IP '%s': %w", parts[0], err)
}
} else {
if parts[0] == "" {
return spec, fmt.Errorf("hostname must not be empty")
}
// TODO: validate hostname?
spec.Hostname = parts[0]
}
default:
return spec, fmt.Errorf("unexpected number of parts in port spec: %d", len(parts))
}
if spec.Hostname != "" {
if specifiedProtocol == "" {
spec.Protocol = ProtocolHTTPS
} else if specifiedProtocol != ProtocolHTTP && specifiedProtocol != ProtocolHTTPS {
return spec, fmt.Errorf("hostname is only valid with '%s' or '%s' protocols, specified: '%s'",
ProtocolHTTP, ProtocolHTTPS, specifiedProtocol)
}
}
return spec, nil
}
// splitPortParts splits a port specification [hostname|host_ip:][published_port:]container_port into its parts.
func splitPortParts(port string) []string {
parts := strings.Split(port, ":")
n := len(parts)
if n > 3 {
// Host IP may contain colons if it's IPv6, so we need to join the first n-2 parts.
return append([]string{strings.Join(parts[:n-2], ":")}, parts[n-2:]...)
}
return parts
}
func parsePort(s string) (uint16, error) {
port, err := strconv.ParseUint(s, 10, 16)
if err != nil {
return 0, err
}
return uint16(port), nil
}
+2 -13
View File
@@ -4,8 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/distribution/reference" "github.com/distribution/reference"
"net/netip"
"uncloud/internal/machine/api/pb" "uncloud/internal/machine/api/pb"
) )
@@ -19,6 +17,8 @@ type ServiceSpec struct {
// Mode is the replication mode of the service. Default is ServiceModeReplicated if empty. // Mode is the replication mode of the service. Default is ServiceModeReplicated if empty.
Mode string Mode string
Name string Name string
// Ports defines what service ports to publish to make the service accessible outside the cluster.
Ports []PortSpec
} }
func (s *ServiceSpec) Validate() error { func (s *ServiceSpec) Validate() error {
@@ -40,8 +40,6 @@ type ContainerSpec struct {
Image string Image string
// Run a custom init inside the container. If nil, use the daemon's configured settings. // Run a custom init inside the container. If nil, use the daemon's configured settings.
Init *bool Init *bool
// Ports to publish from the container.
Ports []PortSpec
} }
func (s *ContainerSpec) Validate() error { func (s *ContainerSpec) Validate() error {
@@ -53,15 +51,6 @@ func (s *ContainerSpec) Validate() error {
return nil return nil
} }
type PortSpec struct {
Hostname string
HostIP netip.Addr
PublishedPort uint16
ContainerPort uint16
Protocol string
Mode string
}
type Service struct { type Service struct {
ID string ID string
Name string Name string
+322
View File
@@ -0,0 +1,322 @@
package api
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/netip"
"testing"
)
func TestParsePortSpec(t *testing.T) {
t.Parallel()
tests := []struct {
name string
port string
expected PortSpec
wantErr string
}{
// Ingress mode (default).
{
name: "container port only",
port: "8080",
expected: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeIngress,
},
},
{
name: "container port zero",
port: "0",
expected: PortSpec{
ContainerPort: 0,
Protocol: ProtocolTCP,
Mode: PortModeIngress,
},
},
{
name: "published and container port",
port: "8000:8080",
expected: PortSpec{
PublishedPort: 8000,
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeIngress,
},
},
{
name: "published port zero",
port: "0:8080",
expected: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeIngress,
},
},
{
name: "tcp protocol explicit",
port: "8080/tcp",
expected: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeIngress,
},
},
{
name: "udp protocol",
port: "8080/udp",
expected: PortSpec{
ContainerPort: 8080,
Protocol: ProtocolUDP,
Mode: PortModeIngress,
},
},
{
name: "published port udp",
port: "8000:8080/udp",
expected: PortSpec{
PublishedPort: 8000,
ContainerPort: 8080,
Protocol: ProtocolUDP,
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",
expected: PortSpec{
Hostname: "app.example.com",
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
Mode: PortModeIngress,
},
},
{
name: "hostname and container port http",
port: "app.example.com:8080/http",
expected: PortSpec{
Hostname: "app.example.com",
ContainerPort: 8080,
Protocol: ProtocolHTTP,
Mode: PortModeIngress,
},
},
{
name: "hostname and published port",
port: "app.example.com:6443:8080",
expected: PortSpec{
Hostname: "app.example.com",
PublishedPort: 6443,
ContainerPort: 8080,
Protocol: ProtocolHTTPS,
Mode: PortModeIngress,
},
},
{
name: "hostname and published port http",
port: "app.example.com:8000:8080/http",
expected: PortSpec{
Hostname: "app.example.com",
PublishedPort: 8000,
ContainerPort: 8080,
Protocol: ProtocolHTTP,
Mode: PortModeIngress,
},
},
// 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",
expected: PortSpec{
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolUDP,
Mode: PortModeHost,
},
},
{
name: "host mode with IPv4",
port: "127.0.0.1:80:8080@host",
expected: PortSpec{
HostIP: netip.MustParseAddr("127.0.0.1"),
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeHost,
},
},
{
name: "host mode with IPv6",
port: "[2001:db8::1234:5678]:80:8080@host",
expected: PortSpec{
HostIP: netip.MustParseAddr("2001:db8::1234:5678"),
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolTCP,
Mode: PortModeHost,
},
},
{
name: "host mode with IP and protocol",
port: "127.0.0.1:80:8080/udp@host",
expected: PortSpec{
HostIP: netip.MustParseAddr("127.0.0.1"),
PublishedPort: 80,
ContainerPort: 8080,
Protocol: ProtocolUDP,
Mode: PortModeHost,
},
},
// Error cases.
{
name: "empty",
port: "",
wantErr: "invalid container port",
},
{
name: "invalid container port",
port: "invalid",
wantErr: "invalid container port",
},
{
name: "out of range container port",
port: "100500",
wantErr: "invalid container port",
},
{
name: "just protocol",
port: "/tcp",
wantErr: "invalid container port",
},
{
name: "just mode",
port: "@host",
wantErr: "invalid container port",
},
{
name: "multiple @ symbols",
port: "8080@host@host",
wantErr: "too many '@' symbols",
},
{
name: "invalid mode",
port: "8080@invalid",
wantErr: "invalid mode: 'invalid'",
},
{
name: "multiple protocols",
port: "8080/tcp/udp",
wantErr: "too many '/' symbols",
},
{
name: "invalid protocol",
port: "8080/invalid",
wantErr: "unsupported protocol: 'invalid'",
},
{
name: "invalid published port",
port: "test:invalid:8080",
wantErr: "invalid published port",
},
{
name: "invalid host IPv4",
port: "300.0.0.1:80:8080@host",
wantErr: "invalid host IP",
},
{
name: "invalid host IPv6",
port: "[:::1]:80:8080@host",
wantErr: "invalid host IP",
},
{
name: "missing closing bracket in IPv6",
port: "[::1:80:8080@host",
wantErr: "invalid host IP",
},
{
name: "missing brackets in IPv6",
port: "2001:db8::1234:5678:80:8080@host",
wantErr: "invalid host IP",
},
{
name: "hostname in host mode",
port: "app.example.com:8080@host",
wantErr: "hostname cannot be specified in host mode",
},
{
name: "hostname with invalid published port",
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 {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
spec, err := ParsePortSpec(tt.port)
if tt.wantErr != "" {
require.Error(t, err, "Expected error: %s, got nil, spec: %+v", tt.wantErr, spec)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, spec)
})
}
}