package compose import ( "fmt" "net/netip" "strconv" "strings" "github.com/compose-spec/compose-go/v2/types" "github.com/psviderski/uncloud/pkg/api" ) const PortsExtensionKey = "x-ports" type PortsSource []string // transformServicesPortsExtension transforms both standard 'ports' and 'x-ports' to PortSpecs. func transformServicesPortsExtension(project *types.Project) (*types.Project, error) { return project.WithServicesTransform(func(name string, service types.ServiceConfig) (types.ServiceConfig, error) { // Check for mutual exclusivity hasStandardPorts := len(service.Ports) > 0 hasXPorts := service.Extensions[PortsExtensionKey] != nil if hasStandardPorts && hasXPorts { return service, fmt.Errorf("service %q cannot specify both 'ports' and 'x-ports' directives, use only one", name) } var ( specs []api.PortSpec err error ) if hasStandardPorts { // Convert standard ports directly to api.PortSpec specs, err = convertStandardPortsToPortSpecs(service.Ports) if err != nil { return service, fmt.Errorf("convert standard 'ports' for service '%s': %w", name, err) } } else if hasXPorts { // Use existing x-ports string-based processing for backward compatibility var portsSource PortsSource var ok bool portsSource, ok = service.Extensions[PortsExtensionKey].(PortsSource) if !ok { return service, nil } // Parse the port strings using existing logic specs, err = transformPortsExtension(portsSource) if err != nil { return service, err } } else { // No ports specified return service, nil } // Ensure extensions map exists before setting the port specs if service.Extensions == nil { service.Extensions = make(types.Extensions) } service.Extensions[PortsExtensionKey] = specs return service, nil }) } func transformPortsExtension(ports PortsSource) ([]api.PortSpec, error) { var specs []api.PortSpec for _, port := range ports { spec, err := api.ParsePortSpec(port) if err != nil { return specs, fmt.Errorf("parse port %q: %w", port, err) } specs = append(specs, spec) } return specs, nil } // convertServicePortConfigToPortSpec converts types.ServicePortConfig directly to api.PortSpec func convertServicePortConfigToPortSpec(port types.ServicePortConfig) (api.PortSpec, error) { spec := api.PortSpec{ ContainerPort: uint16(port.Target), Protocol: port.Protocol, Mode: port.Mode, } // Compose parser sets the default protocol to "tcp" and mode to "ingress". We still explicitly set these values // to avoid relying on implicit behavior and improve code robustness. if spec.Protocol == "" { spec.Protocol = api.ProtocolTCP } if spec.Mode == "" { spec.Mode = api.PortModeIngress } // Set published port if specified if port.Published != "" { if strings.Contains(port.Published, "-") { // 'a-b:x' format is not automatically expanded by the compose parser and our PortSpec does not support port // ranges for now. return spec, fmt.Errorf("port range '%s' for published port is not supported, use a single port", port.Published) } publishedPort, err := strconv.ParseUint(port.Published, 10, 16) if err != nil { return spec, fmt.Errorf("invalid published port %q: %w", port.Published, err) } spec.PublishedPort = uint16(publishedPort) } // Set host IP if specified if port.HostIP != "" { hostIP, err := netip.ParseAddr(port.HostIP) if err != nil { return spec, fmt.Errorf("invalid host IP %q: %w", port.HostIP, err) } spec.HostIP = hostIP } // Validate the resulting spec if err := spec.Validate(); err != nil { return spec, fmt.Errorf("invalid port configuration: %w", err) } return spec, nil } // convertStandardPortsToPortSpecs converts []types.ServicePortConfig directly to api.PortSpecs. func convertStandardPortsToPortSpecs(ports []types.ServicePortConfig) ([]api.PortSpec, error) { specs := make([]api.PortSpec, 0, len(ports)) for _, port := range ports { spec, err := convertServicePortConfigToPortSpec(port) if err != nil { return nil, err } specs = append(specs, spec) } return specs, nil }