Skip to content

Commit 784ef73

Browse files
rcarilkpfleming
andauthored
feat(kvstoreentry): Support for missing CRUD attributes (#1529)
All Submissions: ### New Feature Submissions: * [x] Does your submission pass tests? ### Changes to Core Features: * [x] Have you added an explanation of what your changes do and why you'd like us to include them? * [x] Have you written new tests for your core changes, as applicable? * [x] Have you successfully run tests with your changes locally? ### User Impact - New `get` command that only provides the `value` of a `key` - Updated the `describe` to now only provide the attributes of a key, rather than the value of a key itself ### Are there any considerations that need to be addressed for release? The following features are now supported: ### Create: - add - append - prepend - metadata - if_generation_match - background_fetch ### Delete - if_generation_match - force ### Describe - if_generation_match - metadata ### Get - if_generation_match ## Tests: ``` make test TEST_ARGS="-run TestCreateCommand ./pkg/commands/kvstoreentry/" ok github.com/fastly/cli/pkg/commands/kvstoreentry 1.105s make test TEST_ARGS="-run TestGetCommand ./pkg/commands/kvstoreentry/" ok github.com/fastly/cli/pkg/commands/kvstoreentry 1.083s make test TEST_ARGS="-run TestDescribeCommand ./pkg/commands/kvstoreentry/" ok github.com/fastly/cli/pkg/commands/kvstoreentry 1.069s make test TEST_ARGS="-run TestDeleteCommand ./pkg/commands/kvstoreentry/" ok github.com/fastly/cli/pkg/commands/kvstoreentry 1.103s ``` --------- Co-authored-by: Kevin P. Fleming <kpfleming@users.noreply.github.com>
1 parent d8c01ef commit 784ef73

12 files changed

Lines changed: 497 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@
33
## [Unreleased]
44

55
### Breaking:
6+
- breaking(kvstoreentry): The 'describe' command now returns only key attributes (ie: generation, metadata) instead of a given key's value ([#1529](https://github.com/fastly/cli/pull/1529))
67

78
### Enhancements:
89
- feat(logging): Add support for 'CompressionCodec' and 'GzipLevel' attribute to the HTTPS endpoint.
910
- feat(kvstoreentry): Add support for the 'prefix' parameter for List operations ([#1526](https://github.com/fastly/cli/pull/1526))
11+
- feat(kvstoreentry): Add support for the add, append, prepend, metadata, if_generation_match, and background_fetch 'create' command operations ([#1529](https://github.com/fastly/cli/pull/1529))
12+
- feat(kvstoreentry): Add support for the if_generation_match and metadata 'describe' command operations ([#1529](https://github.com/fastly/cli/pull/1529))
13+
- feat(kvstoreentry): Add support for the if_generation_match and force 'delete' command operations ([#1529](https://github.com/fastly/cli/pull/1529))
14+
- feat(kvstoreentry): Add the 'get' command operation which obtains the value of the item ([#1529](https://github.com/fastly/cli/pull/1529))
1015

1116
### Bug fixes:
1217

pkg/api/interface.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ type Interface interface {
368368
GetKVStore(context.Context, *fastly.GetKVStoreInput) (*fastly.KVStore, error)
369369
ListKVStoreKeys(context.Context, *fastly.ListKVStoreKeysInput) (*fastly.ListKVStoreKeysResponse, error)
370370
GetKVStoreKey(context.Context, *fastly.GetKVStoreKeyInput) (string, error)
371+
GetKVStoreItem(context.Context, *fastly.GetKVStoreItemInput) (fastly.GetKVStoreItemOutput, error)
371372
DeleteKVStoreKey(context.Context, *fastly.DeleteKVStoreKeyInput) error
372373
InsertKVStoreKey(context.Context, *fastly.InsertKVStoreKeyInput) error
373374
BatchModifyKVStoreKey(context.Context, *fastly.BatchModifyKVStoreKeyInput) error

pkg/app/run.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ func Exec(data *global.Data) error {
236236
}
237237

238238
apiEndpoint, endpointSource := data.APIEndpoint()
239-
if data.Verbose() {
239+
if data.Verbose() && !commandSuppressesVerbose(command) {
240240
displayAPIEndpoint(apiEndpoint, endpointSource, data.Output)
241241
}
242242

@@ -281,7 +281,7 @@ func Exec(data *global.Data) error {
281281
return fmt.Errorf("failed to process token: %w", err)
282282
}
283283

284-
if data.Verbose() {
284+
if data.Verbose() && !commandSuppressesVerbose(command) {
285285
displayToken(tokenSource, data)
286286
}
287287
if !data.Flags.Quiet {
@@ -761,3 +761,17 @@ func accountEndpoint(args []string, e config.Environment, cfg config.File) strin
761761
// Otherwise return the default account endpoint.
762762
return global.DefaultAccountEndpoint
763763
}
764+
765+
// commandSuppressesVerbose checks if the given command suppresses verbose output.
766+
// This uses type assertion to check if the command has an embedded Base struct with SuppressVerbose set.
767+
func commandSuppressesVerbose(command argparser.Command) bool {
768+
// Try to access the SuppressesVerbose method which is available on commands that embed argparser.Base
769+
type verboseSuppressor interface {
770+
SuppressesVerbose() bool
771+
}
772+
if vs, ok := command.(verboseSuppressor); ok {
773+
return vs.SuppressesVerbose()
774+
}
775+
776+
return false
777+
}

pkg/argparser/cmd.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@ type Globals struct {
5555

5656
// Base is stuff that should be included in every concrete command.
5757
type Base struct {
58-
CmdClause *kingpin.CmdClause
59-
Globals *global.Data
58+
CmdClause *kingpin.CmdClause
59+
Globals *global.Data
60+
SuppressVerbose bool
6061
}
6162

6263
// Name implements the Command interface, and returns the FullCommand from the
@@ -65,6 +66,11 @@ func (b Base) Name() string {
6566
return b.CmdClause.FullCommand()
6667
}
6768

69+
// SuppressesVerbose returns true if this command should suppress verbose output.
70+
func (b Base) SuppressesVerbose() bool {
71+
return b.SuppressVerbose
72+
}
73+
6874
// Optional models an optional type that consumers can use to assert whether the
6975
// inner value has been set and is therefore valid for use.
7076
type Optional struct {

pkg/commands/commands.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ func Define( // nolint:revive // function-length
226226
kvstoreentryCmdRoot := kvstoreentry.NewRootCommand(app, data)
227227
kvstoreentryCreate := kvstoreentry.NewCreateCommand(kvstoreentryCmdRoot.CmdClause, data)
228228
kvstoreentryDelete := kvstoreentry.NewDeleteCommand(kvstoreentryCmdRoot.CmdClause, data)
229+
kvstoreentryGet := kvstoreentry.NewGetCommand(kvstoreentryCmdRoot.CmdClause, data)
229230
kvstoreentryDescribe := kvstoreentry.NewDescribeCommand(kvstoreentryCmdRoot.CmdClause, data)
230231
kvstoreentryList := kvstoreentry.NewListCommand(kvstoreentryCmdRoot.CmdClause, data)
231232
logtailCmdRoot := logtail.NewRootCommand(app, data)
@@ -644,6 +645,7 @@ func Define( // nolint:revive // function-length
644645
kvstoreList,
645646
kvstoreentryCreate,
646647
kvstoreentryDelete,
648+
kvstoreentryGet,
647649
kvstoreentryDescribe,
648650
kvstoreentryList,
649651
logtailCmdRoot,

pkg/commands/kvstoreentry/create.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"io/fs"
99
"os"
1010
"path/filepath"
11+
"strconv"
1112
"strings"
1213
"sync"
1314
"sync/atomic"
@@ -35,10 +36,16 @@ func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateComman
3536
c.CmdClause.Flag("store-id", "Store ID").Short('s').Required().StringVar(&c.Input.StoreID)
3637

3738
// Optional.
39+
c.CmdClause.Flag("add", "Limit the operation to adding a new item. If an existing item with the specified key exists, the operation will fail (default: false)").BoolVar(&c.add)
40+
c.CmdClause.Flag("append", "If an item with the specified key exists, the value provided in the operation is appended to the existing value instead of replacing it (default: false)").BoolVar(&c.append)
41+
c.CmdClause.Flag("background-fetch", "If set to true, the new value for the item will not be immediately visible to other users of the KV store; they will receive the existing (stale) value while the platform updates cached copies. Setting this to true ensures that other users of the KV store will receive responses to 'get' operations for this item quickly, although they will be slightly out of date (default: false)").BoolVar(&c.backFetch)
3842
c.CmdClause.Flag("dir", "Path to a directory containing individual files where the filename is the key and the file contents is the value").StringVar(&c.dirPath)
3943
c.CmdClause.Flag("dir-allow-hidden", "Allow hidden files (e.g. dot files) to be included (skipped by default)").BoolVar(&c.dirAllowHidden)
4044
c.CmdClause.Flag("dir-concurrency", "Limit the number of concurrent network resources allocated").Default("50").IntVar(&c.dirConcurrency)
4145
c.CmdClause.Flag("file", `Path to a file containing individual JSON objects (e.g., {"key":"...","value":"base64_encoded_value"}) separated by new-line delimiter`).StringVar(&c.filePath)
46+
c.CmdClause.Flag("if-generation-match", "Value which must match the current generation marker in an item for an update operation to proceed").StringVar(&c.ifGenMatch)
47+
c.CmdClause.Flag("metadata", "An arbitrary data field which can contain up to 2000 bytes of data").StringVar(&c.metadata)
48+
c.CmdClause.Flag("prepend", "If an item with the specified key exists, the value provided in the operation is prepended to the existing value instead of replacing it (Default: false)").BoolVar(&c.prepend)
4249
c.RegisterFlagBool(c.JSONFlag()) // --json
4350
c.CmdClause.Flag("key", "Key name").Short('k').StringVar(&c.Input.Key)
4451
c.CmdClause.Flag("stdin", "Read new-line separated JSON stream via STDIN").BoolVar(&c.stdin)
@@ -52,10 +59,16 @@ type CreateCommand struct {
5259
argparser.Base
5360
argparser.JSONOutput
5461

62+
add bool
63+
append bool
64+
backFetch bool
5565
dirAllowHidden bool
5666
dirConcurrency int
5767
dirPath string
5868
filePath string
69+
ifGenMatch string
70+
metadata string
71+
prepend bool
5972
stdin bool
6073

6174
Input fastly.InsertKVStoreKeyInput
@@ -87,6 +100,24 @@ func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error {
87100
return fsterr.ErrInvalidKVCombo
88101
}
89102

103+
// Append optional params.
104+
c.Input.Add = c.add
105+
c.Input.Append = c.append
106+
c.Input.BackgroundFetch = c.backFetch
107+
// Parse generation match if provided.
108+
if c.ifGenMatch != "" {
109+
inputGeneration, err := strconv.ParseUint(c.ifGenMatch, 10, 64)
110+
if err != nil {
111+
return fmt.Errorf("invalid generation value: %s", c.ifGenMatch)
112+
}
113+
c.Input.IfGenerationMatch = inputGeneration
114+
}
115+
116+
if c.metadata != "" {
117+
c.Input.Metadata = &c.metadata
118+
}
119+
c.Input.Prepend = c.prepend
120+
90121
err := c.Globals.APIClient.InsertKVStoreKey(context.TODO(), &c.Input)
91122
if err != nil {
92123
c.Globals.ErrLog.Add(err)

pkg/commands/kvstoreentry/delete.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ type DeleteCommand struct {
3131
key argparser.OptionalString
3232

3333
// NOTE: Public fields can be set via `kv-store delete`.
34-
DeleteAll bool
35-
MaxErrors int
36-
PoolSize int
37-
StoreID string
34+
DeleteAll bool
35+
Force bool
36+
IfGenerationMatch string
37+
MaxErrors int
38+
PoolSize int
39+
StoreID string
3840
}
3941

4042
// NewDeleteCommand returns a usable command registered under the parent.
@@ -52,6 +54,8 @@ func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteComman
5254
// Optional.
5355
c.CmdClause.Flag("all", "Delete all entries within the store").Short('a').BoolVar(&c.DeleteAll)
5456
c.CmdClause.Flag("concurrency", "The thread pool size (ignored when set without the --all flag)").Default(strconv.Itoa(DeleteKeysPoolSize)).Short('r').IntVar(&c.PoolSize)
57+
c.CmdClause.Flag("force", "Return a successful result from a 'delete' operation even if the specified key was not found").BoolVar(&c.Force)
58+
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)
5559
c.RegisterFlagBool(c.JSONFlag()) // --json
5660
c.CmdClause.Flag("key", "Key name").Short('k').Action(c.key.Set).StringVar(&c.key.Value)
5761
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)
@@ -92,9 +96,19 @@ func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error {
9296

9397
input := fastly.DeleteKVStoreKeyInput{
9498
StoreID: c.StoreID,
99+
Force: c.Force,
95100
Key: c.key.Value,
96101
}
97102

103+
// Validate generation value if provided.
104+
if c.IfGenerationMatch != "" {
105+
_, err := strconv.ParseUint(c.IfGenerationMatch, 10, 64)
106+
if err != nil {
107+
return fmt.Errorf("invalid generation value: %s", c.IfGenerationMatch)
108+
}
109+
input.IfGenerationMatch, _ = strconv.ParseUint(c.IfGenerationMatch, 10, 64)
110+
}
111+
98112
err := c.Globals.APIClient.DeleteKVStoreKey(context.TODO(), &input)
99113
if err != nil {
100114
c.Globals.ErrLog.Add(err)

pkg/commands/kvstoreentry/describe.go

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,26 @@ import (
1010
"github.com/fastly/cli/pkg/argparser"
1111
fsterr "github.com/fastly/cli/pkg/errors"
1212
"github.com/fastly/cli/pkg/global"
13-
"github.com/fastly/cli/pkg/text"
1413
)
1514

1615
// DescribeCommand calls the Fastly API to fetch the value of a key from an kv store.
1716
type DescribeCommand struct {
1817
argparser.Base
1918
argparser.JSONOutput
2019

21-
Input fastly.GetKVStoreKeyInput
20+
Input fastly.GetKVStoreItemInput
2221
}
2322

2423
// NewDescribeCommand returns a usable command registered under the parent.
2524
func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand {
2625
c := DescribeCommand{
2726
Base: argparser.Base{
2827
Globals: g,
28+
// This argument suppresses the 'Fastly API' output from the global verbose command.
29+
SuppressVerbose: true,
2930
},
3031
}
31-
c.CmdClause = parent.Command("describe", "Get the value associated with a key").Alias("get")
32+
c.CmdClause = parent.Command("describe", "Get the associated attributes of a key")
3233

3334
// Required.
3435
c.CmdClause.Flag("key", "Key name").Short('k').Required().StringVar(&c.Input.Key)
@@ -46,23 +47,33 @@ func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error {
4647
return fsterr.ErrInvalidVerboseJSONCombo
4748
}
4849

49-
value, err := c.Globals.APIClient.GetKVStoreKey(context.TODO(), &c.Input)
50+
if c.Globals.Flags.Verbose {
51+
// We won't be supporting a --verbose flag here as there wouldn't be any additional output to provide.
52+
return fmt.Errorf("the 'describe' command does not support the --verbose flag")
53+
}
54+
55+
item, err := c.Globals.APIClient.GetKVStoreItem(context.TODO(), &c.Input)
5056
if err != nil {
5157
c.Globals.ErrLog.Add(err)
5258
return err
5359
}
5460

5561
if c.JSONOutput.Enabled {
56-
text.Output(out, `{"%s": "%s"}`, c.Input.Key, value)
57-
return nil
58-
}
59-
60-
if c.Globals.Flags.Verbose {
61-
text.PrintKVStoreKeyValue(out, "", c.Input.Key, value)
62+
o := map[string]interface{}{
63+
"key": c.Input.Key,
64+
"generation": fmt.Sprintf("%d", item.Generation),
65+
"metadata": item.Metadata,
66+
}
67+
if ok, err := c.WriteJSON(out, o); ok {
68+
return err
69+
}
6270
return nil
6371
}
6472

6573
// IMPORTANT: Don't use `text` package as binary data can be messed up.
66-
fmt.Fprint(out, value)
74+
// Print the key attributes.
75+
fmt.Fprintf(out, "Key: %s\n", c.Input.Key)
76+
fmt.Fprintf(out, "Generation: %d\n", item.Generation)
77+
fmt.Fprintf(out, "Metadata: %s\n", item.Metadata)
6778
return nil
6879
}

pkg/commands/kvstoreentry/get.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package kvstoreentry
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"fmt"
7+
"io"
8+
"strconv"
9+
10+
"github.com/fastly/go-fastly/v11/fastly"
11+
12+
"github.com/fastly/cli/pkg/argparser"
13+
fsterr "github.com/fastly/cli/pkg/errors"
14+
"github.com/fastly/cli/pkg/global"
15+
"github.com/fastly/cli/pkg/text"
16+
)
17+
18+
// GetCommand calls the Fastly API to fetch the value of a key from an kv store.
19+
type GetCommand struct {
20+
argparser.Base
21+
argparser.JSONOutput
22+
23+
Input fastly.GetKVStoreItemInput
24+
Generation string
25+
}
26+
27+
// NewGetCommand returns a usable command registered under the parent.
28+
func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand {
29+
c := GetCommand{
30+
Base: argparser.Base{
31+
Globals: g,
32+
// This argument suppresses the 'Fastly API' output from the global verbose command.
33+
SuppressVerbose: true,
34+
},
35+
}
36+
c.CmdClause = parent.Command("get", "Get the value associated with a key")
37+
38+
// Required.
39+
c.CmdClause.Flag("key", "Key name").Short('k').Required().StringVar(&c.Input.Key)
40+
c.CmdClause.Flag("store-id", "Store ID").Short('s').Required().StringVar(&c.Input.StoreID)
41+
42+
// Optional.
43+
c.CmdClause.Flag("if-generation-match", "Compares if the provided generation marker matches that of the object").StringVar(&c.Generation)
44+
c.RegisterFlagBool(c.JSONFlag()) // --json
45+
46+
return &c
47+
}
48+
49+
// Exec invokes the application logic for the command.
50+
func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error {
51+
// As the 'describe' command provides the object attributes,
52+
// we won't be supporting a --verbose flag here.
53+
if c.Globals.Flags.Verbose {
54+
return fmt.Errorf("the 'get' command does not support the --verbose flag")
55+
}
56+
57+
if c.Globals.Verbose() && c.JSONOutput.Enabled {
58+
return fsterr.ErrInvalidVerboseJSONCombo
59+
}
60+
61+
// Validate generation value before making API call
62+
var inputGeneration uint64
63+
if c.Generation != "" {
64+
var err error
65+
inputGeneration, err = strconv.ParseUint(c.Generation, 10, 64)
66+
if err != nil {
67+
return fmt.Errorf("invalid generation value: %s", c.Generation)
68+
}
69+
}
70+
71+
result, err := c.Globals.APIClient.GetKVStoreItem(context.TODO(), &c.Input)
72+
if err != nil {
73+
c.Globals.ErrLog.Add(err)
74+
return err
75+
}
76+
77+
// Check if the generation marker matches the API result
78+
if c.Generation != "" {
79+
if inputGeneration != result.Generation {
80+
return fmt.Errorf("generation value does not match: expected %d, got %d", result.Generation, inputGeneration)
81+
}
82+
}
83+
84+
// Ensure we close the value reader.
85+
if result.Value != nil {
86+
defer result.Value.Close()
87+
}
88+
89+
// Read the value from ReadCloser.
90+
var value string
91+
if result.Value != nil {
92+
valueBytes, err := io.ReadAll(result.Value)
93+
if err != nil {
94+
c.Globals.ErrLog.Add(err)
95+
return err
96+
}
97+
value = string(valueBytes)
98+
}
99+
100+
if c.JSONOutput.Enabled {
101+
// We are encoding the value of the item here to ensure safe
102+
// output for binary content along with other outputs.
103+
encodedValue := base64.StdEncoding.EncodeToString([]byte(value))
104+
text.Output(out, `{"%s": "%s"}`, c.Input.Key, encodedValue)
105+
return nil
106+
}
107+
108+
// IMPORTANT: Don't use `text` package as binary data can be messed up.
109+
fmt.Fprint(out, value)
110+
return nil
111+
}

0 commit comments

Comments
 (0)