diff --git a/cmd/uncloud/caddy/deploy.go b/cmd/uncloud/caddy/deploy.go index a5505a94..14a4ec6a 100644 --- a/cmd/uncloud/caddy/deploy.go +++ b/cmd/uncloud/caddy/deploy.go @@ -7,8 +7,6 @@ import ( "github.com/charmbracelet/huh" "github.com/docker/compose/v2/pkg/progress" "github.com/spf13/cobra" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" "maps" "slices" "strings" @@ -175,8 +173,8 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { } fmt.Println() - if _, err = clusterClient.GetDomain(ctx, nil); err != nil { - if status.Convert(err).Code() == codes.NotFound { + if _, err = clusterClient.GetDomain(ctx); err != nil { + if errors.Is(err, client.ErrNotFound) { fmt.Println("Skipping DNS records update as no cluster domain is reserved (see 'uc dns').") return nil } diff --git a/cmd/uncloud/dns/show.go b/cmd/uncloud/dns/show.go index df524237..31198546 100644 --- a/cmd/uncloud/dns/show.go +++ b/cmd/uncloud/dns/show.go @@ -5,10 +5,8 @@ import ( "errors" "fmt" "github.com/spf13/cobra" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/emptypb" "uncloud/internal/cli" + "uncloud/internal/cli/client" ) type showOptions struct { @@ -36,20 +34,20 @@ func NewShowCommand() *cobra.Command { } func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error { - client, err := uncli.ConnectCluster(ctx, opts.cluster) + clusterClient, err := uncli.ConnectCluster(ctx, opts.cluster) if err != nil { return fmt.Errorf("connect to cluster: %w", err) } - defer client.Close() + defer clusterClient.Close() - domain, err := client.GetDomain(ctx, &emptypb.Empty{}) + domain, err := clusterClient.GetDomain(ctx) if err != nil { - if status.Convert(err).Code() == codes.NotFound { + if errors.Is(err, client.ErrNotFound) { return errors.New("no domain reserved") } return err } - fmt.Println(domain.Name) + fmt.Println(domain) return nil } diff --git a/internal/cli/client/dns.go b/internal/cli/client/dns.go index 509a1207..cbfd2b8f 100644 --- a/internal/cli/client/dns.go +++ b/internal/cli/client/dns.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "github.com/docker/compose/v2/pkg/progress" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "io" "net/http" "sync" @@ -13,6 +15,19 @@ import ( "uncloud/internal/machine/caddyfile" ) +// GetDomain returns the cluster domain name or ErrNotFound if it hasn't been reserved yet. +func (cli *Client) GetDomain(ctx context.Context) (string, error) { + domain, err := cli.ClusterClient.GetDomain(ctx, nil) + if err != nil { + if status.Convert(err).Code() == codes.NotFound { + return "", ErrNotFound + } + return "", err + } + + return domain.Name, nil +} + var ErrNoReachableMachines = errors.New("no internet-reachable machines running service containers") // CreateIngressRecords verifies which machines running the specified service (typically Caddy) are reachable from diff --git a/internal/cli/client/resolver.go b/internal/cli/client/resolver.go new file mode 100644 index 00000000..a8c37f4e --- /dev/null +++ b/internal/cli/client/resolver.go @@ -0,0 +1,103 @@ +package client + +import ( + "fmt" + "github.com/distribution/reference" + "strings" + "uncloud/internal/api" + "uncloud/internal/secret" +) + +// ServiceSpecResolver transforms user-provided service specs into deployment-ready form. +type ServiceSpecResolver struct { + ClusterDomain string +} + +func NewServiceSpecResolver(clusterDomain string) *ServiceSpecResolver { + return &ServiceSpecResolver{ClusterDomain: clusterDomain} +} + +// 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.resolveServiceName, + r.expandIngressPorts, + } + + for _, step := range steps { + if err := step(spec); err != nil { + return err + } + } + + 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 +} + +// expandIngressPorts processes 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.Mode != api.PortModeIngress { + continue + } + + if port.Hostname == "" { + if r.ClusterDomain == "" { + return fmt.Errorf("cluster domain must be reserved to generate hostname for ingress port %s", + port) + } + // 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 +} diff --git a/internal/cli/client/service.go b/internal/cli/client/service.go index 101fbda3..3420a372 100644 --- a/internal/cli/client/service.go +++ b/internal/cli/client/service.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "github.com/distribution/reference" "github.com/docker/compose/v2/pkg/progress" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" @@ -12,13 +11,28 @@ import ( "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "slices" - "strings" "sync" "uncloud/internal/api" "uncloud/internal/machine/api/pb" "uncloud/internal/secret" ) +func (cli *Client) PrepareDeploymentSpec(ctx context.Context, spec api.ServiceSpec) (api.ServiceSpec, error) { + domain, err := cli.GetDomain(ctx) + if err != nil && !errors.Is(err, ErrNotFound) { + return spec, fmt.Errorf("get domain: %w", err) + } + + // If the domain is not found (not reserved), an empty domain is used for the resolver. + resolver := NewServiceSpecResolver(domain) + + if err = resolver.Resolve(&spec); err != nil { + return spec, err + } + + return spec, nil +} + type RunServiceResponse struct { ID string Name string @@ -31,26 +45,7 @@ func (cli *Client) RunService(ctx context.Context, spec api.ServiceSpec) (RunSer return resp, fmt.Errorf("invalid service spec: %w", err) } - img, err := reference.ParseDockerRef(spec.Container.Image) - if err != nil { - return resp, fmt.Errorf("invalid image: %w", err) - } - - if spec.Name == "" { - // Generate a random service name from the image if not specified. - // 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 resp, fmt.Errorf("generate random suffix: %w", err) - } - spec.Name = fmt.Sprintf("%s-%s", imageName, suffix) - } else { + if spec.Name != "" { // Optimistically check if a service with the specified name already exists. _, err := cli.InspectService(ctx, spec.Name) if err == nil { @@ -61,6 +56,11 @@ func (cli *Client) RunService(ctx context.Context, spec api.ServiceSpec) (RunSer } } + var err error + if spec, err = cli.PrepareDeploymentSpec(ctx, spec); err != nil { + return resp, fmt.Errorf("prepare service spec ready for deployment: %w", err) + } + serviceID, err := secret.NewID() if err != nil { return resp, fmt.Errorf("generate service ID: %w", err)