From 24c24cb71d0c76c2ad8534fc11a1be9f4c823f97 Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Tue, 2 Jun 2026 09:16:37 -0400 Subject: [PATCH 1/5] feat(dns): add support for DNS Zones and TSIG Keys --- pkg/commands/commands.go | 29 ++ pkg/commands/dns/doc.go | 2 + pkg/commands/dns/root.go | 31 +++ pkg/commands/dns/tsigkey/common.go | 17 ++ pkg/commands/dns/tsigkey/create.go | 95 +++++++ pkg/commands/dns/tsigkey/delete.go | 75 ++++++ pkg/commands/dns/tsigkey/describe.go | 70 +++++ pkg/commands/dns/tsigkey/list.go | 75 ++++++ pkg/commands/dns/tsigkey/root.go | 31 +++ pkg/commands/dns/tsigkey/tsigkey_test.go | 324 +++++++++++++++++++++++ pkg/commands/dns/tsigkey/update.go | 107 ++++++++ pkg/commands/dns/zone/common.go | 14 + pkg/commands/dns/zone/create.go | 119 +++++++++ pkg/commands/dns/zone/delete.go | 75 ++++++ pkg/commands/dns/zone/describe.go | 70 +++++ pkg/commands/dns/zone/doc.go | 2 + pkg/commands/dns/zone/list.go | 75 ++++++ pkg/commands/dns/zone/root.go | 31 +++ pkg/commands/dns/zone/update.go | 118 +++++++++ pkg/commands/dns/zone/zone_test.go | 316 ++++++++++++++++++++++ pkg/text/dnszone.go | 66 +++++ pkg/text/tsigkey.go | 39 +++ 22 files changed, 1781 insertions(+) create mode 100644 pkg/commands/dns/doc.go create mode 100644 pkg/commands/dns/root.go create mode 100644 pkg/commands/dns/tsigkey/common.go create mode 100644 pkg/commands/dns/tsigkey/create.go create mode 100644 pkg/commands/dns/tsigkey/delete.go create mode 100644 pkg/commands/dns/tsigkey/describe.go create mode 100644 pkg/commands/dns/tsigkey/list.go create mode 100644 pkg/commands/dns/tsigkey/root.go create mode 100644 pkg/commands/dns/tsigkey/tsigkey_test.go create mode 100644 pkg/commands/dns/tsigkey/update.go create mode 100644 pkg/commands/dns/zone/common.go create mode 100644 pkg/commands/dns/zone/create.go create mode 100644 pkg/commands/dns/zone/delete.go create mode 100644 pkg/commands/dns/zone/describe.go create mode 100644 pkg/commands/dns/zone/doc.go create mode 100644 pkg/commands/dns/zone/list.go create mode 100644 pkg/commands/dns/zone/root.go create mode 100644 pkg/commands/dns/zone/update.go create mode 100644 pkg/commands/dns/zone/zone_test.go create mode 100644 pkg/text/dnszone.go create mode 100644 pkg/text/tsigkey.go diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 6fff7917e..1caf7f22e 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -64,6 +64,9 @@ import ( "github.com/fastly/cli/pkg/commands/configstoreentry" "github.com/fastly/cli/pkg/commands/dashboard" dashboardItem "github.com/fastly/cli/pkg/commands/dashboard/item" + "github.com/fastly/cli/pkg/commands/dns" + dnstsigkey "github.com/fastly/cli/pkg/commands/dns/tsigkey" + dnszone "github.com/fastly/cli/pkg/commands/dns/zone" "github.com/fastly/cli/pkg/commands/domain" "github.com/fastly/cli/pkg/commands/install" "github.com/fastly/cli/pkg/commands/ip" @@ -282,6 +285,19 @@ func Define( // nolint:revive // function-length dashboardItemDescribe := dashboardItem.NewDescribeCommand(dashboardItemCmdRoot.CmdClause, data) dashboardItemUpdate := dashboardItem.NewUpdateCommand(dashboardItemCmdRoot.CmdClause, data) dashboardItemDelete := dashboardItem.NewDeleteCommand(dashboardItemCmdRoot.CmdClause, data) + dnsCmdRoot := dns.NewRootCommand(app, data) + dnsTSIGKeyCmdRoot := dnstsigkey.NewRootCommand(dnsCmdRoot.CmdClause, data) + dnsTSIGKeyCreate := dnstsigkey.NewCreateCommand(dnsTSIGKeyCmdRoot.CmdClause, data) + dnsTSIGKeyDelete := dnstsigkey.NewDeleteCommand(dnsTSIGKeyCmdRoot.CmdClause, data) + dnsTSIGKeyDescribe := dnstsigkey.NewDescribeCommand(dnsTSIGKeyCmdRoot.CmdClause, data) + dnsTSIGKeyList := dnstsigkey.NewListCommand(dnsTSIGKeyCmdRoot.CmdClause, data) + dnsTSIGKeyUpdate := dnstsigkey.NewUpdateCommand(dnsTSIGKeyCmdRoot.CmdClause, data) + dnsZoneCmdRoot := dnszone.NewRootCommand(dnsCmdRoot.CmdClause, data) + dnsZoneCreate := dnszone.NewCreateCommand(dnsZoneCmdRoot.CmdClause, data) + dnsZoneDelete := dnszone.NewDeleteCommand(dnsZoneCmdRoot.CmdClause, data) + dnsZoneDescribe := dnszone.NewDescribeCommand(dnsZoneCmdRoot.CmdClause, data) + dnsZoneList := dnszone.NewListCommand(dnsZoneCmdRoot.CmdClause, data) + dnsZoneUpdate := dnszone.NewUpdateCommand(dnsZoneCmdRoot.CmdClause, data) domainCmdRoot := domain.NewRootCommand(app, data) domainCreate := domain.NewCreateCommand(domainCmdRoot.CmdClause, data) domainDelete := domain.NewDeleteCommand(domainCmdRoot.CmdClause, data) @@ -1156,6 +1172,19 @@ func Define( // nolint:revive // function-length dashboardItemDescribe, dashboardItemUpdate, dashboardItemDelete, + dnsCmdRoot, + dnsTSIGKeyCmdRoot, + dnsTSIGKeyCreate, + dnsTSIGKeyDelete, + dnsTSIGKeyDescribe, + dnsTSIGKeyList, + dnsTSIGKeyUpdate, + dnsZoneCmdRoot, + dnsZoneCreate, + dnsZoneDelete, + dnsZoneDescribe, + dnsZoneList, + dnsZoneUpdate, domainCmdRoot, domainCreate, domainDelete, diff --git a/pkg/commands/dns/doc.go b/pkg/commands/dns/doc.go new file mode 100644 index 000000000..18cfe98b3 --- /dev/null +++ b/pkg/commands/dns/doc.go @@ -0,0 +1,2 @@ +// Package dns contains commands to inspect and manipulate Fastly DNS Zones and TSIG Keys. +package dns diff --git a/pkg/commands/dns/root.go b/pkg/commands/dns/root.go new file mode 100644 index 000000000..ca80d0e70 --- /dev/null +++ b/pkg/commands/dns/root.go @@ -0,0 +1,31 @@ +package dns + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "dns" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly DNS Zones and TSIG Keys") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/dns/tsigkey/common.go b/pkg/commands/dns/tsigkey/common.go new file mode 100644 index 000000000..a3a410353 --- /dev/null +++ b/pkg/commands/dns/tsigkey/common.go @@ -0,0 +1,17 @@ +package tsigkey + +// SortOptions are the valid values for the --sort flag. +// Since Kingpin interprets any '-' as a flag, we'll need to remap the API enums +// to more friendly values and then send back to the API accordingly. +var SortOptions = []string{"name_asc", "name_desc", "created_at_asc", "created_at_desc"} + +// sortAPIValue maps CLI sort values to the API sort parameter. +var sortAPIValue = map[string]string{ + "name_asc": "name", + "name_desc": "-name", + "created_at_asc": "created_at", + "created_at_desc": "-created_at", +} + +// AlgorithmOptions are the valid values for the --algorithm flag. +var AlgorithmOptions = []string{"hmac-sha224", "hmac-sha256", "hmac-sha384", "hmac-sha512"} diff --git a/pkg/commands/dns/tsigkey/create.go b/pkg/commands/dns/tsigkey/create.go new file mode 100644 index 000000000..c02c5c35c --- /dev/null +++ b/pkg/commands/dns/tsigkey/create.go @@ -0,0 +1,95 @@ +package tsigkey + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/dns/v1/tsigkeys" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a TSIG key. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + name string + algorithm string + secret string + + // Optional. + description argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a TSIG key").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the TSIG key.").Required().StringVar(&c.name) + c.CmdClause.Flag("algorithm", "The algorithm of the TSIG key. Valid values are: hmac-sha224, hmac-sha256, hmac-sha384, hmac-sha512.").Required().HintOptions(AlgorithmOptions...).EnumVar(&c.algorithm, AlgorithmOptions...) + c.CmdClause.Flag("secret", "The Base64 encoded secret key.").Required().StringVar(&c.secret) + + // Optional. + c.CmdClause.Flag("description", "A freeform descriptive note.").Action(c.description.Set).StringVar(&c.description.Value) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if strings.Contains(c.name, " ") { + return fmt.Errorf("invalid --name value %q: TSIG key names cannot contain spaces", c.name) + } + if len(c.name) > 255 { + return fmt.Errorf("invalid --name value %q: TSIG key names cannot exceed 255 characters", c.name) + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &tsigkeys.CreateInput{ + Name: &c.name, + Algorithm: &c.algorithm, + Secret: &c.secret, + } + if c.description.WasSet { + input.Description = &c.description.Value + } + + k, err := tsigkeys.Create(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Name": c.name, + }) + return err + } + + if ok, err := c.WriteJSON(out, k); ok { + return err + } + + text.Success(out, "Created TSIG key '%s' (tsig-key-id: %s)", *k.Name, *k.ID) + return nil +} diff --git a/pkg/commands/dns/tsigkey/delete.go b/pkg/commands/dns/tsigkey/delete.go new file mode 100644 index 000000000..846ce8512 --- /dev/null +++ b/pkg/commands/dns/tsigkey/delete.go @@ -0,0 +1,75 @@ +package tsigkey + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/dns/v1/tsigkeys" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a TSIG key. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + tsigKeyID string +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a TSIG key").Alias("remove") + + // Required. + c.CmdClause.Flag("tsig-key-id", "The TSIG key ID to delete.").Required().StringVar(&c.tsigKeyID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := tsigkeys.Delete(context.TODO(), fc, &tsigkeys.DeleteInput{ + TSIGKeyID: &c.tsigKeyID, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TSIG Key ID": c.tsigKeyID, + }) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{c.tsigKeyID, true} + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted TSIG key (tsig-key-id: %s)", c.tsigKeyID) + return nil +} diff --git a/pkg/commands/dns/tsigkey/describe.go b/pkg/commands/dns/tsigkey/describe.go new file mode 100644 index 000000000..607040d66 --- /dev/null +++ b/pkg/commands/dns/tsigkey/describe.go @@ -0,0 +1,70 @@ +package tsigkey + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/dns/v1/tsigkeys" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a TSIG key. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + tsigKeyID string +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Describe a TSIG key").Alias("get") + + // Required. + c.CmdClause.Flag("tsig-key-id", "The TSIG key ID to describe.").Required().StringVar(&c.tsigKeyID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + k, err := tsigkeys.Get(context.TODO(), fc, &tsigkeys.GetInput{ + TSIGKeyID: &c.tsigKeyID, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TSIG Key ID": c.tsigKeyID, + }) + return err + } + + if ok, err := c.WriteJSON(out, k); ok { + return err + } + + text.PrintTSIGKey(out, "", k) + return nil +} diff --git a/pkg/commands/dns/tsigkey/list.go b/pkg/commands/dns/tsigkey/list.go new file mode 100644 index 000000000..752704c09 --- /dev/null +++ b/pkg/commands/dns/tsigkey/list.go @@ -0,0 +1,75 @@ +package tsigkey + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/dns/v1/tsigkeys" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list TSIG keys. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + name argparser.OptionalString + sort argparser.OptionalString +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List TSIG keys") + + // Optional. + c.CmdClause.Flag("name", "Filter TSIG keys to only those containing the provided name.").Action(c.name.Set).StringVar(&c.name.Value) + c.CmdClause.Flag("sort", "Order in which to list results. Valid values are: name_asc, name_desc, created_at_asc, created_at_desc.").Action(c.sort.Set).HintOptions(SortOptions...).EnumVar(&c.sort.Value, SortOptions...) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &tsigkeys.ListInput{} + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.sort.WasSet { + v := sortAPIValue[c.sort.Value] + input.Sort = &v + } + + keys, err := tsigkeys.List(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, keys); ok { + return err + } + + text.PrintTSIGKeyTbl(out, keys) + return nil +} diff --git a/pkg/commands/dns/tsigkey/root.go b/pkg/commands/dns/tsigkey/root.go new file mode 100644 index 000000000..f59eefb54 --- /dev/null +++ b/pkg/commands/dns/tsigkey/root.go @@ -0,0 +1,31 @@ +package tsigkey + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "tsig-key" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly TSIG Keys") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/dns/tsigkey/tsigkey_test.go b/pkg/commands/dns/tsigkey/tsigkey_test.go new file mode 100644 index 000000000..b6f321ab1 --- /dev/null +++ b/pkg/commands/dns/tsigkey/tsigkey_test.go @@ -0,0 +1,324 @@ +package tsigkey_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/go-fastly/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/dns/v1/tsigkeys" + + dnsroot "github.com/fastly/cli/pkg/commands/dns" + root "github.com/fastly/cli/pkg/commands/dns/tsigkey" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/text" +) + +const ( + tsigKeyID = "tsig-key-id-123" + tsigKeyName = "my-tsig-key" + tsigAlgo = "hmac-sha256" + tsigSecret = "dGVzdHNlY3JldA==" +) + +var testKey = tsigkeys.TSIGKey{ + ID: fastly.ToPointer(tsigKeyID), + Name: fastly.ToPointer(tsigKeyName), + Algorithm: fastly.ToPointer(tsigAlgo), +} + +func TestTSIGKeyCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "", + WantError: "error parsing arguments: required flag", + }, + { + Args: fmt.Sprintf("--name %s --algorithm %s --secret %s", tsigKeyName, tsigAlgo, tsigSecret), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(testKey))), + }, + }, + }, + WantOutput: fmt.Sprintf("SUCCESS: Created TSIG key '%s' (tsig-key-id: %s)", tsigKeyName, tsigKeyID), + }, + { + Args: fmt.Sprintf("--name `invalid name` --algorithm %s --secret %s", tsigAlgo, tsigSecret), + WantError: "TSIG key names cannot contain spaces", + }, + { + Args: fmt.Sprintf("--name %s --algorithm %s --secret %s", strings.Repeat("a", 256), tsigAlgo, tsigSecret), + WantError: "TSIG key names cannot exceed 255 characters", + }, + { + Args: fmt.Sprintf("--verbose --json --name %s --algorithm %s --secret %s", tsigKeyName, tsigAlgo, tsigSecret), + WantError: "invalid flag combination, --verbose and --json", + }, + { + Args: fmt.Sprintf("--name %s --algorithm %s --secret %s --json", tsigKeyName, tsigAlgo, tsigSecret), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(testKey))), + }, + }, + }, + WantOutput: string(testutil.GenJSON(testKey)), + }, + { + Args: fmt.Sprintf("--name %s --algorithm %s --secret %s", tsigKeyName, tsigAlgo, tsigSecret), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(strings.NewReader(`{"errors": [{"title": "Bad Request"}]}`)), + }, + }, + }, + WantError: "400 - Bad Request", + }, + } + testutil.RunCLIScenarios(t, []string{dnsroot.CommandName, root.CommandName, "create"}, scenarios) +} + +func TestTSIGKeyDescribe(t *testing.T) { + resp := testutil.GenJSON(testKey) + + scenarios := []testutil.CLIScenario{ + { + Args: "", + WantError: "error parsing arguments: required flag --tsig-key-id not provided", + }, + { + Args: "--verbose --json --tsig-key-id " + tsigKeyID, + WantError: "invalid flag combination, --verbose and --json", + }, + { + Args: "--tsig-key-id " + tsigKeyID, + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(resp)), + }, + }, + }, + WantOutput: fmtKey(&testKey), + }, + { + Args: "--tsig-key-id " + tsigKeyID + " --json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(resp)), + }, + }, + }, + WantOutput: string(resp), + }, + { + Args: "--tsig-key-id " + tsigKeyID, + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(strings.NewReader(`{"errors": [{"title": "Not Found"}]}`)), + }, + }, + }, + WantError: "404 - Not Found", + }, + } + testutil.RunCLIScenarios(t, []string{dnsroot.CommandName, root.CommandName, "describe"}, scenarios) +} + +func TestTSIGKeyDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "", + WantError: "error parsing arguments: required flag --tsig-key-id not provided", + }, + { + Args: "--tsig-key-id " + tsigKeyID, + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + Body: http.NoBody, + }, + }, + }, + WantOutput: fmt.Sprintf("SUCCESS: Deleted TSIG key (tsig-key-id: %s)", tsigKeyID), + }, + { + Args: "--tsig-key-id " + tsigKeyID + " --json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + Body: http.NoBody, + }, + }, + }, + WantOutput: fmt.Sprintf(`"id": %q`, tsigKeyID), + }, + { + Args: "--tsig-key-id " + tsigKeyID, + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(strings.NewReader(`{"errors": [{"title": "Not Found"}]}`)), + }, + }, + }, + WantError: "404 - Not Found", + }, + } + testutil.RunCLIScenarios(t, []string{dnsroot.CommandName, root.CommandName, "delete"}, scenarios) +} + +func TestTSIGKeyList(t *testing.T) { + keys := []tsigkeys.TSIGKey{testKey} + resp := testutil.GenJSON(tsigkeys.TSIGKeys{ + Data: keys, + Meta: tsigkeys.MetaTSIGKeys{}, + }) + + scenarios := []testutil.CLIScenario{ + { + Args: "--verbose --json", + WantError: "invalid flag combination, --verbose and --json", + }, + { + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(resp)), + }, + }, + }, + WantOutput: tsigKeyName, + }, + { + Args: "--json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(resp)), + }, + }, + }, + WantOutput: string(testutil.GenJSON(keys)), + }, + { + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(strings.NewReader(`{"errors": [{"title": "Bad Request"}]}`)), + }, + }, + }, + WantError: "400 - Bad Request", + }, + } + testutil.RunCLIScenarios(t, []string{dnsroot.CommandName, root.CommandName, "list"}, scenarios) +} + +func TestTSIGKeyUpdate(t *testing.T) { + updated := tsigkeys.TSIGKey{ + ID: fastly.ToPointer(tsigKeyID), + Name: fastly.ToPointer("new-name"), + Algorithm: fastly.ToPointer(tsigAlgo), + } + + scenarios := []testutil.CLIScenario{ + { + Args: "", + WantError: "error parsing arguments: required flag --tsig-key-id not provided", + }, + { + Args: "--verbose --json --tsig-key-id " + tsigKeyID, + WantError: "invalid flag combination, --verbose and --json", + }, + { + Args: fmt.Sprintf("--tsig-key-id %s --name `invalid name`", tsigKeyID), + WantError: "TSIG key names cannot contain spaces", + }, + { + Args: fmt.Sprintf("--tsig-key-id %s --name %s", tsigKeyID, strings.Repeat("a", 256)), + WantError: "TSIG key names cannot exceed 255 characters", + }, + { + Args: "--tsig-key-id " + tsigKeyID + " --name new-name", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updated))), + }, + }, + }, + WantOutput: fmt.Sprintf("SUCCESS: Updated TSIG key '%s' (tsig-key-id: %s)", "new-name", tsigKeyID), + }, + { + Args: "--tsig-key-id " + tsigKeyID + " --json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updated))), + }, + }, + }, + WantOutput: string(testutil.GenJSON(updated)), + }, + { + Args: "--tsig-key-id " + tsigKeyID, + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(strings.NewReader(`{"errors": [{"title": "Bad Request"}]}`)), + }, + }, + }, + WantError: "400 - Bad Request", + }, + } + testutil.RunCLIScenarios(t, []string{dnsroot.CommandName, root.CommandName, "update"}, scenarios) +} + +func fmtKey(k *tsigkeys.TSIGKey) string { + var b bytes.Buffer + text.PrintTSIGKey(&b, "", k) + return b.String() +} diff --git a/pkg/commands/dns/tsigkey/update.go b/pkg/commands/dns/tsigkey/update.go new file mode 100644 index 000000000..090610d79 --- /dev/null +++ b/pkg/commands/dns/tsigkey/update.go @@ -0,0 +1,107 @@ +package tsigkey + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/dns/v1/tsigkeys" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a TSIG key. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + tsigKeyID string + + // Optional. + name argparser.OptionalString + algorithm argparser.OptionalString + secret argparser.OptionalString + description argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a TSIG key") + + // Required. + c.CmdClause.Flag("tsig-key-id", "The TSIG key ID to update.").Required().StringVar(&c.tsigKeyID) + + // Optional. + c.CmdClause.Flag("name", "The name of the TSIG key.").Action(c.name.Set).StringVar(&c.name.Value) + c.CmdClause.Flag("algorithm", "The algorithm of the TSIG key. Valid values are: hmac-sha224, hmac-sha256, hmac-sha384, hmac-sha512.").Action(c.algorithm.Set).HintOptions(AlgorithmOptions...).EnumVar(&c.algorithm.Value, AlgorithmOptions...) + c.CmdClause.Flag("secret", "The Base64 encoded secret key.").Action(c.secret.Set).StringVar(&c.secret.Value) + c.CmdClause.Flag("description", "A freeform descriptive note.").Action(c.description.Set).StringVar(&c.description.Value) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &tsigkeys.UpdateInput{ + TSIGKeyID: &c.tsigKeyID, + } + if c.name.WasSet { + if strings.Contains(c.name.Value, " ") { + return fmt.Errorf("invalid --name value %q: TSIG key names cannot contain spaces", c.name.Value) + } + if len(c.name.Value) > 255 { + return fmt.Errorf("invalid --name value %q: TSIG key names cannot exceed 255 characters", c.name.Value) + } + input.Name = &c.name.Value + } + if c.algorithm.WasSet { + input.Algorithm = &c.algorithm.Value + } + if c.secret.WasSet { + input.Secret = &c.secret.Value + } + if c.description.WasSet { + if strings.TrimSpace(c.description.Value) == "" { + input.Description = fastly.NullValue[string]() + } else { + input.Description = fastly.NewNullable(c.description.Value) + } + } + + k, err := tsigkeys.Update(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TSIG Key ID": c.tsigKeyID, + }) + return err + } + + if ok, err := c.WriteJSON(out, k); ok { + return err + } + + text.Success(out, "Updated TSIG key '%s' (tsig-key-id: %s)", *k.Name, *k.ID) + return nil +} diff --git a/pkg/commands/dns/zone/common.go b/pkg/commands/dns/zone/common.go new file mode 100644 index 000000000..61786773f --- /dev/null +++ b/pkg/commands/dns/zone/common.go @@ -0,0 +1,14 @@ +package zone + +// SortOptions are the valid values for the --sort flag. +// Since Kingpin interprets any '-' as a flag, we'll need to remap the API enums +// to more friendly values and then send back to the API accordingly. +var SortOptions = []string{"name_asc", "name_desc", "created_at_asc", "created_at_desc"} + +// sortAPIValue maps CLI sort values to the API sort parameter. +var sortAPIValue = map[string]string{ + "name_asc": "name", + "name_desc": "-name", + "created_at_asc": "created_at", + "created_at_desc": "-created_at", +} diff --git a/pkg/commands/dns/zone/create.go b/pkg/commands/dns/zone/create.go new file mode 100644 index 000000000..450624230 --- /dev/null +++ b/pkg/commands/dns/zone/create.go @@ -0,0 +1,119 @@ +package zone + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/dns/v1/dnszones" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a DNS Zone. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + name string + + // Optional. + description argparser.OptionalString + xfrTSIGKeyID argparser.OptionalString + xfrPrimAddress argparser.OptionalStringSlice + xfrPrimDescription argparser.OptionalStringSlice +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a DNS Zone").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The domain name for your zone. Must be in FQDN format.").Required().StringVar(&c.name) + + // Optional. + c.CmdClause.Flag("description", "A freeform descriptive note.").Action(c.description.Set).StringVar(&c.description.Value) + c.CmdClause.Flag("primary-address", "An IPv4 address for the Primary DNS Server (repeatable).").Action(c.xfrPrimAddress.Set).StringsVar(&c.xfrPrimAddress.Value) + c.CmdClause.Flag("primary-description", "A description of the Primary DNS server (requires --primary-address, repeatable).").Action(c.xfrPrimDescription.Set).StringsVar(&c.xfrPrimDescription.Value) + c.CmdClause.Flag("inbound-tsig-key-id", "The ID of the TSIG key used to secure inbound zone transfers.").Action(c.xfrTSIGKeyID.Set).StringVar(&c.xfrTSIGKeyID.Value) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if strings.Contains(c.name, " ") { + return fmt.Errorf("invalid --name value %q: zone names cannot contain spaces", c.name) + } + if len(c.name) > 255 { + return fmt.Errorf("invalid --name value %q: zone names cannot exceed 255 characters", c.name) + } + + // zoneType must be explicitly set to 'secondary' as it's the only possible API value. + zoneType := "secondary" + input := &dnszones.CreateInput{ + Name: &c.name, + Type: &zoneType, + } + + if c.description.WasSet { + input.Description = &c.description.Value + } + + if c.xfrPrimAddress.WasSet { + primaries := make([]dnszones.Primary, len(c.xfrPrimAddress.Value)) + for i, addr := range c.xfrPrimAddress.Value { + primaries[i] = dnszones.Primary{Address: fastly.ToPointer(addr)} + if c.xfrPrimDescription.WasSet && i < len(c.xfrPrimDescription.Value) { + primaries[i].Description = fastly.ToPointer(c.xfrPrimDescription.Value[i]) + } + } + input.XfrConfigInbound = &dnszones.XfrConfigInboundInput{ + Primaries: primaries, + } + } + + if c.xfrTSIGKeyID.WasSet { + if input.XfrConfigInbound == nil { + input.XfrConfigInbound = &dnszones.XfrConfigInboundInput{} + } + input.XfrConfigInbound.InboundTSIGKeyID = fastly.NewNullable(c.xfrTSIGKeyID.Value) + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + d, err := dnszones.Create(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Name": c.name, + }) + return err + } + + if ok, err := c.WriteJSON(out, d); ok { + return err + } + + text.Success(out, "Created DNS zone '%s' (zone-id: %s)", *d.Name, *d.ID) + return nil +} diff --git a/pkg/commands/dns/zone/delete.go b/pkg/commands/dns/zone/delete.go new file mode 100644 index 000000000..0ef1cfa69 --- /dev/null +++ b/pkg/commands/dns/zone/delete.go @@ -0,0 +1,75 @@ +package zone + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/dns/v1/dnszones" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a DNS Zone. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + zoneID string +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a DNS Zone").Alias("remove") + + // Required. + c.CmdClause.Flag("zone-id", "The zone ID to delete.").Required().StringVar(&c.zoneID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := dnszones.Delete(context.TODO(), fc, &dnszones.DeleteInput{ + ZoneID: &c.zoneID, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Zone ID": c.zoneID, + }) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{c.zoneID, true} + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted DNS zone (zone-id: %s)", c.zoneID) + return nil +} diff --git a/pkg/commands/dns/zone/describe.go b/pkg/commands/dns/zone/describe.go new file mode 100644 index 000000000..708c14fd0 --- /dev/null +++ b/pkg/commands/dns/zone/describe.go @@ -0,0 +1,70 @@ +package zone + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/dns/v1/dnszones" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a DNS Zone. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + zoneID string +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Describe a DNS Zone").Alias("get") + + // Required. + c.CmdClause.Flag("zone-id", "The zone ID to describe.").Required().StringVar(&c.zoneID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + z, err := dnszones.Get(context.TODO(), fc, &dnszones.GetInput{ + ZoneID: &c.zoneID, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Zone ID": c.zoneID, + }) + return err + } + + if ok, err := c.WriteJSON(out, z); ok { + return err + } + + text.PrintDNSZone(out, "", z) + return nil +} diff --git a/pkg/commands/dns/zone/doc.go b/pkg/commands/dns/zone/doc.go new file mode 100644 index 000000000..002fcd0c3 --- /dev/null +++ b/pkg/commands/dns/zone/doc.go @@ -0,0 +1,2 @@ +// Package zone contains commands to inspect and manipulate Fastly DNS Zones. +package zone diff --git a/pkg/commands/dns/zone/list.go b/pkg/commands/dns/zone/list.go new file mode 100644 index 000000000..3f2122033 --- /dev/null +++ b/pkg/commands/dns/zone/list.go @@ -0,0 +1,75 @@ +package zone + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/dns/v1/dnszones" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list DNS Zones. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + name argparser.OptionalString + sort argparser.OptionalString +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List DNS Zones") + + // Optional. + c.CmdClause.Flag("name", "Filter zones to only those containing the provided name.").Action(c.name.Set).StringVar(&c.name.Value) + c.CmdClause.Flag("sort", "Order in which to list results. Valid values are: name_asc, name_desc, created_at_asc, created_at_desc.").Action(c.sort.Set).HintOptions(SortOptions...).EnumVar(&c.sort.Value, SortOptions...) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &dnszones.ListInput{} + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.sort.WasSet { + v := sortAPIValue[c.sort.Value] + input.Sort = &v + } + + zones, err := dnszones.List(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, zones); ok { + return err + } + + text.PrintDNSZoneTbl(out, zones) + return nil +} diff --git a/pkg/commands/dns/zone/root.go b/pkg/commands/dns/zone/root.go new file mode 100644 index 000000000..47ce94dc5 --- /dev/null +++ b/pkg/commands/dns/zone/root.go @@ -0,0 +1,31 @@ +package zone + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "zone" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly DNS Zones") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/dns/zone/update.go b/pkg/commands/dns/zone/update.go new file mode 100644 index 000000000..caccb7805 --- /dev/null +++ b/pkg/commands/dns/zone/update.go @@ -0,0 +1,118 @@ +package zone + +import ( + "context" + "errors" + "io" + "strings" + + "github.com/fastly/go-fastly/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/dns/v1/dnszones" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a DNS Zone. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + zoneID string + + // Optional. + description argparser.OptionalString + xfrTSIGKeyID argparser.OptionalString + xfrPrimAddress argparser.OptionalStringSlice + xfrPrimDescription argparser.OptionalStringSlice +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a DNS Zone") + + // Required. + c.CmdClause.Flag("zone-id", "The zone ID to update.").Required().StringVar(&c.zoneID) + + // Optional. + c.CmdClause.Flag("description", "A freeform descriptive note.").Action(c.description.Set).StringVar(&c.description.Value) + c.CmdClause.Flag("primary-address", "An IPv4 address for the Primary DNS Server (repeatable).").Action(c.xfrPrimAddress.Set).StringsVar(&c.xfrPrimAddress.Value) + c.CmdClause.Flag("primary-description", "A description of the Primary DNS server (requires --primary-address, repeatable).").Action(c.xfrPrimDescription.Set).StringsVar(&c.xfrPrimDescription.Value) + c.CmdClause.Flag("inbound-tsig-key-id", "The ID of the TSIG key used to secure inbound zone transfers. Pass 'nil' to dissociate key.").Action(c.xfrTSIGKeyID.Set).StringVar(&c.xfrTSIGKeyID.Value) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &dnszones.UpdateInput{ + ZoneID: &c.zoneID, + } + + if c.description.WasSet { + if strings.TrimSpace(c.description.Value) == "" { + input.Description = fastly.NullValue[string]() + } else { + input.Description = fastly.NewNullable(c.description.Value) + } + } + + if c.xfrPrimAddress.WasSet || c.xfrTSIGKeyID.WasSet { + xfr := &dnszones.XfrConfigInboundInput{} + + if c.xfrPrimAddress.WasSet { + primaries := make([]dnszones.Primary, len(c.xfrPrimAddress.Value)) + for i, addr := range c.xfrPrimAddress.Value { + primaries[i] = dnszones.Primary{Address: fastly.ToPointer(addr)} + if c.xfrPrimDescription.WasSet && i < len(c.xfrPrimDescription.Value) { + primaries[i].Description = fastly.ToPointer(c.xfrPrimDescription.Value[i]) + } + } + xfr.Primaries = primaries + } + + // We need to explicitly allow users to unset a given TSIG Key. + if c.xfrTSIGKeyID.WasSet { + if c.xfrTSIGKeyID.Value == "nil" { + xfr.InboundTSIGKeyID = fastly.NullValue[string]() + } else { + xfr.InboundTSIGKeyID = fastly.NewNullable(c.xfrTSIGKeyID.Value) + } + } + + input.XfrConfigInbound = xfr + } + + z, err := dnszones.Update(context.TODO(), fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Zone ID": c.zoneID, + }) + return err + } + + if ok, err := c.WriteJSON(out, z); ok { + return err + } + + text.Success(out, "Updated DNS zone '%s' (zone-id: %s)", *z.Name, *z.ID) + return nil +} diff --git a/pkg/commands/dns/zone/zone_test.go b/pkg/commands/dns/zone/zone_test.go new file mode 100644 index 000000000..67607ae42 --- /dev/null +++ b/pkg/commands/dns/zone/zone_test.go @@ -0,0 +1,316 @@ +package zone_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/go-fastly/v15/fastly" + "github.com/fastly/go-fastly/v15/fastly/dns/v1/dnszones" + + dnsroot "github.com/fastly/cli/pkg/commands/dns" + root "github.com/fastly/cli/pkg/commands/dns/zone" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/text" +) + +const ( + zoneID = "zone-id-123" + zoneName = "example.com." + zoneType = "secondary" +) + +var testZone = dnszones.Zone{ + ID: fastly.ToPointer(zoneID), + Name: fastly.ToPointer(zoneName), + Type: fastly.ToPointer(zoneType), +} + +func TestZoneCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--name example.com", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(testZone))), + }, + }, + }, + WantOutput: fmt.Sprintf("SUCCESS: Created DNS zone '%s' (zone-id: %s)", zoneName, zoneID), + }, + { + Args: "--name `example .com`", + WantError: "zone names cannot contain spaces", + }, + { + Args: "--name " + strings.Repeat("a", 256) + ".com", + WantError: "zone names cannot exceed 255 characters", + }, + { + Args: "--verbose --json --name example.com", + WantError: "invalid flag combination, --verbose and --json", + }, + { + Args: "--name example.com --json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusCreated, + Status: http.StatusText(http.StatusCreated), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(testZone))), + }, + }, + }, + WantOutput: string(testutil.GenJSON(testZone)), + }, + { + Args: "--name example.com", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(strings.NewReader(`{"errors": [{"title": "Invalid parameters"}]}`)), + }, + }, + }, + WantError: "400 - Bad Request", + }, + } + testutil.RunCLIScenarios(t, []string{dnsroot.CommandName, root.CommandName, "create"}, scenarios) +} + +func TestZoneDescribe(t *testing.T) { + resp := testutil.GenJSON(testZone) + + scenarios := []testutil.CLIScenario{ + { + Args: "", + WantError: "error parsing arguments: required flag --zone-id not provided", + }, + { + Args: "--verbose --json --zone-id " + zoneID, + WantError: "invalid flag combination, --verbose and --json", + }, + { + Args: "--zone-id " + zoneID, + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(resp)), + }, + }, + }, + WantOutput: fmtZone(&testZone), + }, + { + Args: "--zone-id " + zoneID + " --json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(resp)), + }, + }, + }, + WantOutput: string(resp), + }, + { + Args: "--zone-id " + zoneID, + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(strings.NewReader(`{"errors": [{"title": "Not Found"}]}`)), + }, + }, + }, + WantError: "404 - Not Found", + }, + } + testutil.RunCLIScenarios(t, []string{dnsroot.CommandName, root.CommandName, "describe"}, scenarios) +} + +func TestZoneDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "", + WantError: "error parsing arguments: required flag --zone-id not provided", + }, + { + Args: "--zone-id " + zoneID, + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + Body: http.NoBody, + }, + }, + }, + WantOutput: fmt.Sprintf("SUCCESS: Deleted DNS zone (zone-id: %s)", zoneID), + }, + { + Args: "--zone-id " + zoneID + " --json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + Body: http.NoBody, + }, + }, + }, + WantOutput: fmt.Sprintf(`"id": %q`, zoneID), + }, + { + Args: "--zone-id " + zoneID, + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + Status: http.StatusText(http.StatusNotFound), + Body: io.NopCloser(strings.NewReader(`{"errors": [{"title": "Not Found"}]}`)), + }, + }, + }, + WantError: "404 - Not Found", + }, + } + testutil.RunCLIScenarios(t, []string{dnsroot.CommandName, root.CommandName, "delete"}, scenarios) +} + +func TestZoneList(t *testing.T) { + zones := []dnszones.Zone{testZone} + resp := testutil.GenJSON(dnszones.Zones{ + Data: zones, + Meta: dnszones.MetaZones{}, + }) + + scenarios := []testutil.CLIScenario{ + { + Args: "--verbose --json", + WantError: "invalid flag combination, --verbose and --json", + }, + { + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(resp)), + }, + }, + }, + WantOutput: zoneName, + }, + { + Args: "--json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(resp)), + }, + }, + }, + WantOutput: string(testutil.GenJSON(zones)), + }, + { + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(strings.NewReader(`{"errors": [{"title": "Bad Request"}]}`)), + }, + }, + }, + WantError: "400 - Bad Request", + }, + } + testutil.RunCLIScenarios(t, []string{dnsroot.CommandName, root.CommandName, "list"}, scenarios) +} + +func TestZoneUpdate(t *testing.T) { + updated := dnszones.Zone{ + ID: fastly.ToPointer(zoneID), + Name: fastly.ToPointer(zoneName), + Type: fastly.ToPointer(zoneType), + Description: fastly.ToPointer("updated description"), + } + + scenarios := []testutil.CLIScenario{ + { + Args: "", + WantError: "error parsing arguments: required flag --zone-id not provided", + }, + { + Args: "--verbose --json --zone-id " + zoneID, + WantError: "invalid flag combination, --verbose and --json", + }, + { + Args: "--zone-id " + zoneID + " --description updated-description", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updated))), + }, + }, + }, + WantOutput: fmt.Sprintf("SUCCESS: Updated DNS zone '%s' (zone-id: %s)", zoneName, zoneID), + }, + { + Args: "--zone-id " + zoneID + " --json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(updated))), + }, + }, + }, + WantOutput: string(testutil.GenJSON(updated)), + }, + { + Args: "--zone-id " + zoneID, + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(strings.NewReader(`{"errors": [{"title": "Bad Request"}]}`)), + }, + }, + }, + WantError: "400 - Bad Request", + }, + } + testutil.RunCLIScenarios(t, []string{dnsroot.CommandName, root.CommandName, "update"}, scenarios) +} + +func fmtZone(z *dnszones.Zone) string { + var b bytes.Buffer + text.PrintDNSZone(&b, "", z) + return b.String() +} diff --git a/pkg/text/dnszone.go b/pkg/text/dnszone.go new file mode 100644 index 000000000..44334346a --- /dev/null +++ b/pkg/text/dnszone.go @@ -0,0 +1,66 @@ +package text + +import ( + "fmt" + "io" + "strings" + + "github.com/segmentio/textio" + + "github.com/fastly/go-fastly/v15/fastly/dns/v1/dnszones" +) + +// PrintDNSZone pretty prints a dnszones.Zone in verbose format to a given +// io.Writer. Consumers can provide a prefix string for indentation. +func PrintDNSZone(out io.Writer, prefix string, z *dnszones.Zone) { + out = textio.NewPrefixWriter(out, prefix) + + fmt.Fprintf(out, "ID: %s\n", strOrEmpty(z.ID)) + fmt.Fprintf(out, "Name: %s\n", strOrEmpty(z.Name)) + fmt.Fprintf(out, "Type: %s\n", strOrEmpty(z.Type)) + fmt.Fprintf(out, "Description: %s\n", strOrEmpty(z.Description)) + fmt.Fprintf(out, "Serial: %s\n", strOrEmpty(z.Serial)) + fmt.Fprintf(out, "Nameservers: %s\n", strings.Join(z.Nameservers, ", ")) + if z.XfrConfigInbound != nil { + fmt.Fprintf(out, "Inbound Transfer Config:\n") + printXfrConfigInbound(out, "\t\t", z.XfrConfigInbound) + } + fmt.Fprintf(out, "Created at: %s\n", strOrEmpty(z.CreatedAt)) + fmt.Fprintf(out, "Updated at: %s\n", strOrEmpty(z.UpdatedAt)) +} + +// PrintDNSZoneTbl prints a slice of dnszones.Zone in table format to a given io.Writer. +func PrintDNSZoneTbl(out io.Writer, zones []dnszones.Zone) { + tbl := NewTable(out) + tbl.AddHeader("ID", "Name", "Type", "Description", "Created At", "Updated At") + + if zones == nil { + tbl.Print() + return + } + + for _, z := range zones { + tbl.AddLine(strOrEmpty(z.ID), strOrEmpty(z.Name), strOrEmpty(z.Type), strOrEmpty(z.Description), strOrEmpty(z.CreatedAt), strOrEmpty(z.UpdatedAt)) + } + tbl.Print() +} + +func printXfrConfigInbound(out io.Writer, indent string, x *dnszones.XfrConfigInbound) { + out = textio.NewPrefixWriter(out, indent) + + fmt.Fprintf(out, "Inbound TSIG Key ID: %s\n", strOrEmpty(x.InboundTSIGKeyID)) + for i, p := range x.Primaries { + fmt.Fprintf(out, "Primary[%d] Address: %s\n", i, strOrEmpty(p.Address)) + fmt.Fprintf(out, "Primary[%d] Description: %s\n", i, strOrEmpty(p.Description)) + } + if x.NotifyIPAddresses != nil { + fmt.Fprintf(out, "Notify IPv4 Addresses: %s\n", strings.Join(x.NotifyIPAddresses.IPv4, ", ")) + } +} + +func strOrEmpty(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/pkg/text/tsigkey.go b/pkg/text/tsigkey.go new file mode 100644 index 000000000..7240931df --- /dev/null +++ b/pkg/text/tsigkey.go @@ -0,0 +1,39 @@ +package text + +import ( + "fmt" + "io" + + "github.com/segmentio/textio" + + "github.com/fastly/go-fastly/v15/fastly/dns/v1/tsigkeys" +) + +// PrintTSIGKey pretty prints a tsigkeys.TSIGKey in verbose format to a given +// io.Writer. Consumers can provide a prefix string for indentation. +func PrintTSIGKey(out io.Writer, prefix string, k *tsigkeys.TSIGKey) { + out = textio.NewPrefixWriter(out, prefix) + + fmt.Fprintf(out, "ID: %s\n", strOrEmpty(k.ID)) + fmt.Fprintf(out, "Name: %s\n", strOrEmpty(k.Name)) + fmt.Fprintf(out, "Algorithm: %s\n", strOrEmpty(k.Algorithm)) + fmt.Fprintf(out, "Description: %s\n", strOrEmpty(k.Description)) + fmt.Fprintf(out, "Created at: %s\n", strOrEmpty(k.CreatedAt)) + fmt.Fprintf(out, "Updated at: %s\n", strOrEmpty(k.UpdatedAt)) +} + +// PrintTSIGKeyTbl prints a slice of tsigkeys.TSIGKey in table format to a given io.Writer. +func PrintTSIGKeyTbl(out io.Writer, keys []tsigkeys.TSIGKey) { + tbl := NewTable(out) + tbl.AddHeader("ID", "Name", "Algorithm", "Description", "Created At", "Updated At") + + if keys == nil { + tbl.Print() + return + } + + for _, k := range keys { + tbl.AddLine(strOrEmpty(k.ID), strOrEmpty(k.Name), strOrEmpty(k.Algorithm), strOrEmpty(k.Description), strOrEmpty(k.CreatedAt), strOrEmpty(k.UpdatedAt)) + } + tbl.Print() +} From d7b3e79daca19e2d24faa544b1ea39bc19a0ba7c Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Tue, 2 Jun 2026 09:19:26 -0400 Subject: [PATCH 2/5] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc8e4471..9132f6203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Bug Fixes: ### Enhancements: +- feat(dns): add support for DNS Zones and TSIG Keys ([#1809](https://github.com/fastly/cli/pull/1809)) ### Dependencies: - build(deps): `github.com/bodgit/sevenzip` from 1.6.1 to 1.6.2 ([#1795](https://github.com/fastly/cli/pull/1795)) From 36208369014b0e139961761440f0d0d6a70787a9 Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Tue, 2 Jun 2026 09:41:41 -0400 Subject: [PATCH 3/5] corrected linting issues --- pkg/commands/dns/tsigkey/tsigkey_test.go | 10 +++++----- pkg/commands/dns/zone/create.go | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/commands/dns/tsigkey/tsigkey_test.go b/pkg/commands/dns/tsigkey/tsigkey_test.go index b6f321ab1..6978de27a 100644 --- a/pkg/commands/dns/tsigkey/tsigkey_test.go +++ b/pkg/commands/dns/tsigkey/tsigkey_test.go @@ -18,10 +18,10 @@ import ( ) const ( - tsigKeyID = "tsig-key-id-123" - tsigKeyName = "my-tsig-key" - tsigAlgo = "hmac-sha256" - tsigSecret = "dGVzdHNlY3JldA==" + tsigKeyID = "tsig-key-id-123" + tsigKeyName = "my-tsig-key" + tsigAlgo = "hmac-sha256" + tsigSecret = "dGVzdHNlY3JldA==" // #nosec G101 (CWE-798) ) var testKey = tsigkeys.TSIGKey{ @@ -58,7 +58,7 @@ func TestTSIGKeyCreate(t *testing.T) { WantError: "TSIG key names cannot exceed 255 characters", }, { - Args: fmt.Sprintf("--verbose --json --name %s --algorithm %s --secret %s", tsigKeyName, tsigAlgo, tsigSecret), + Args: fmt.Sprintf("--verbose --json --name %s --algorithm %s --secret %s", tsigKeyName, tsigAlgo, tsigSecret), WantError: "invalid flag combination, --verbose and --json", }, { diff --git a/pkg/commands/dns/zone/create.go b/pkg/commands/dns/zone/create.go index 450624230..f0fad1f08 100644 --- a/pkg/commands/dns/zone/create.go +++ b/pkg/commands/dns/zone/create.go @@ -25,9 +25,9 @@ type CreateCommand struct { name string // Optional. - description argparser.OptionalString - xfrTSIGKeyID argparser.OptionalString - xfrPrimAddress argparser.OptionalStringSlice + description argparser.OptionalString + xfrTSIGKeyID argparser.OptionalString + xfrPrimAddress argparser.OptionalStringSlice xfrPrimDescription argparser.OptionalStringSlice } From 5537b2972e1b5c6d907e7681de8311600e36df46 Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Tue, 2 Jun 2026 10:41:34 -0400 Subject: [PATCH 4/5] added dns to test run --- pkg/app/run_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index 58176d032..655f11def 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -72,6 +72,7 @@ config config-store config-store-entry dashboard +dns domain install ip-list From fb9705905d3fb3c92d5750236b5e6a5d93938664 Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Tue, 2 Jun 2026 14:57:55 -0400 Subject: [PATCH 5/5] Add description count validation with warning --- pkg/commands/dns/zone/create.go | 7 +++++++ pkg/commands/dns/zone/update.go | 8 ++++++++ pkg/commands/dns/zone/zone_test.go | 16 ++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/pkg/commands/dns/zone/create.go b/pkg/commands/dns/zone/create.go index f0fad1f08..482c8298a 100644 --- a/pkg/commands/dns/zone/create.go +++ b/pkg/commands/dns/zone/create.go @@ -66,6 +66,13 @@ func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { return fmt.Errorf("invalid --name value %q: zone names cannot exceed 255 characters", c.name) } + if c.xfrPrimDescription.WasSet && !c.xfrPrimAddress.WasSet { + return fmt.Errorf("--primary-description requires --primary-address") + } + if len(c.xfrPrimDescription.Value) > len(c.xfrPrimAddress.Value) { + return fmt.Errorf("--primary-description cannot be provided more times than --primary-address") + } + // zoneType must be explicitly set to 'secondary' as it's the only possible API value. zoneType := "secondary" input := &dnszones.CreateInput{ diff --git a/pkg/commands/dns/zone/update.go b/pkg/commands/dns/zone/update.go index caccb7805..3f7b588e4 100644 --- a/pkg/commands/dns/zone/update.go +++ b/pkg/commands/dns/zone/update.go @@ -3,6 +3,7 @@ package zone import ( "context" "errors" + "fmt" "io" "strings" @@ -58,6 +59,13 @@ func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { return fsterr.ErrInvalidVerboseJSONCombo } + if c.xfrPrimDescription.WasSet && !c.xfrPrimAddress.WasSet { + return fmt.Errorf("--primary-description requires --primary-address") + } + if len(c.xfrPrimDescription.Value) > len(c.xfrPrimAddress.Value) { + return fmt.Errorf("--primary-description cannot be provided more times than --primary-address") + } + fc, ok := c.Globals.APIClient.(*fastly.Client) if !ok { return errors.New("failed to convert interface to a fastly client") diff --git a/pkg/commands/dns/zone/zone_test.go b/pkg/commands/dns/zone/zone_test.go index 67607ae42..6670e751a 100644 --- a/pkg/commands/dns/zone/zone_test.go +++ b/pkg/commands/dns/zone/zone_test.go @@ -60,6 +60,14 @@ func TestZoneCreate(t *testing.T) { Args: "--verbose --json --name example.com", WantError: "invalid flag combination, --verbose and --json", }, + { + Args: "--name example.com --primary-description foo", + WantError: "--primary-description requires --primary-address", + }, + { + Args: "--name example.com --primary-address 1.2.3.4 --primary-description foo --primary-description bar", + WantError: "--primary-description cannot be provided more times than --primary-address", + }, { Args: "--name example.com --json", Client: &http.Client{ @@ -266,6 +274,14 @@ func TestZoneUpdate(t *testing.T) { Args: "--verbose --json --zone-id " + zoneID, WantError: "invalid flag combination, --verbose and --json", }, + { + Args: "--zone-id " + zoneID + " --primary-description foo", + WantError: "--primary-description requires --primary-address", + }, + { + Args: "--zone-id " + zoneID + " --primary-address 1.2.3.4 --primary-description foo --primary-description bar", + WantError: "--primary-description cannot be provided more times than --primary-address", + }, { Args: "--zone-id " + zoneID + " --description updated-description", Client: &http.Client{