diff --git a/CHANGELOG.md b/CHANGELOG.md index fcdaf7989..3b701a9d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Enhancements: +- feat(kvstoreentry/delete): Add support for multiple-key deletion using a key prefix. ([#XXX](https://github.com/fastly/cli/pull/XXX)) + ### Dependencies: ## [v15.2.0](https://github.com/fastly/cli/releases/tag/v15.2.0) (2026-06-10) diff --git a/pkg/commands/kvstore/delete.go b/pkg/commands/kvstore/delete.go index e686a005c..87defd0e8 100644 --- a/pkg/commands/kvstore/delete.go +++ b/pkg/commands/kvstore/delete.go @@ -73,7 +73,7 @@ func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { PoolSize: c.poolSize, StoreID: c.Input.StoreID, } - if err := dc.DeleteAllKeys(out); err != nil { + if err := dc.DeleteMultipleKeys(out, ""); err != nil { return err } text.Break(out) diff --git a/pkg/commands/kvstoreentry/delete.go b/pkg/commands/kvstoreentry/delete.go index 9b2b65d44..d560e6b3e 100644 --- a/pkg/commands/kvstoreentry/delete.go +++ b/pkg/commands/kvstoreentry/delete.go @@ -28,7 +28,8 @@ const DeleteKeysMaxErrors int = 100 type DeleteCommand struct { argparser.Base argparser.JSONOutput - key argparser.OptionalString + key argparser.OptionalString + prefix argparser.OptionalString // NOTE: Public fields can be set via `kv-store delete`. DeleteAll bool @@ -46,7 +47,7 @@ func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteComman Globals: g, }, } - c.CmdClause = parent.Command("delete", "Delete a key") + c.CmdClause = parent.Command("delete", "Delete one, multiple, or all keys") // Required. c.CmdClause.Flag("store-id", "Store ID").Short('s').Required().StringVar(&c.StoreID) @@ -58,6 +59,7 @@ func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteComman c.CmdClause.Flag("if-generation-match", "Value which must match the current generation marker in an item for a delete operation to proceed").StringVar(&c.IfGenerationMatch) c.RegisterFlagBool(c.JSONFlag()) // --json c.CmdClause.Flag("key", "Key name").Short('k').Action(c.key.Set).StringVar(&c.key.Value) + c.CmdClause.Flag("prefix", "Delete items whose keys match this prefix").Action(c.prefix.Set).StringVar(&c.prefix.Value) c.CmdClause.Flag("max-errors", "The number of errors to accept before stopping (ignored when set without the --all flag)").Default(strconv.Itoa(DeleteKeysMaxErrors)).Short('m').IntVar(&c.MaxErrors) return &c @@ -69,14 +71,20 @@ func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { return fsterr.ErrInvalidVerboseJSONCombo } // TODO: Support --json for bulk deletions. - if c.DeleteAll && c.JSONOutput.Enabled { - return fsterr.ErrInvalidDeleteAllJSONKeyCombo + if (c.DeleteAll || c.prefix.WasSet) && c.JSONOutput.Enabled { + return fsterr.ErrInvalidDeleteMultipleJSONKeyCombo } if c.DeleteAll && c.key.WasSet { - return fsterr.ErrInvalidDeleteAllKeyCombo + return fsterr.ErrInvalidDeleteMultipleKeyCombo } - if !c.DeleteAll && !c.key.WasSet { - return fsterr.ErrMissingDeleteAllKeyCombo + if c.DeleteAll && c.prefix.WasSet { + return fsterr.ErrInvalidDeleteMultipleKeyCombo + } + if c.prefix.WasSet && c.key.WasSet { + return fsterr.ErrInvalidDeleteMultipleKeyCombo + } + if !c.DeleteAll && !c.prefix.WasSet && !c.key.WasSet { + return fsterr.ErrMissingDeleteMultipleKeyCombo } if c.DeleteAll { @@ -91,7 +99,22 @@ func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { } text.Break(out) } - return c.DeleteAllKeys(out) + return c.DeleteMultipleKeys(out, "") + } + + if c.prefix.WasSet { + if !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive { + text.Warning(out, "This may delete MANY entries from your store!\n\n") + cont, err := text.AskYesNo(out, "Are you sure you want to continue? [y/N]: ", in) + if err != nil { + return err + } + if !cont { + return nil + } + text.Break(out) + } + return c.DeleteMultipleKeys(out, c.prefix.Value) } input := fastly.DeleteKVStoreKeyInput{ @@ -133,9 +156,9 @@ func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { return nil } -// DeleteAllKeys deletes all keys within the specified KV Store. +// DeleteMultipleKeys deletes multiple keys from the specified KV Store. // NOTE: It's a public method as it can be called via `kv-store delete --all`. -func (c *DeleteCommand) DeleteAllKeys(out io.Writer) error { +func (c *DeleteCommand) DeleteMultipleKeys(out io.Writer, prefix string) error { spinnerMessage := "Deleting keys" var spinner text.Spinner @@ -152,6 +175,7 @@ func (c *DeleteCommand) DeleteAllKeys(out io.Writer) error { p := c.Globals.APIClient.NewListKVStoreKeysPaginator(context.TODO(), &fastly.ListKVStoreKeysInput{ StoreID: c.StoreID, + Prefix: prefix, }) errorsCh := make(chan string, c.MaxErrors) diff --git a/pkg/commands/kvstoreentry/kvstoreentry_test.go b/pkg/commands/kvstoreentry/kvstoreentry_test.go index dda95c0bc..2b46eff3f 100644 --- a/pkg/commands/kvstoreentry/kvstoreentry_test.go +++ b/pkg/commands/kvstoreentry/kvstoreentry_test.go @@ -203,15 +203,27 @@ func TestDeleteCommand(t *testing.T) { }, { Args: "--store-id " + storeID, - WantError: "invalid command, neither --all or --key provided", + WantError: "invalid command, none of --all, --prefix, or --key provided", }, { Args: "--json --all --store-id " + storeID, - WantError: "invalid flag combination, --all and --json", + WantError: "invalid flag combination, --all/--prefix and --json", }, { - Args: "--key a-key --all --store-id " + storeID, - WantError: "invalid flag combination, --all and --key", + Args: "--json --prefix a-prefix --store-id " + storeID, + WantError: "invalid flag combination, --all/--prefix and --json", + }, + { + Args: "--all --key a-key --store-id " + storeID, + WantError: "invalid flag combination, more than one of --all, --prefix, or --key specified", + }, + { + Args: "--key a-key --prefix a-prefix --store-id " + storeID, + WantError: "invalid flag combination, more than one of --all, --prefix, or --key specified", + }, + { + Args: "--all --prefix a-prefix --store-id " + storeID, + WantError: "invalid flag combination, more than one of --all, --prefix, or --key specified", }, { Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), @@ -303,6 +315,38 @@ func TestDeleteCommand(t *testing.T) { }, WantError: "failed to delete 3 keys", }, + { + // there is no way to mock the effect of the --prefix flag + Args: fmt.Sprintf("--store-id %s --prefix a-prefix --auto-yes", storeID), + API: &mock.API{ + NewListKVStoreKeysPaginatorFn: func(_ context.Context, _ *fastly.ListKVStoreKeysInput) fastly.PaginatorKVStoreEntries { + return &mockKVStoresEntriesPaginator{ + next: true, + keys: []string{"foo", "bar", "baz"}, + } + }, + DeleteKVStoreKeyFn: func(_ context.Context, _ *fastly.DeleteKVStoreKeyInput) error { + return nil + }, + }, + WantOutput: "Deleting keys...", + }, + { + // there is no way to mock the effect of the --prefix flag + Args: fmt.Sprintf("--store-id %s --prefix a-prefix --auto-yes", storeID), + API: &mock.API{ + NewListKVStoreKeysPaginatorFn: func(_ context.Context, _ *fastly.ListKVStoreKeysInput) fastly.PaginatorKVStoreEntries { + return &mockKVStoresEntriesPaginator{ + next: true, + keys: []string{"foo", "bar", "baz"}, + } + }, + DeleteKVStoreKeyFn: func(_ context.Context, _ *fastly.DeleteKVStoreKeyInput) error { + return errors.New("whoops") + }, + }, + WantError: "failed to delete 3 keys", + }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 035c78c41..1d0eb43e0 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -163,6 +163,29 @@ var ErrMissingDeleteAllKeyCombo = RemediationError{ Remediation: "Provide at least one of: --all or --key, not both.", } +// ErrInvalidDeleteMultipleJSONKeyCombo means the user requested +// multiple-key delete and provided the --json flag which are mutually +// exclusive behaviours. +var ErrInvalidDeleteMultipleJSONKeyCombo = RemediationError{ + Inner: fmt.Errorf("invalid flag combination, --all/--prefix and --json"), + Remediation: "Use either --all/--prefix or --json, not both.", +} + +// ErrInvalidDeleteMultipleKeyCombo means the user more than one of +// the --all, --prefix, and --key flags which are mutually exclusive +// behaviours. +var ErrInvalidDeleteMultipleKeyCombo = RemediationError{ + Inner: fmt.Errorf("invalid flag combination, more than one of --all, --prefix, or --key specified"), + Remediation: "Use --all, --prefix, or --key, not more than one.", +} + +// ErrMissingDeleteMultipleKeyCombo means the user omitted all of the +// --all, --prefix, and --key flags and we need one of them. +var ErrMissingDeleteMultipleKeyCombo = RemediationError{ + Inner: fmt.Errorf("invalid command, none of --all, --prefix, or --key provided"), + Remediation: "Provide one of --all, --prefix, or --key.", +} + // ErrNoSTDINData indicates the --stdin flag was specified but no data was piped // into stdin. var ErrNoSTDINData = RemediationError{