Skip to content

Commit 75d8ec4

Browse files
Improve JSON output for kv update (#475)
- Creates a struct for the JSON/YAML output shape for `render ea kv update` (before we were returning the full data we got back from the API) - Returns the updated KV under `data`. - Returns a field-level `diff` for updateable fields so scripting users can inspect what changed. - Refactor: Moves IP allow-list equality into `internal/ipallowlist` so text and KV update diffing can share the same comparison. --- Make an _explicit_ contract for JSON output shape for kv. We take these contracts seriously after all! Unlike `get` and `resume`, `update` has some useful operation-specific metadata: the before/after diff. That diff is helpful for humans in text output and also useful for scripts, so this PR makes it part of the JSON contract instead of leaving it as text-only formatter logic. GROW-2587: https://linear.app/render-com/issue/GROW-2587/slim-down-kv-json-output-to-resource-only GitOrigin-RevId: e806c2345bdd8c90f0951466bfb36f7e84e88223
1 parent d4faaa6 commit 75d8ec4

12 files changed

Lines changed: 203 additions & 74 deletions

File tree

cmd/kvupdate.go

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import (
66

77
"github.com/spf13/cobra"
88

9-
"github.com/render-oss/cli/pkg/client"
109
"github.com/render-oss/cli/pkg/command"
1110
"github.com/render-oss/cli/pkg/dependencies"
11+
"github.com/render-oss/cli/pkg/keyvalue"
1212
"github.com/render-oss/cli/pkg/text"
1313
kvtypes "github.com/render-oss/cli/pkg/types/keyvalue"
1414
)
@@ -89,22 +89,17 @@ Example: --ip-allow-list "cidr=203.0.113.5/32,description=office"`
8989
return err
9090
}
9191

92-
// Captured by both closures so the text formatter can render a diff
93-
// while JSON/YAML output stays simple — just the new KV state, matching
94-
// kv create's output shape.
95-
var before *client.KeyValueDetail
96-
9792
_, err := command.NonInteractive(cmd,
98-
func() (*client.KeyValueDetail, error) {
93+
func() (*keyvalue.KeyValueUpdateOut, error) {
9994
result, err := deps.KeyValueService().Update(cmd.Context(), input)
10095
if err != nil {
10196
return nil, err
10297
}
103-
before = result.Before
104-
return result.After, nil
98+
out := keyvalue.NewKeyValueUpdateOut(result.Before, result.After)
99+
return &out, nil
105100
},
106-
func(after *client.KeyValueDetail) string {
107-
return kvUpdateSuccessMessage(before, after)
101+
func(out *keyvalue.KeyValueUpdateOut) string {
102+
return kvUpdateSuccessMessage(out)
108103
},
109104
)
110105
return err
@@ -113,9 +108,9 @@ Example: --ip-allow-list "cidr=203.0.113.5/32,description=office"`
113108
return cmd
114109
}
115110

116-
func kvUpdateSuccessMessage(before, after *client.KeyValueDetail) string {
117-
details := "Full details:\n " + strings.ReplaceAll(text.KeyValueAPIDetail(after), "\n", "\n ")
118-
diff := text.KeyValueUpdateDiff(before, after)
111+
func kvUpdateSuccessMessage(out *keyvalue.KeyValueUpdateOut) string {
112+
details := "Full details:\n " + strings.ReplaceAll(text.KeyValueDetail(&out.Data), "\n", "\n ")
113+
diff := text.KeyValueUpdateDiff(out.Diff)
119114
if diff == "" {
120115
return fmt.Sprintf("No changes applied to Key Value\n\n%s\n", details)
121116
}

cmd/kvupdate_test.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -180,19 +180,40 @@ func TestKVUpdate_IPAllowListFlags_MutuallyExclusive(t *testing.T) {
180180
// --- Output formats and API errors ---
181181

182182
// TestKVUpdate_OutputJSON proves --output json produces valid JSON of the
183-
// post-update KV (matching the shape `kv create` returns), not the pre/post
184-
// diff structure that text output renders.
183+
// post-update KV plus the public diff contract for fields update can mutate.
185184
func TestKVUpdate_OutputJSON(t *testing.T) {
186185
server := renderapi.NewServer(t)
187-
kv := seedKV(server, "before-name")
186+
project := server.CreateProject(
187+
renderapi.ProjectAttrs{Name: "My Project", OwnerId: kvTestWorkspaceID},
188+
renderapi.EnvAttrs{Name: "production"},
189+
)
190+
env := project.Env("production")
191+
kv := seedKVInEnv(server, "before-name", env.Id)
188192

189193
result, err := executeKVUpdate(t, server, kv.Id, "--name", "after-name", "--output", "json")
190194
require.NoError(t, err)
191195

192-
var body map[string]interface{}
196+
var body map[string]any
193197
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &body),
194198
"expected valid JSON, got: %s", result.Stdout)
195-
assert.Equal(t, "after-name", body["name"])
199+
200+
data := requireSubMap(t, body, "data")
201+
assert.Equal(t, "after-name", data["name"])
202+
assert.Equal(t, kv.Id, data["id"])
203+
assert.Equal(t, kvTestWorkspaceID, data["ownerId"])
204+
assert.Equal(t, project.Project.Id, data["projectId"])
205+
assert.Equal(t, env.Id, data["environmentId"])
206+
207+
diff := requireSubMap(t, body, "diff")
208+
nameDiff := requireSubMap(t, diff, "name")
209+
assert.Equal(t, "before-name", nameDiff["before"])
210+
assert.Equal(t, "after-name", nameDiff["after"])
211+
assert.NotContains(t, diff, "plan")
212+
assert.NotContains(t, diff, "maxmemoryPolicy")
213+
assert.NotContains(t, diff, "ipAllowList")
214+
215+
assert.NotContains(t, body, "before")
216+
assert.NotContains(t, body, "after")
196217
}
197218

198219
// TestKVUpdate_APIError_Propagates proves a 5xx from the PATCH surfaces as
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package ipallowlist
2+
3+
import (
4+
"cmp"
5+
"slices"
6+
7+
"github.com/render-oss/cli/pkg/client"
8+
)
9+
10+
// Equal reports whether two allow-lists contain the same entries.
11+
// An allow-list is conceptually a set, so the comparison is order-insensitive:
12+
// it sorts copies before comparing, never the caller's live slices.
13+
func Equal(a, b []client.CidrBlockAndDescription) bool {
14+
if len(a) != len(b) {
15+
return false
16+
}
17+
as := slices.Clone(a)
18+
bs := slices.Clone(b)
19+
byKey := func(x, y client.CidrBlockAndDescription) int {
20+
return cmp.Or(
21+
cmp.Compare(x.CidrBlock, y.CidrBlock),
22+
cmp.Compare(x.Description, y.Description),
23+
)
24+
}
25+
slices.SortFunc(as, byKey)
26+
slices.SortFunc(bs, byKey)
27+
return slices.Equal(as, bs)
28+
}
Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package text
1+
package ipallowlist
22

33
import (
44
"testing"
@@ -7,38 +7,38 @@ import (
77
"github.com/stretchr/testify/assert"
88
)
99

10-
func TestIPAllowListEqual(t *testing.T) {
10+
func TestEqual(t *testing.T) {
1111
office := client.CidrBlockAndDescription{CidrBlock: "203.0.113.5/32", Description: "office"}
1212
internal := client.CidrBlockAndDescription{CidrBlock: "10.0.0.0/8", Description: "internal"}
1313

1414
t.Run("same entries, same order", func(t *testing.T) {
1515
a := []client.CidrBlockAndDescription{office, internal}
1616
b := []client.CidrBlockAndDescription{office, internal}
17-
assert.True(t, ipAllowListEqual(a, b))
17+
assert.True(t, Equal(a, b))
1818
})
1919

2020
t.Run("same entries, different order", func(t *testing.T) {
2121
a := []client.CidrBlockAndDescription{office, internal}
2222
b := []client.CidrBlockAndDescription{internal, office}
23-
assert.True(t, ipAllowListEqual(a, b))
23+
assert.True(t, Equal(a, b))
2424
})
2525

2626
t.Run("different entries", func(t *testing.T) {
2727
a := []client.CidrBlockAndDescription{office}
2828
b := []client.CidrBlockAndDescription{internal}
29-
assert.False(t, ipAllowListEqual(a, b))
29+
assert.False(t, Equal(a, b))
3030
})
3131

3232
t.Run("different lengths", func(t *testing.T) {
3333
a := []client.CidrBlockAndDescription{office, internal}
3434
b := []client.CidrBlockAndDescription{office}
35-
assert.False(t, ipAllowListEqual(a, b))
35+
assert.False(t, Equal(a, b))
3636
})
3737

3838
t.Run("does not mutate the caller's slices", func(t *testing.T) {
3939
a := []client.CidrBlockAndDescription{office, internal}
4040
b := []client.CidrBlockAndDescription{internal, office}
41-
ipAllowListEqual(a, b)
41+
Equal(a, b)
4242
assert.Equal(t, []client.CidrBlockAndDescription{office, internal}, a)
4343
assert.Equal(t, []client.CidrBlockAndDescription{internal, office}, b)
4444
})

pkg/keyvalue/output.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ type SuspendOutMeta struct {
4747
Message string `json:"message,omitempty"`
4848
}
4949

50+
type KeyValueUpdateOut struct {
51+
Data KeyValueOut `json:"data"`
52+
Diff KeyValueUpdateDiff `json:"diff"`
53+
}
54+
55+
type KeyValueUpdateDiff struct {
56+
Name *KeyValueFieldDiff[string] `json:"name,omitempty"`
57+
Plan *KeyValueFieldDiff[client.KeyValuePlan] `json:"plan,omitempty"`
58+
MaxmemoryPolicy *KeyValueFieldDiff[*string] `json:"maxmemoryPolicy,omitempty"`
59+
IPAllowList *KeyValueFieldDiff[[]client.CidrBlockAndDescription] `json:"ipAllowList,omitempty"`
60+
}
61+
62+
type KeyValueFieldDiff[T any] struct {
63+
Before T `json:"before"`
64+
After T `json:"after"`
65+
}
66+
5067
func NewKeyValueOut(resolved *ResolvedKeyValue) KeyValueOut {
5168
if resolved == nil || resolved.KeyValue == nil {
5269
return KeyValueOut{}

pkg/keyvalue/service.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func (s *Service) Resume(ctx context.Context, id string) error {
104104
return s.repo.ResumeKeyValue(ctx, id)
105105
}
106106

107-
func (s *Service) Update(ctx context.Context, input kvtypes.KeyValueUpdateInput) (*UpdateResult, error) {
107+
func (s *Service) Update(ctx context.Context, input kvtypes.KeyValueUpdateInput) (*UpdateOutcome, error) {
108108
normalized, err := kvtypes.NormalizeAndValidateUpdateInput(input)
109109
if err != nil {
110110
return nil, err
@@ -127,7 +127,14 @@ func (s *Service) Update(ctx context.Context, input kvtypes.KeyValueUpdateInput)
127127
if err != nil {
128128
return nil, err
129129
}
130-
return &UpdateResult{Before: before.KeyValue, After: after}, nil
130+
return &UpdateOutcome{
131+
Before: before.KeyValue,
132+
After: &ResolvedKeyValue{
133+
KeyValue: after,
134+
Project: before.Project,
135+
Environment: before.Environment,
136+
},
137+
}, nil
131138
}
132139

133140
func (s *Service) hydrateKeyValueModel(ctx context.Context, kv *client.KeyValue, projects []*client.Project) (*Model, error) {

pkg/keyvalue/update.go

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,56 @@
11
package keyvalue
22

33
import (
4+
"github.com/render-oss/cli/internal/ipallowlist"
45
"github.com/render-oss/cli/pkg/client"
6+
"github.com/render-oss/cli/pkg/pointers"
57
"github.com/render-oss/cli/pkg/types"
68
kvtypes "github.com/render-oss/cli/pkg/types/keyvalue"
79
)
810

9-
// UpdateResult captures both the pre- and post-update KV state so callers can
10-
// show users a diff of what changed. JSON/YAML consumers also get strictly
11-
// more information than just the new state.
12-
type UpdateResult struct {
13-
Before *client.KeyValueDetail `json:"before"`
14-
After *client.KeyValueDetail `json:"after"`
11+
// UpdateOutcome captures both the pre-update API detail and post-update
12+
// resolved state needed by command output.
13+
type UpdateOutcome struct {
14+
Before *client.KeyValueDetail
15+
After *ResolvedKeyValue
16+
}
17+
18+
func NewKeyValueUpdateOut(before *client.KeyValueDetail, after *ResolvedKeyValue) KeyValueUpdateOut {
19+
out := KeyValueUpdateOut{
20+
Data: NewKeyValueOut(after),
21+
}
22+
if before == nil {
23+
return out
24+
}
25+
out.Diff = NewKeyValueUpdateDiff(before, &out.Data)
26+
return out
27+
}
28+
29+
func NewKeyValueUpdateDiff(before *client.KeyValueDetail, after *KeyValueOut) KeyValueUpdateDiff {
30+
var diff KeyValueUpdateDiff
31+
if before.Name != after.Name {
32+
diff.Name = newKeyValueFieldDiff(before.Name, after.Name)
33+
}
34+
if before.Plan != after.Plan {
35+
diff.Plan = newKeyValueFieldDiff(before.Plan, after.Plan)
36+
}
37+
if !pointers.Equal(before.Options.MaxmemoryPolicy, after.MaxmemoryPolicy) {
38+
diff.MaxmemoryPolicy = newKeyValueFieldDiff(
39+
before.Options.MaxmemoryPolicy,
40+
after.MaxmemoryPolicy,
41+
)
42+
}
43+
if !ipallowlist.Equal(before.IpAllowList, after.IPAllowList) {
44+
diff.IPAllowList = newKeyValueFieldDiff(before.IpAllowList, after.IPAllowList)
45+
}
46+
return diff
47+
}
48+
49+
func newKeyValueFieldDiff[T any](before, after T) *KeyValueFieldDiff[T] {
50+
return &KeyValueFieldDiff[T]{
51+
Before: before,
52+
After: after,
53+
}
1554
}
1655

1756
// BuildUpdateRequest converts a normalized KeyValueUpdateInput into the API

pkg/pointers/pointers.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ func ValueOrDefault[T any](x *T, def T) T {
2121
return *x
2222
}
2323

24+
// Equal reports whether two pointers are both nil or point to equal values.
25+
// It compares the pointed-to values, not the pointer addresses. T must be
26+
// comparable because Equal uses == after dereferencing non-nil pointers.
27+
func Equal[T comparable](a, b *T) bool {
28+
if a == nil || b == nil {
29+
return a == b
30+
}
31+
return *a == *b
32+
}
33+
2434
func PointerValueIfNotEmptyString(s string) *string {
2535
if s == "" {
2636
return nil

pkg/pointers/pointers_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package pointers
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestEqual(t *testing.T) {
10+
value := "value"
11+
same := "value"
12+
other := "other"
13+
14+
tests := []struct {
15+
name string
16+
a *string
17+
b *string
18+
want bool
19+
}{
20+
{name: "both nil", want: true},
21+
{name: "left nil", b: &value, want: false},
22+
{name: "right nil", a: &value, want: false},
23+
{name: "same value", a: &value, b: &same, want: true},
24+
{name: "different value", a: &value, b: &other, want: false},
25+
}
26+
27+
for _, tt := range tests {
28+
t.Run(tt.name, func(t *testing.T) {
29+
require.Equal(t, tt.want, Equal(tt.a, tt.b))
30+
})
31+
}
32+
}

pkg/text/ipallowlist.go

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
package text
22

33
import (
4-
"cmp"
54
"fmt"
6-
"slices"
75
"strings"
86

97
"github.com/render-oss/cli/pkg/client"
@@ -42,24 +40,3 @@ func ipAllowListLabel(entries []client.CidrBlockAndDescription) string {
4240
return fmt.Sprintf("%d entries", len(entries))
4341
}
4442
}
45-
46-
// ipAllowListEqual reports whether two allow-lists contain the same entries.
47-
// An allow-list is conceptually a set, so the comparison is order-insensitive:
48-
// it sorts copies (never the caller's slices, which are live server data)
49-
// before comparing, so a reordered list is not reported as a change.
50-
func ipAllowListEqual(a, b []client.CidrBlockAndDescription) bool {
51-
if len(a) != len(b) {
52-
return false
53-
}
54-
as := slices.Clone(a)
55-
bs := slices.Clone(b)
56-
byKey := func(x, y client.CidrBlockAndDescription) int {
57-
return cmp.Or(
58-
cmp.Compare(x.CidrBlock, y.CidrBlock),
59-
cmp.Compare(x.Description, y.Description),
60-
)
61-
}
62-
slices.SortFunc(as, byKey)
63-
slices.SortFunc(bs, byKey)
64-
return slices.Equal(as, bs)
65-
}

0 commit comments

Comments
 (0)