mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
134 lines
3.9 KiB
Go
134 lines
3.9 KiB
Go
package deploy
|
|
|
|
import (
|
|
"fmt"
|
|
"github.com/distribution/reference"
|
|
"github.com/psviderski/uncloud/internal/secret"
|
|
"github.com/psviderski/uncloud/pkg/api"
|
|
"strings"
|
|
)
|
|
|
|
type ImageDigestResolver interface {
|
|
Resolve(image string) (string, error)
|
|
}
|
|
|
|
// ServiceSpecResolver transforms user-provided service specs into deployment-ready form.
|
|
type ServiceSpecResolver struct {
|
|
ClusterDomain string
|
|
ImageResolver ImageDigestResolver
|
|
}
|
|
|
|
// Resolve transforms a service spec into its fully resolved form ready for deployment.
|
|
func (r *ServiceSpecResolver) Resolve(spec *api.ServiceSpec) error {
|
|
if err := spec.Validate(); err != nil {
|
|
return fmt.Errorf("invalid service spec: %w", err)
|
|
}
|
|
|
|
steps := []func(*api.ServiceSpec) error{
|
|
r.applyDefaults,
|
|
r.resolveServiceName,
|
|
r.resolveImageDigest,
|
|
r.expandIngressPorts,
|
|
}
|
|
|
|
for _, step := range steps {
|
|
if err := step(spec); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *ServiceSpecResolver) applyDefaults(spec *api.ServiceSpec) error {
|
|
if spec.Mode == "" {
|
|
spec.Mode = api.ServiceModeReplicated
|
|
}
|
|
// Ensure the replicated service has at least one replica.
|
|
if spec.Mode == api.ServiceModeReplicated && spec.Replicas == 0 {
|
|
spec.Replicas = 1
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *ServiceSpecResolver) resolveServiceName(spec *api.ServiceSpec) error {
|
|
if spec.Name != "" {
|
|
return nil
|
|
}
|
|
|
|
// Generate a random service name from the image when not provided.
|
|
img, err := reference.ParseDockerRef(spec.Container.Image)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid image: %w", err)
|
|
}
|
|
// Get the image name without the repository and tag/digest parts.
|
|
imageName := reference.FamiliarName(img)
|
|
// Get the last part of the image name (path), e.g. "nginx" from "bitnami/nginx".
|
|
if i := strings.LastIndex(imageName, "/"); i != -1 {
|
|
imageName = imageName[i+1:]
|
|
}
|
|
// Append a random suffix to the image name to generate an optimistically unique service name.
|
|
suffix, err := secret.RandomAlphaNumeric(4)
|
|
if err != nil {
|
|
return fmt.Errorf("generate random suffix: %w", err)
|
|
}
|
|
spec.Name = fmt.Sprintf("%s-%s", imageName, suffix)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *ServiceSpecResolver) resolveImageDigest(spec *api.ServiceSpec) error {
|
|
if r.ImageResolver == nil {
|
|
// Skip digest resolution when no resolver is provided.
|
|
return nil
|
|
}
|
|
|
|
imageDigest, err := r.ImageResolver.Resolve(spec.Container.Image)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve image digest: %w", err)
|
|
}
|
|
spec.Container.Image = imageDigest
|
|
|
|
return nil
|
|
}
|
|
|
|
// expandIngressPorts processes HTTP(S) ingress ports in a service spec by:
|
|
// 1. Setting a default hostname (service-name.cluster-domain) for ports without a hostname.
|
|
// 2. Duplicating a port with a cluster domain hostname for ports with external domains.
|
|
// This ensures every ingress port is accessible via the cluster domain, while preserving any custom domains specified
|
|
// by the user.
|
|
func (r *ServiceSpecResolver) expandIngressPorts(spec *api.ServiceSpec) error {
|
|
for i, port := range spec.Ports {
|
|
if port.Protocol != api.ProtocolHTTP && port.Protocol != api.ProtocolHTTPS {
|
|
continue
|
|
}
|
|
|
|
if port.Hostname == "" {
|
|
if r.ClusterDomain == "" {
|
|
return fmt.Errorf("cluster domain must be reserved to generate hostname for ingress port: %d/%s",
|
|
port.ContainerPort, port.Protocol)
|
|
}
|
|
// Assign the default hostname (service-name.cluster-domain).
|
|
spec.Ports[i].Hostname = fmt.Sprintf("%s.%s", spec.Name, r.ClusterDomain)
|
|
} else {
|
|
if r.ClusterDomain == "" {
|
|
// When no cluster domain is reserved, use only the provided hostname.
|
|
continue
|
|
}
|
|
|
|
if strings.HasSuffix(port.Hostname, "."+r.ClusterDomain) {
|
|
// If the hostname is already a cluster subdomain, use as is.
|
|
continue
|
|
}
|
|
// For external domains, duplicate the port with a service-name.cluster-domain hostname so the service
|
|
// can be accessed via both hostnames.
|
|
newPort := port
|
|
newPort.Hostname = fmt.Sprintf("%s.%s", spec.Name, r.ClusterDomain)
|
|
spec.Ports = append(spec.Ports, newPort)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|