diff --git a/internal/api/port.go b/internal/api/port.go new file mode 100644 index 00000000..91d244dd --- /dev/null +++ b/internal/api/port.go @@ -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 +} diff --git a/internal/api/service.go b/internal/api/service.go index 22fa2d17..77e415e5 100644 --- a/internal/api/service.go +++ b/internal/api/service.go @@ -4,8 +4,6 @@ import ( "encoding/json" "fmt" "github.com/distribution/reference" - "net/netip" - "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 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 { @@ -40,8 +40,6 @@ type ContainerSpec struct { Image string // Run a custom init inside the container. If nil, use the daemon's configured settings. Init *bool - // Ports to publish from the container. - Ports []PortSpec } func (s *ContainerSpec) Validate() error { @@ -53,15 +51,6 @@ func (s *ContainerSpec) Validate() error { return nil } -type PortSpec struct { - Hostname string - HostIP netip.Addr - PublishedPort uint16 - ContainerPort uint16 - Protocol string - Mode string -} - type Service struct { ID string Name string diff --git a/internal/api/service_test.go b/internal/api/service_test.go new file mode 100644 index 00000000..14b1cdaa --- /dev/null +++ b/internal/api/service_test.go @@ -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) + }) + } +}