mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
implement PortSpec parsing
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user