From af4629b6797a2e2adc10a77534a90a471df6652e Mon Sep 17 00:00:00 2001 From: Pavel Sviderski Date: Mon, 24 Feb 2025 17:37:43 +1000 Subject: [PATCH] feat(dns): add CLI commands to reserve, show, delete cluster domain --- cmd/uncloud/dns/release.go | 54 +++++++++++++++++ cmd/uncloud/dns/reserve.go | 62 +++++++++++++++++++ cmd/uncloud/dns/root.go | 22 +++++++ cmd/uncloud/dns/show.go | 55 +++++++++++++++++ cmd/uncloud/main.go | 2 + internal/dns/api.go | 16 +++++ internal/dns/client.go | 118 +++++++++++++++++++++++++++++++++++++ 7 files changed, 329 insertions(+) create mode 100644 cmd/uncloud/dns/release.go create mode 100644 cmd/uncloud/dns/reserve.go create mode 100644 cmd/uncloud/dns/root.go create mode 100644 cmd/uncloud/dns/show.go create mode 100644 internal/dns/api.go create mode 100644 internal/dns/client.go diff --git a/cmd/uncloud/dns/release.go b/cmd/uncloud/dns/release.go new file mode 100644 index 00000000..52d625c1 --- /dev/null +++ b/cmd/uncloud/dns/release.go @@ -0,0 +1,54 @@ +package dns + +import ( + "context" + "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" +) + +type releaseOptions struct { + cluster string +} + +func NewReleaseCommand() *cobra.Command { + opts := releaseOptions{} + + cmd := &cobra.Command{ + Use: "release", + Short: "Release the reserved cluster domain.", + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return release(cmd.Context(), uncli, opts) + }, + } + + cmd.Flags().StringVarP( + &opts.cluster, "cluster", "c", "", + "Name of the cluster. (default is the current cluster)", + ) + + return cmd +} + +func release(ctx context.Context, uncli *cli.CLI, opts releaseOptions) error { + client, err := uncli.ConnectCluster(ctx, opts.cluster) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer client.Close() + + domain, err := client.ReleaseDomain(ctx, &emptypb.Empty{}) + if err != nil { + if status.Convert(err).Code() == codes.NotFound { + return errors.New("no domain reserved") + } + } + + fmt.Printf("Released cluster domain: %s\n", domain.Name) + return nil +} diff --git a/cmd/uncloud/dns/reserve.go b/cmd/uncloud/dns/reserve.go new file mode 100644 index 00000000..78ba7398 --- /dev/null +++ b/cmd/uncloud/dns/reserve.go @@ -0,0 +1,62 @@ +package dns + +import ( + "context" + "errors" + "fmt" + "github.com/spf13/cobra" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "uncloud/internal/cli" + "uncloud/internal/machine/api/pb" +) + +const DefaultUncloudDNSAPIEndpoint = "https://dns.uncloud.run/v1" + +type reserveOptions struct { + endpoint string + cluster string +} + +func NewReserveCommand() *cobra.Command { + opts := reserveOptions{} + + cmd := &cobra.Command{ + Use: "reserve", + Short: "Reserve a cluster domain in Uncloud DNS.", + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return reserve(cmd.Context(), uncli, opts) + }, + } + + cmd.Flags().StringVar(&opts.endpoint, "endpoint", DefaultUncloudDNSAPIEndpoint, + "API endpoint for the Uncloud DNS service.") + cmd.Flags().StringVarP( + &opts.cluster, "cluster", "c", "", + "Name of the cluster. (default is the current cluster)", + ) + + return cmd +} + +func reserve(ctx context.Context, uncli *cli.CLI, opts reserveOptions) error { + client, err := uncli.ConnectCluster(ctx, opts.cluster) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer client.Close() + + domain, err := client.ReserveDomain(ctx, &pb.ReserveDomainRequest{ApiEndpoint: opts.endpoint}) + if err != nil { + if status.Convert(err).Code() == codes.AlreadyExists { + return errors.New("domain already reserved") + } + return err + } + + fmt.Printf("Reserved cluster domain: %s\n", domain.Name) + fmt.Println("Redeploy the Caddy service ('uc caddy deploy') to configure DNS records for the domain " + + "to route traffic to the services in the cluster.") + return nil +} diff --git a/cmd/uncloud/dns/root.go b/cmd/uncloud/dns/root.go new file mode 100644 index 00000000..2f906414 --- /dev/null +++ b/cmd/uncloud/dns/root.go @@ -0,0 +1,22 @@ +package dns + +import ( + "github.com/spf13/cobra" +) + +func NewRootCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "dns", + Short: "Manage cluster domain in Uncloud DNS.", + Long: "Manage cluster domain in Uncloud DNS.\n" + + "DNS commands allow you to reserve or release a unique '.cluster.uncloud.run' domain for your " + + "cluster. When reserved, Caddy service deployments will automatically update DNS records to route " + + "traffic to the services in the cluster.", + } + cmd.AddCommand( + NewReleaseCommand(), + NewReserveCommand(), + NewShowCommand(), + ) + return cmd +} diff --git a/cmd/uncloud/dns/show.go b/cmd/uncloud/dns/show.go new file mode 100644 index 00000000..df524237 --- /dev/null +++ b/cmd/uncloud/dns/show.go @@ -0,0 +1,55 @@ +package dns + +import ( + "context" + "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" +) + +type showOptions struct { + cluster string +} + +func NewShowCommand() *cobra.Command { + opts := showOptions{} + + cmd := &cobra.Command{ + Use: "show", + Short: "Print the cluster domain name.", + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return show(cmd.Context(), uncli, opts) + }, + } + + cmd.Flags().StringVarP( + &opts.cluster, "cluster", "c", "", + "Name of the cluster. (default is the current cluster)", + ) + + return cmd +} + +func show(ctx context.Context, uncli *cli.CLI, opts showOptions) error { + client, err := uncli.ConnectCluster(ctx, opts.cluster) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer client.Close() + + domain, err := client.GetDomain(ctx, &emptypb.Empty{}) + if err != nil { + if status.Convert(err).Code() == codes.NotFound { + return errors.New("no domain reserved") + } + return err + } + + fmt.Println(domain.Name) + return nil +} diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go index 0caf2356..56d735b1 100644 --- a/cmd/uncloud/main.go +++ b/cmd/uncloud/main.go @@ -7,6 +7,7 @@ import ( "os" "strings" "uncloud/cmd/uncloud/caddy" + "uncloud/cmd/uncloud/dns" "uncloud/cmd/uncloud/machine" "uncloud/cmd/uncloud/service" "uncloud/internal/cli" @@ -43,6 +44,7 @@ func main() { cmd.AddCommand( caddy.NewRootCommand(), + dns.NewRootCommand(), machine.NewRootCommand(), service.NewRootCommand(), service.NewInspectCommand(), diff --git a/internal/dns/api.go b/internal/dns/api.go new file mode 100644 index 00000000..6d8cfc01 --- /dev/null +++ b/internal/dns/api.go @@ -0,0 +1,16 @@ +package dns + +type DomainResponse struct { + Name string `json:"name,omitempty"` + Token string `json:"token,omitempty"` +} + +type AuthErrorResponse struct { + Status int `json:"status,omitempty"` + Message string `json:"msg,omitempty"` + Data authErrorData `json:"data,omitempty"` +} + +type authErrorData struct { + NoDomain bool `json:"noDomain,omitempty"` +} diff --git a/internal/dns/client.go b/internal/dns/client.go new file mode 100644 index 00000000..146d6b27 --- /dev/null +++ b/internal/dns/client.go @@ -0,0 +1,118 @@ +package dns + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" +) + +// The dns package code is based on https://github.com/acorn-io/runtime/blob/main/pkg/dns. + +// Client handles interactions with the Uncloud DNS API service. +type Client interface { + // ReserveDomain calls Uncloud DNS to reserve a new domain. It returns the domain, a token for authentication, + // and an error. + ReserveDomain(endpoint string) (string, string, error) +} + +// ErrAuthNoDomain indicates that a request failed authentication because the domain was not found. +// If encountered, a new domain needs to be reserved. +var ErrAuthNoDomain = errors.New("the supplied domain failed authentication") + +// NewClient creates a new AcornDNS client +func NewClient() Client { + return &client{ + c: http.DefaultClient, + } +} + +type client struct { + c *http.Client +} + +func (c *client) ReserveDomain(endpoint string) (string, string, error) { + url := fmt.Sprintf("%s/%s", endpoint, "domains") + + req, err := c.request(http.MethodPost, url, nil, "") + if err != nil { + return "", "", err + } + + resp := &DomainResponse{} + err = c.do(req, resp) + if err != nil { + return "", "", err + } + + domain := resp.Name + if strings.HasPrefix(domain, ".") { + domain = domain[1:] + } + return domain, resp.Token, nil +} + +func (c *client) request(method string, url string, body io.Reader, token string) (*http.Request, error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + + if token != "" { + bearer := "Bearer " + token + req.Header.Add("Authorization", bearer) + } + + return req, nil +} + +func (c *client) do(req *http.Request, responseBody any) error { + slog.Debug("Making request to DNS service.", "method", req.Method, "url", req.URL) + + resp, err := c.c.Do(req) + if err != nil { + return err + } + + slog.Debug("Response code for request to DNS service.", + "method", req.Method, "url", req.URL, "code", resp.StatusCode) + // When err is nil, resp contains a non-nil resp.Body which must be closed. + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response body: %w", err) + } + + if resp.StatusCode == http.StatusUnauthorized { + var authError AuthErrorResponse + + err = json.Unmarshal(body, &authError) + if err != nil { + return fmt.Errorf("unmarshal auth error response: %w", err) + } + + if authError.Data.NoDomain { + return ErrAuthNoDomain + } + + return errors.New("authentication failed") + } + + if code := resp.StatusCode; code < 200 || code > 300 { + return fmt.Errorf("unexpected response status code: %d", code) + } + + if responseBody != nil { + err = json.Unmarshal(body, responseBody) + if err != nil { + return fmt.Errorf("unmarshal response body (%s): %w", string(body), err) + } + } + + return nil +}