Skip to content

Commit d4faaa6

Browse files
Add Key Value output contracts for action commands (#474)
Adds action metadata for commands that need it: - `delete`: `{ "data": ..., "meta": { "deleted": true|false, "message": "..." } }` - `suspend`: `{ "data": ..., "meta": { "suspended": true|false, "message": "..." } }` Basically, define and fully control the JSON output for these commands instead of passing along raw API payloads. --- These commands all operate on a single existing KV, so they can use the same flattened item contract introduced by `kv get`. `delete` and `suspend` are special because they can run in preview mode. Their JSON needs to represent both the KV and the operation state, so this PR gives them explicit `data` and `meta` envelopes. `resume` does not need operation metadata; it returns the resumed `KeyValueOut` directly. GROW-2587: https://linear.app/render-com/issue/GROW-2587/slim-down-kv-json-output-to-resource-only GitOrigin-RevId: e4b264a2796f1e570f55a5a73a5f40474ee23e9f
1 parent e063108 commit d4faaa6

10 files changed

Lines changed: 154 additions & 80 deletions

File tree

cmd/kvdelete.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,35 +59,42 @@ Key Value ID instead (which works across workspaces).`,
5959
input = kvtypes.NormalizeDeleteInput(input)
6060
confirm := command.GetConfirmFromContext(cmd.Context())
6161

62-
loadData := func() (*keyvalue.DeleteResult, error) {
62+
loadData := func() (*keyvalue.DeleteOut, error) {
6363
resolved, err := deps.KeyValueService().Resolve(cmd.Context(), keyvalue.ResolveInput{
6464
IDOrName: input.IDOrName,
6565
EnvironmentIDOrName: input.EnvironmentIDOrName,
6666
})
6767
if err != nil {
6868
return nil, err
6969
}
70-
kv := resolved.KeyValue
70+
out := keyvalue.DeleteOut{
71+
Data: keyvalue.NewKeyValueOut(resolved),
72+
Meta: keyvalue.DeleteOutMeta{
73+
Deleted: confirm,
74+
},
75+
}
7176
if confirm {
72-
if err := deps.KeyValueService().Delete(cmd.Context(), kv.Id); err != nil {
77+
if err := deps.KeyValueService().Delete(cmd.Context(), out.Data.ID); err != nil {
7378
return nil, err
7479
}
80+
} else {
81+
out.Meta.Message = "re-run with --confirm to delete"
7582
}
76-
return &keyvalue.DeleteResult{KeyValue: kv, Deleted: confirm}, nil
83+
return &out, nil
7784
}
7885

79-
_, err := command.NonInteractive(cmd, loadData, formatTextOutput)
86+
_, err := command.NonInteractive(cmd, loadData, formatDeleteTextOutput)
8087
return err
8188
}
8289

8390
return cmd
8491
}
8592

86-
func formatTextOutput(r *keyvalue.DeleteResult) string {
87-
if r.Deleted {
88-
return "Deleted this Key Value:\n\n" + text.KeyValueAPIDetail(r.KeyValue) + "\n"
93+
func formatDeleteTextOutput(r *keyvalue.DeleteOut) string {
94+
if r.Meta.Deleted {
95+
return "Deleted this Key Value:\n\n" + text.KeyValueDetail(&r.Data) + "\n"
8996
}
9097
return "This command would delete this Key Value:\n\n" +
91-
text.KeyValueAPIDetail(r.KeyValue) +
98+
text.KeyValueDetail(&r.Data) +
9299
"\nRe-run with --confirm to proceed\n"
93100
}

cmd/kvdelete_test.go

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -96,23 +96,51 @@ func TestKVDelete_UnknownName_Errors(t *testing.T) {
9696

9797
func TestKVDelete_JSONOutput_AfterConfirm(t *testing.T) {
9898
server := renderapi.NewServer(t)
99-
kv := seedKV(server, "json-cache")
99+
project := server.CreateProject(
100+
renderapi.ProjectAttrs{Name: "My Project", OwnerId: kvTestWorkspaceID},
101+
renderapi.EnvAttrs{Name: "production"},
102+
)
103+
env := project.Env("production")
104+
kv := seedKVInEnv(server, "json-cache", env.Id)
100105

101106
result, err := executeKVDelete(t, server, kv.Id, "--confirm", "--output", "json")
102107
require.NoError(t, err)
103108
assert.Empty(t, server.KV.Instances)
104109

105-
var body struct {
106-
KeyValue struct {
107-
ID string `json:"id"`
108-
Name string `json:"name"`
109-
} `json:"keyValue"`
110-
Deleted bool `json:"deleted"`
111-
}
110+
var body map[string]any
111+
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &body))
112+
require.Len(t, body, 2)
113+
114+
data := requireSubMap(t, body, "data")
115+
meta := requireSubMap(t, body, "meta")
116+
assert.Equal(t, kv.Id, data["id"])
117+
assert.Equal(t, "json-cache", data["name"])
118+
assert.Equal(t, kvTestWorkspaceID, data["ownerId"])
119+
assert.Equal(t, project.Project.Id, data["projectId"])
120+
assert.Equal(t, env.Id, data["environmentId"])
121+
assert.Equal(t, true, meta["deleted"])
122+
assert.NotContains(t, meta, "message")
123+
assert.NotContains(t, body, "keyValue")
124+
assert.NotContains(t, data, "projectName")
125+
assert.NotContains(t, data, "environmentName")
126+
}
127+
128+
func TestKVDelete_JSONOutput_PreviewIncludesConfirmMessage(t *testing.T) {
129+
server := renderapi.NewServer(t)
130+
kv := seedKV(server, "json-cache")
131+
132+
result, err := executeKVDelete(t, server, kv.Id, "--output", "json")
133+
require.NoError(t, err)
134+
assert.Len(t, server.KV.Instances, 1, "preview must not delete")
135+
136+
var body map[string]any
112137
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &body))
113-
assert.Equal(t, kv.Id, body.KeyValue.ID)
114-
assert.Equal(t, "json-cache", body.KeyValue.Name)
115-
assert.True(t, body.Deleted)
138+
139+
data := requireSubMap(t, body, "data")
140+
meta := requireSubMap(t, body, "meta")
141+
assert.Equal(t, kv.Id, data["id"])
142+
assert.Equal(t, false, meta["deleted"])
143+
assert.Equal(t, "re-run with --confirm to delete", meta["message"])
116144
}
117145

118146
func TestKVDelete_JSONOutput_OnError(t *testing.T) {

cmd/kvresume.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package cmd
33
import (
44
"github.com/spf13/cobra"
55

6-
"github.com/render-oss/cli/pkg/client"
76
"github.com/render-oss/cli/pkg/command"
87
"github.com/render-oss/cli/pkg/dependencies"
98
"github.com/render-oss/cli/pkg/keyvalue"
@@ -51,7 +50,7 @@ Key Value ID instead (which works across workspaces).`,
5150
}
5251
input = kvtypes.NormalizeResumeInput(input)
5352

54-
loadData := func() (*client.KeyValueDetail, error) {
53+
loadData := func() (*keyvalue.KeyValueOut, error) {
5554
kv, err := deps.KeyValueService().Resolve(cmd.Context(), keyvalue.ResolveInput{
5655
IDOrName: input.IDOrName,
5756
EnvironmentIDOrName: input.EnvironmentIDOrName,
@@ -68,7 +67,8 @@ Key Value ID instead (which works across workspaces).`,
6867
if err != nil {
6968
return nil, err
7069
}
71-
return kv.KeyValue, nil
70+
out := keyvalue.NewKeyValueOut(kv)
71+
return &out, nil
7272
}
7373

7474
_, err := command.NonInteractive(cmd, loadData, formatResumeTextOutput)
@@ -78,6 +78,6 @@ Key Value ID instead (which works across workspaces).`,
7878
return cmd
7979
}
8080

81-
func formatResumeTextOutput(kv *client.KeyValueDetail) string {
82-
return "Resumed this Key Value:\n\n" + text.KeyValueAPIDetail(kv) + "\n"
81+
func formatResumeTextOutput(kv *keyvalue.KeyValueOut) string {
82+
return "Resumed this Key Value:\n\n" + text.KeyValueDetail(kv) + "\n"
8383
}

cmd/kvresume_test.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,21 +79,29 @@ func TestKVResume_UnknownID_Errors(t *testing.T) {
7979

8080
func TestKVResume_JSONOutput(t *testing.T) {
8181
server := renderapi.NewServer(t)
82-
kv := seedSuspendedKV(server, "json-cache")
82+
project := server.CreateProject(
83+
renderapi.ProjectAttrs{Name: "My Project", OwnerId: kvTestWorkspaceID},
84+
renderapi.EnvAttrs{Name: "production"},
85+
)
86+
env := project.Env("production")
87+
kv := seedKVInEnv(server, "json-cache", env.Id)
88+
kv.Status = client.DatabaseStatusSuspended
8389

8490
result, err := executeKVResume(t, server, kv.Id, "--output", "json")
8591
require.NoError(t, err)
8692
assert.Equal(t, client.DatabaseStatusAvailable, server.KV.Instances[0].Status)
8793

88-
var body struct {
89-
ID string `json:"id"`
90-
Name string `json:"name"`
91-
Status string `json:"status"`
92-
}
94+
var body map[string]any
9395
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &body))
94-
assert.Equal(t, kv.Id, body.ID)
95-
assert.Equal(t, "json-cache", body.Name)
96-
assert.Equal(t, string(client.DatabaseStatusAvailable), body.Status)
96+
assert.Equal(t, kv.Id, body["id"])
97+
assert.Equal(t, "json-cache", body["name"])
98+
assert.Equal(t, string(client.DatabaseStatusAvailable), body["status"])
99+
assert.Equal(t, kvTestWorkspaceID, body["ownerId"])
100+
assert.Equal(t, project.Project.Id, body["projectId"])
101+
assert.Equal(t, env.Id, body["environmentId"])
102+
assert.NotContains(t, body, "keyValue")
103+
assert.NotContains(t, body, "projectName")
104+
assert.NotContains(t, body, "environmentName")
97105
}
98106

99107
func TestKVResume_NameCollision_NarrowedByEnvironment_Resumes(t *testing.T) {

cmd/kvsuspend.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,28 +57,35 @@ Key Value ID instead (which works across workspaces).`,
5757
input = kvtypes.NormalizeSuspendInput(input)
5858
confirm := command.GetConfirmFromContext(cmd.Context())
5959

60-
loadData := func() (*keyvalue.SuspendResult, error) {
60+
loadData := func() (*keyvalue.SuspendOut, error) {
6161
before, err := deps.KeyValueService().Resolve(cmd.Context(), keyvalue.ResolveInput{
6262
IDOrName: input.IDOrName,
6363
EnvironmentIDOrName: input.EnvironmentIDOrName,
6464
})
6565
if err != nil {
6666
return nil, err
6767
}
68-
kv := before.KeyValue
68+
out := keyvalue.SuspendOut{
69+
Data: keyvalue.NewKeyValueOut(before),
70+
Meta: keyvalue.SuspendOutMeta{
71+
Suspended: confirm,
72+
},
73+
}
6974
if !confirm {
70-
return &keyvalue.SuspendResult{KeyValue: kv, Suspended: false}, nil
75+
out.Meta.Message = "re-run with --confirm to suspend"
76+
return &out, nil
7177
}
72-
if err := deps.KeyValueService().Suspend(cmd.Context(), kv.Id); err != nil {
78+
if err := deps.KeyValueService().Suspend(cmd.Context(), out.Data.ID); err != nil {
7379
return nil, err
7480
}
7581
after, err := deps.KeyValueService().Resolve(cmd.Context(), keyvalue.ResolveInput{
76-
IDOrName: kv.Id,
82+
IDOrName: out.Data.ID,
7783
})
7884
if err != nil {
7985
return nil, err
8086
}
81-
return &keyvalue.SuspendResult{KeyValue: after.KeyValue, Suspended: true}, nil
87+
out.Data = keyvalue.NewKeyValueOut(after)
88+
return &out, nil
8289
}
8390

8491
_, err := command.NonInteractive(cmd, loadData, formatSuspendTextOutput)
@@ -88,11 +95,11 @@ Key Value ID instead (which works across workspaces).`,
8895
return cmd
8996
}
9097

91-
func formatSuspendTextOutput(r *keyvalue.SuspendResult) string {
92-
if r.Suspended {
93-
return "Suspended this Key Value:\n\n" + text.KeyValueAPIDetail(r.KeyValue) + "\n"
98+
func formatSuspendTextOutput(r *keyvalue.SuspendOut) string {
99+
if r.Meta.Suspended {
100+
return "Suspended this Key Value:\n\n" + text.KeyValueDetail(&r.Data) + "\n"
94101
}
95102
return "This command would suspend this Key Value:\n\n" +
96-
text.KeyValueAPIDetail(r.KeyValue) +
103+
text.KeyValueDetail(&r.Data) +
97104
"\n\nRe-run with --confirm to proceed\n"
98105
}

cmd/kvsuspend_test.go

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,38 @@ func TestKVSuspend_JSONOutput_AfterConfirm(t *testing.T) {
9292
require.NoError(t, err)
9393
assert.Equal(t, client.DatabaseStatusSuspended, server.KV.Instances[0].Status)
9494

95-
var body struct {
96-
KeyValue struct {
97-
ID string `json:"id"`
98-
Name string `json:"name"`
99-
Status string `json:"status"`
100-
} `json:"keyValue"`
101-
Suspended bool `json:"suspended"`
102-
}
95+
var body map[string]any
96+
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &body))
97+
require.Len(t, body, 2)
98+
99+
data := requireSubMap(t, body, "data")
100+
meta := requireSubMap(t, body, "meta")
101+
assert.Equal(t, kv.Id, data["id"])
102+
assert.Equal(t, "json-cache", data["name"])
103+
assert.Equal(t, string(client.DatabaseStatusSuspended), data["status"])
104+
assert.Equal(t, kvTestWorkspaceID, data["ownerId"])
105+
assert.Nil(t, data["projectId"])
106+
assert.Nil(t, data["environmentId"])
107+
assert.Equal(t, true, meta["suspended"])
108+
assert.NotContains(t, meta, "message")
109+
}
110+
111+
func TestKVSuspend_JSONOutput_PreviewIncludesConfirmMessage(t *testing.T) {
112+
server := renderapi.NewServer(t)
113+
kv := seedKV(server, "json-cache")
114+
115+
result, err := executeKVSuspend(t, server, kv.Id, "--output", "json")
116+
require.NoError(t, err)
117+
assert.Len(t, server.KV.Instances, 1, "preview must not suspend")
118+
119+
var body map[string]any
103120
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &body))
104-
assert.Equal(t, kv.Id, body.KeyValue.ID)
105-
assert.Equal(t, "json-cache", body.KeyValue.Name)
106-
assert.Equal(t, string(client.DatabaseStatusSuspended), body.KeyValue.Status)
107-
assert.True(t, body.Suspended)
121+
122+
data := requireSubMap(t, body, "data")
123+
meta := requireSubMap(t, body, "meta")
124+
assert.Equal(t, kv.Id, data["id"])
125+
assert.Equal(t, false, meta["suspended"])
126+
assert.Equal(t, "re-run with --confirm to suspend", meta["message"])
108127
}
109128

110129
func TestKVSuspend_NameCollision_NarrowedByEnvironment_Suspends(t *testing.T) {

cmd/testhelpers_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,12 @@ func newTestConfigPath(t *testing.T) string {
2121
_ = tmpCfg.Close()
2222
return tmpCfg.Name()
2323
}
24+
25+
// requireSubMap returns the nested map stored at key, failing the test if the
26+
// key is absent or the value is not a map[string]any.
27+
func requireSubMap(t *testing.T, body map[string]any, key string) map[string]any {
28+
t.Helper()
29+
require.Contains(t, body, key, "expected %q", key)
30+
require.IsType(t, map[string]any{}, body[key], "expected %q to contain a map", key)
31+
return body[key].(map[string]any)
32+
}

pkg/keyvalue/delete.go

Lines changed: 0 additions & 12 deletions
This file was deleted.

pkg/keyvalue/output.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,26 @@ type KeyValueOut struct {
2727
MaxmemoryPolicy *string `json:"maxmemoryPolicy,omitempty"`
2828
}
2929

30+
type DeleteOut struct {
31+
Data KeyValueOut `json:"data"`
32+
Meta DeleteOutMeta `json:"meta"`
33+
}
34+
35+
type DeleteOutMeta struct {
36+
Deleted bool `json:"deleted"`
37+
Message string `json:"message,omitempty"`
38+
}
39+
40+
type SuspendOut struct {
41+
Data KeyValueOut `json:"data"`
42+
Meta SuspendOutMeta `json:"meta"`
43+
}
44+
45+
type SuspendOutMeta struct {
46+
Suspended bool `json:"suspended"`
47+
Message string `json:"message,omitempty"`
48+
}
49+
3050
func NewKeyValueOut(resolved *ResolvedKeyValue) KeyValueOut {
3151
if resolved == nil || resolved.KeyValue == nil {
3252
return KeyValueOut{}

pkg/keyvalue/suspend.go

Lines changed: 0 additions & 12 deletions
This file was deleted.

0 commit comments

Comments
 (0)