diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac3312d5..a298d93e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - feat(commands/ngwaf/lists): add support for CRUD operations for NGWAF Lists at account and workspace levels ([#1582](https://github.com/fastly/cli/pull/1582)) - feat(commands/ngwaf/workspaces/alerts): add support for operations for NGWAF alerts ([#1589](https://github.com/fastly/cli/pull/1589)) - feat(commands/ngwaf/customsignals): add support for CRUD operations for NGWAF Custom Signals ([#1592](https://github.com/fastly/cli/pull/1592)) +- feat(commands/ngwaf/customsignals): add support for CRUD operations for NGWAF Thresholds ([#1595](https://github.com/fastly/cli/pull/1595)) ### Bug fixes: - fix(commands/ngwaf/virtualpatch): ensured a check was in place for the 'update' command that disallowed the --json and --verbose flag to be ran at the same time. ([#1596](https://github.com/fastly/cli/pull/1596)) diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 1b2f0295e..d380d6d59 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -78,6 +78,7 @@ import ( "github.com/fastly/cli/pkg/commands/ngwaf/workspace/redaction" wssignallistlist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/signallist" wsstringlistlist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/stringlist" + "github.com/fastly/cli/pkg/commands/ngwaf/workspace/threshold" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/virtualpatch" wswildcardlistlist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/wildcardlist" "github.com/fastly/cli/pkg/commands/objectstorage" @@ -497,6 +498,12 @@ func Define( // nolint:revive // function-length ngwafWorkspaceStringListGet := wsstringlistlist.NewGetCommand(ngwafWorkspaceStringListRoot.CmdClause, data) ngwafWorkspaceStringListList := wsstringlistlist.NewListCommand(ngwafWorkspaceStringListRoot.CmdClause, data) ngwafWorkspaceStringListUpdate := wsstringlistlist.NewUpdateCommand(ngwafWorkspaceStringListRoot.CmdClause, data) + ngwafWorkspaceThresholdRoot := threshold.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) + ngwafWorkspaceThresholdCreate := threshold.NewCreateCommand(ngwafWorkspaceThresholdRoot.CmdClause, data) + ngwafWorkspaceThresholdDelete := threshold.NewDeleteCommand(ngwafWorkspaceThresholdRoot.CmdClause, data) + ngwafWorkspaceThresholdGet := threshold.NewGetCommand(ngwafWorkspaceThresholdRoot.CmdClause, data) + ngwafWorkspaceThresholdList := threshold.NewListCommand(ngwafWorkspaceThresholdRoot.CmdClause, data) + ngwafWorkspaceThresholdUpdate := threshold.NewUpdateCommand(ngwafWorkspaceThresholdRoot.CmdClause, data) ngwafWorkspaceWildcardListRoot := wildcardlist.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceWildcardListCreate := wswildcardlistlist.NewCreateCommand(ngwafWorkspaceWildcardListRoot.CmdClause, data) ngwafWorkspaceWildcardListDelete := wswildcardlistlist.NewDeleteCommand(ngwafWorkspaceWildcardListRoot.CmdClause, data) @@ -1049,6 +1056,12 @@ func Define( // nolint:revive // function-length ngwafWorkspaceStringListGet, ngwafWorkspaceStringListList, ngwafWorkspaceStringListUpdate, + ngwafWorkspaceThresholdRoot, + ngwafWorkspaceThresholdCreate, + ngwafWorkspaceThresholdDelete, + ngwafWorkspaceThresholdGet, + ngwafWorkspaceThresholdList, + ngwafWorkspaceThresholdUpdate, ngwafWorkspaceWildcardListCreate, ngwafWorkspaceWildcardListDelete, ngwafWorkspaceWildcardListGet, diff --git a/pkg/commands/ngwaf/workspace/threshold/create.go b/pkg/commands/ngwaf/workspace/threshold/create.go new file mode 100644 index 000000000..43645bb72 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/threshold/create.go @@ -0,0 +1,130 @@ +package threshold + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/thresholds" + + "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 workspace threshold. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + action string + dontNotify argparser.OptionalString + duration int + enabled argparser.OptionalString + interval int + limit int + name string + signal string + workspaceID argparser.OptionalWorkspaceID + + // Optional. +} + +// 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 workspace threshold").Alias("add") + + // Required. + c.CmdClause.Flag("action", "The action to take when the threshold is exceeded. [block, log]").Required().StringVar(&c.action) + c.CmdClause.Flag("do-not-notify", "Whether to silence notifications when action is taken. [true, false]").Required().Action(c.dontNotify.Set).StringVar(&c.dontNotify.Value) + c.CmdClause.Flag("duration", "The duration the action is in place in seconds. Default duration is 86,400 seconds (1 day).").Required().IntVar(&c.duration) + c.CmdClause.Flag("enabled", "Whether the threshold is active. [true, false]").Required().Action(c.enabled.Set).StringVar(&c.enabled.Value) + c.CmdClause.Flag("interval", "The threshold interval in seconds. The default interval is 3600 seconds (1 hour).").Required().IntVar(&c.interval) + c.CmdClause.Flag("limit", "The threshold limit. Input must be between 1 and 10000. Default limit is 10.").Required().IntVar(&c.limit) + c.CmdClause.Flag("name", "User submitted display name of a signal threshold. Input must be between 3 and 50 characters").Required().StringVar(&c.name) + c.CmdClause.Flag("signal", "The name of the signal this threshold is acting on").Required().StringVar(&c.signal) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.workspaceID.Value, + Action: c.workspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + 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 + } + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.workspaceID.Parse(); err != nil { + return err + } + + var enabled bool + switch c.enabled.Value { + case "true": + enabled = true + case "false": + enabled = false + default: + err := errors.New("'enabled' flag must be one of the following [true, false]") + c.Globals.ErrLog.Add(err) + return err + } + + var dontNotify bool + switch c.dontNotify.Value { + case "true": + dontNotify = true + case "false": + dontNotify = false + default: + err := errors.New("'do-not-notify' flag must be one of the following [true, false]") + c.Globals.ErrLog.Add(err) + return err + } + + input := &thresholds.CreateInput{ + Action: &c.action, + Duration: &c.duration, + Enabled: &enabled, + Interval: &c.interval, + Limit: &c.limit, + Name: &c.name, + DontNotify: &dontNotify, + Signal: &c.signal, + WorkspaceID: &c.workspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := thresholds.Create(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Created threshold '%s' for workspace '%s'", data.ThresholdID, c.workspaceID.Value) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/threshold/delete.go b/pkg/commands/ngwaf/workspace/threshold/delete.go new file mode 100644 index 000000000..a24f9b21f --- /dev/null +++ b/pkg/commands/ngwaf/workspace/threshold/delete.go @@ -0,0 +1,92 @@ +package threshold + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/thresholds" + + "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 workspace threshold. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + thresholdID string + workspaceID argparser.OptionalWorkspaceID + + // Optional. +} + +// 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", "Deletes a workspace threshold") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.workspaceID.Value, + Action: c.workspaceID.Set, + }) + c.CmdClause.Flag("threshold-id", "Threshold ID").Required().StringVar(&c.thresholdID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + 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 + } + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.workspaceID.Parse(); err != nil { + return err + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := thresholds.Delete(context.TODO(), fc, &thresholds.DeleteInput{ + ThresholdID: &c.thresholdID, + WorkspaceID: &c.workspaceID.Value, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.thresholdID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted threshold (id: %s)", c.thresholdID) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/threshold/doc.go b/pkg/commands/ngwaf/workspace/threshold/doc.go new file mode 100644 index 000000000..415d5af3a --- /dev/null +++ b/pkg/commands/ngwaf/workspace/threshold/doc.go @@ -0,0 +1,2 @@ +// Package threshold contains commands to inspect and manipulate NGWAF workspace thresholds. +package threshold diff --git a/pkg/commands/ngwaf/workspace/threshold/get.go b/pkg/commands/ngwaf/workspace/threshold/get.go new file mode 100644 index 000000000..1f4df77d6 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/threshold/get.go @@ -0,0 +1,84 @@ +package threshold + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/thresholds" + + "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" +) + +// GetCommand calls the Fastly API to get a workspace threshold. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + thresholdID string + workspaceID argparser.OptionalWorkspaceID + + // Optional. +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("get", "Retrieves a workspace threshold") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.workspaceID.Value, + Action: c.workspaceID.Set, + }) + c.CmdClause.Flag("threshold-id", "Threshold ID").Required().StringVar(&c.thresholdID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.workspaceID.Parse(); err != nil { + return err + } + input := &thresholds.GetInput{ + ThresholdID: &c.thresholdID, + WorkspaceID: &c.workspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := thresholds.Get(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintThreshold(out, data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/threshold/list.go b/pkg/commands/ngwaf/workspace/threshold/list.go new file mode 100644 index 000000000..2af6147f5 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/threshold/list.go @@ -0,0 +1,81 @@ +package threshold + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/thresholds" + + "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 workspace thresholds. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + workspaceID argparser.OptionalWorkspaceID + + // Optional. +} + +// 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 workspace thresholds") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.workspaceID.Value, + Action: c.workspaceID.Set, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + 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 + } + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.workspaceID.Parse(); err != nil { + return err + } + input := &thresholds.ListInput{ + WorkspaceID: &c.workspaceID.Value, + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := thresholds.List(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.PrintThresholdTbl(out, data.Data) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/threshold/root.go b/pkg/commands/ngwaf/workspace/threshold/root.go new file mode 100644 index 000000000..5cef26aee --- /dev/null +++ b/pkg/commands/ngwaf/workspace/threshold/root.go @@ -0,0 +1,31 @@ +package threshold + +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 = "threshold" + +// 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, "Manage NGWAF Workspace Thresholds") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/ngwaf/workspace/threshold/threshold_test.go b/pkg/commands/ngwaf/workspace/threshold/threshold_test.go new file mode 100644 index 000000000..11fbb9dc2 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/threshold/threshold_test.go @@ -0,0 +1,440 @@ +package threshold_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + workspace "github.com/fastly/cli/pkg/commands/ngwaf/workspace" + sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/threshold" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/thresholds" +) + +const ( + thresholdAction = "block" + thresholdDuration = 86400 + thresholdEnabled = true + thresholdID = "thresholdID" + thresholdInterval = 3600 + thresholdLimit = 10 + thresholdName = "Test_Threshold" + thresholdSignal = "test-signal" + thresholdDontNotify = false + workspaceID = "workspaceID" +) + +var threshold = thresholds.Threshold{ + Action: thresholdAction, + CreatedAt: testutil.Date, + DontNotify: thresholdDontNotify, + Duration: thresholdDuration, + Enabled: thresholdEnabled, + Interval: thresholdInterval, + Limit: thresholdLimit, + Name: thresholdName, + Signal: thresholdSignal, + ThresholdID: thresholdID, +} + +func TestThresholdCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --action flag", + Args: fmt.Sprintf("--name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), + WantError: "error parsing arguments: required flag --action not provided", + }, + { + Name: "validate missing --name flag", + Args: fmt.Sprintf("--action %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --signal flag", + Args: fmt.Sprintf("--action %s --name %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), + WantError: "error parsing arguments: required flag --signal not provided", + }, + { + Name: "validate missing --do-not-notify flag", + Args: fmt.Sprintf("--action %s --name %s --signal %s --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), + WantError: "error parsing arguments: required flag --do-not-notify not provided", + }, + { + Name: "validate missing --duration flag", + Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), + WantError: "error parsing arguments: required flag --duration not provided", + }, + { + Name: "validate missing --enabled flag", + Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdInterval, thresholdLimit, workspaceID), + WantError: "error parsing arguments: required flag --enabled not provided", + }, + { + Name: "validate missing --interval flag", + Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdLimit, workspaceID), + WantError: "error parsing arguments: required flag --interval not provided", + }, + { + Name: "validate missing --limit flag", + Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, workspaceID), + WantError: "error parsing arguments: required flag --limit not provided", + }, + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(threshold)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created threshold '%s' for workspace '%s'", thresholdID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --workspace-id %s --json", thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(threshold))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(threshold), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestThresholdDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --threshold-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --threshold-id not provided", + }, + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--threshold-id %s", thresholdID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate bad request", + Args: fmt.Sprintf("--threshold-id %s --workspace-id %s", thresholdID, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid Threshold ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--threshold-id %s --workspace-id %s", thresholdID, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted threshold (id: %s)", thresholdID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--threshold-id %s --workspace-id %s --json", thresholdID, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, thresholdID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestThresholdGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --threshold-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --threshold-id not provided", + }, + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--threshold-id %s", thresholdID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate bad request", + Args: fmt.Sprintf("--threshold-id %s --workspace-id %s", thresholdID, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid Threshold ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--threshold-id %s --workspace-id %s", thresholdID, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(threshold)))), + }, + }, + }, + WantOutput: thresholdString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--threshold-id %s --workspace-id %s --json", thresholdID, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(threshold)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(threshold), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "get"}, scenarios) +} + +func TestThresholdList(t *testing.T) { + thresholdsObject := thresholds.Thresholds{ + Data: []thresholds.Threshold{ + { + Action: thresholdAction, + CreatedAt: testutil.Date, + DontNotify: thresholdDontNotify, + Duration: thresholdDuration, + Enabled: thresholdEnabled, + Interval: thresholdInterval, + Limit: thresholdLimit, + Name: thresholdName, + Signal: thresholdSignal, + ThresholdID: thresholdID, + }, + { + Action: "log", + CreatedAt: testutil.Date, + DontNotify: true, + Duration: 43200, + Enabled: false, + Interval: 600, + Limit: 20, + Name: "Test_Threshold_2", + Signal: "test-signal-2", + ThresholdID: thresholdID + "2", + }, + }, + Meta: thresholds.MetaThresholds{}, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --workspace-id flag", + Args: "", + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero thresholds)", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(thresholds.Thresholds{ + Data: []thresholds.Threshold{}, + Meta: thresholds.MetaThresholds{}, + }))), + }, + }, + }, + WantOutput: zeroListThresholdString, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(thresholdsObject))), + }, + }, + }, + WantOutput: listThresholdString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--workspace-id %s --json", workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(thresholdsObject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(thresholdsObject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestThresholdUpdate(t *testing.T) { + thresholdsObject := thresholds.Threshold{ + Action: thresholdAction, + CreatedAt: testutil.Date, + DontNotify: thresholdDontNotify, + Duration: thresholdDuration, + Enabled: thresholdEnabled, + Interval: thresholdInterval, + Limit: thresholdLimit, + Name: thresholdName, + Signal: thresholdSignal, + ThresholdID: thresholdID, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --threshold-id flag", + Args: fmt.Sprintf("--workspace-id %s", workspaceID), + WantError: "error parsing arguments: required flag --threshold-id not provided", + }, + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--threshold-id %s", thresholdID), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--threshold-id %s --workspace-id %s --action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d", thresholdID, workspaceID, thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(thresholdsObject))), + }, + }, + }, + WantOutput: fstfmt.Success("Updated threshold '%s' for workspace '%s'", thresholdID, workspaceID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--threshold-id %s --workspace-id %s --action %s --name %s --signal %s --do-not-notify=%t --duration %d --enabled=%t --interval %d --limit %d --json", thresholdID, workspaceID, thresholdAction, thresholdName, thresholdSignal, thresholdDontNotify, thresholdDuration, thresholdEnabled, thresholdInterval, thresholdLimit), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(threshold))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(threshold), + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "update"}, scenarios) +} + +var listThresholdString = strings.TrimSpace(` +Signal Name ID Action Enabled Do Not Notify Limit Interval Duration Created At +test-signal Test_Threshold thresholdID block true false 10 3600 86400 2021-06-15T23:00:00Z +test-signal-2 Test_Threshold_2 thresholdID2 log false true 20 600 43200 2021-06-15T23:00:00Z +`) + "\n" + +var zeroListThresholdString = strings.TrimSpace(` +Signal Name ID Action Enabled Do Not Notify Limit Interval Duration Created At +`) + "\n" + +var thresholdString = strings.TrimSpace(` +Signal: test-signal +Name: Test_Threshold +Action: block +Do Not Notify: false +Duration: 86400 +Enabled: true +Interval: 3600 +Limit: 10 +`) diff --git a/pkg/commands/ngwaf/workspace/threshold/update.go b/pkg/commands/ngwaf/workspace/threshold/update.go new file mode 100644 index 000000000..d9626e95b --- /dev/null +++ b/pkg/commands/ngwaf/workspace/threshold/update.go @@ -0,0 +1,146 @@ +package threshold + +import ( + "context" + "errors" + "io" + + "github.com/fastly/go-fastly/v12/fastly" + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/thresholds" + + "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 workspace threshold. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + thresholdID string + workspaceID argparser.OptionalWorkspaceID + + // Optional. + action argparser.OptionalString + dontNotify argparser.OptionalString + duration argparser.OptionalInt + enabled argparser.OptionalString + interval argparser.OptionalInt + limit argparser.OptionalInt + name argparser.OptionalString + signal 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 workspace threshold") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.workspaceID.Value, + Action: c.workspaceID.Set, + }) + c.CmdClause.Flag("threshold-id", "Threshold ID").Required().StringVar(&c.thresholdID) + + // Optional. + c.CmdClause.Flag("action", "The action to take when the threshold is exceeded. [block, log]").Action(c.action.Set).StringVar(&c.action.Value) + c.CmdClause.Flag("do-not-notify", "Whether to silence notifications when action is taken. [true, false]").Action(c.dontNotify.Set).StringVar(&c.dontNotify.Value) + c.CmdClause.Flag("duration", "The duration the action is in place in seconds. Default duration is 86,400 seconds (1 day).").Action(c.duration.Set).IntVar(&c.duration.Value) + c.CmdClause.Flag("enabled", "Whether the threshold is active. [true, false]").Action(c.enabled.Set).StringVar(&c.enabled.Value) + c.CmdClause.Flag("interval", "The threshold interval in seconds. The default interval is 3600 seconds (1 hour).").Action(c.interval.Set).IntVar(&c.interval.Value) + c.CmdClause.Flag("limit", "The threshold limit. Input must be between 1 and 10000. Default limit is 10.").Action(c.limit.Set).IntVar(&c.limit.Value) + c.CmdClause.Flag("name", "User submitted display name of a signal threshold. Input must be between 3 and 50 characters").Action(c.name.Set).StringVar(&c.name.Value) + c.CmdClause.Flag("signal", "The name of the signal this threshold is acting on").Action(c.signal.Set).StringVar(&c.signal.Value) + c.RegisterFlagBool(c.JSONFlag()) + + 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 + } + // Call Parse() to ensure that we check if workspaceID + // is set or to throw the appropriate error. + if err := c.workspaceID.Parse(); err != nil { + return err + } + input := &thresholds.UpdateInput{ + ThresholdID: &c.thresholdID, + WorkspaceID: &c.workspaceID.Value, + } + if c.action.WasSet { + input.Action = &c.action.Value + } + if c.dontNotify.WasSet { + var dontNotify bool + switch c.dontNotify.Value { + case "true": + dontNotify = true + case "false": + dontNotify = false + default: + err := errors.New("'do-not-notify' flag must be one of the following [true, false]") + c.Globals.ErrLog.Add(err) + return err + } + input.DontNotify = &dontNotify + } + if c.duration.WasSet { + input.Duration = &c.duration.Value + } + if c.enabled.WasSet { + var enabled bool + switch c.enabled.Value { + case "true": + enabled = true + case "false": + enabled = false + default: + err := errors.New("'enabled' flag must be one of the following [true, false]") + c.Globals.ErrLog.Add(err) + return err + } + input.Enabled = &enabled + } + if c.interval.WasSet { + input.Interval = &c.interval.Value + } + if c.limit.WasSet { + input.Limit = &c.limit.Value + } + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.signal.WasSet { + input.Signal = &c.signal.Value + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + data, err := thresholds.Update(context.TODO(), fc, input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + + text.Success(out, "Updated threshold '%s' for workspace '%s'", data.ThresholdID, c.workspaceID.Value) + return nil +} diff --git a/pkg/text/threshold.go b/pkg/text/threshold.go new file mode 100644 index 000000000..51c2f23af --- /dev/null +++ b/pkg/text/threshold.go @@ -0,0 +1,48 @@ +package text + +import ( + "fmt" + "io" + "time" + + "github.com/fastly/go-fastly/v12/fastly/ngwaf/v1/workspaces/thresholds" +) + +// PrintThreshold displays a single threshold. +func PrintThreshold(out io.Writer, thresholdToPrint *thresholds.Threshold) { + fmt.Fprintf(out, "Signal: %s\n", thresholdToPrint.Signal) + fmt.Fprintf(out, "Name: %s\n", thresholdToPrint.Name) + fmt.Fprintf(out, "Action: %s\n", thresholdToPrint.Action) + fmt.Fprintf(out, "Do Not Notify: %t\n", thresholdToPrint.DontNotify) + fmt.Fprintf(out, "Duration: %d\n", thresholdToPrint.Duration) + fmt.Fprintf(out, "Enabled: %t\n", thresholdToPrint.Enabled) + fmt.Fprintf(out, "Interval: %d\n", thresholdToPrint.Interval) + fmt.Fprintf(out, "Limit: %d\n", thresholdToPrint.Limit) +} + +// PrintThresholdTbl prints a table of thresholds. +func PrintThresholdTbl(out io.Writer, thresholdsToPrint []thresholds.Threshold) { + tbl := NewTable(out) + tbl.AddHeader("Signal", "Name", "ID", "Action", "Enabled", "Do Not Notify", "Limit", "Interval", "Duration", "Created At") + + if thresholdsToPrint == nil { + tbl.Print() + return + } + + for _, ts := range thresholdsToPrint { + tbl.AddLine( + ts.Signal, + ts.Name, + ts.ThresholdID, + ts.Action, + ts.Enabled, + ts.DontNotify, + ts.Limit, + ts.Interval, + ts.Duration, + ts.CreatedAt.UTC().Format(time.RFC3339), + ) + } + tbl.Print() +}