Skip to content

Commit fca4b89

Browse files
Add kv suspend / resume commands (#431)
Adds the ability to suspend and resume Key Values from the command line. https://linear.app/render-com/issue/GROW-2516/kv-resume https://linear.app/render-com/issue/GROW-2517/kv-suspend GitOrigin-RevId: a9097d810d98a4abc142299d461e30c9f7f79b11
1 parent 0ced4e4 commit fca4b89

10 files changed

Lines changed: 681 additions & 0 deletions

File tree

cmd/kvresume.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/render-oss/cli/pkg/client"
7+
"github.com/render-oss/cli/pkg/command"
8+
"github.com/render-oss/cli/pkg/keyvalue"
9+
"github.com/render-oss/cli/pkg/resolve"
10+
"github.com/render-oss/cli/pkg/text"
11+
kvtypes "github.com/render-oss/cli/pkg/types/keyvalue"
12+
)
13+
14+
var kvResumeCmd = &cobra.Command{
15+
Use: "resume <keyValueID|keyValueName>",
16+
Short: "Resume a suspended Key Value store instance",
17+
Args: cobra.ExactArgs(1),
18+
SilenceUsage: true,
19+
Long: `Resume a suspended Key Value store instance on Render.
20+
21+
The positional argument accepts either a Key Value ID (red-...) or a name.
22+
If the name matches more than one instance, narrow the search with
23+
--environment <id|name>, or pass the Key Value ID directly.
24+
25+
Name lookup is scoped to your active workspace. If a name isn't found, switch
26+
workspaces with 'render workspace set <name|ID>' and try again, or pass the
27+
Key Value ID instead (which works across workspaces).`,
28+
Example: ` # Resume by ID
29+
render ea kv resume red-abc123def456ghi789jkl0
30+
31+
# Resume by name
32+
render ea kv resume my-cache
33+
34+
# Disambiguate a name that exists in multiple environments
35+
render ea kv resume my-cache --environment production
36+
37+
# JSON output
38+
render ea kv resume red-abc123def456ghi789jkl0 --output json`,
39+
}
40+
41+
func init() {
42+
kvResumeCmd.Flags().String("environment", "",
43+
"Environment ID or name (optional). Narrows name lookup when the same Key Value name exists in multiple environments.")
44+
45+
kvResumeCmd.RunE = func(cmd *cobra.Command, args []string) error {
46+
command.DefaultFormatNonInteractive(cmd)
47+
48+
var input kvtypes.KeyValueResumeInput
49+
if err := command.ParseCommand(cmd, args, &input); err != nil {
50+
return err
51+
}
52+
input = kvtypes.NormalizeResumeInput(input)
53+
54+
loadData := func() (*client.KeyValueDetail, error) {
55+
var project *client.Project
56+
var env *client.Environment
57+
if input.EnvironmentIDOrName != nil {
58+
c, err := client.NewDefaultClient()
59+
if err != nil {
60+
return nil, err
61+
}
62+
scope, err := resolve.New(c).ResolveScopeInActiveWorkspace(cmd.Context(), resolve.ActiveWorkspaceScopeInput{
63+
EnvironmentIDOrName: input.EnvironmentIDOrName,
64+
})
65+
if err != nil {
66+
return nil, err
67+
}
68+
project = scope.Project
69+
env = scope.Environment
70+
}
71+
kv, err := keyvalue.Resolve(cmd.Context(), input.IDOrName, project, env)
72+
if err != nil {
73+
return nil, err
74+
}
75+
if err := keyvalue.Resume(cmd.Context(), kv.Id); err != nil {
76+
return nil, err
77+
}
78+
return keyvalue.Resolve(cmd.Context(), kv.Id, project, env)
79+
}
80+
81+
_, err := command.NonInteractive(cmd, loadData, formatResumeTextOutput)
82+
return err
83+
}
84+
85+
kvCmd.AddCommand(kvResumeCmd)
86+
}
87+
88+
func formatResumeTextOutput(kv *client.KeyValueDetail) string {
89+
return "Resumed this Key Value:\n\n" + text.KeyValueDetail(kv) + "\n"
90+
}

cmd/kvresume_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"testing"
7+
8+
renderapi "github.com/render-oss/cli/internal/fakes/renderapi"
9+
"github.com/render-oss/cli/internal/testids"
10+
"github.com/render-oss/cli/pkg/client"
11+
"github.com/spf13/pflag"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// executeKVResume runs `render ea kv resume <args>` against the fake server.
17+
// Seeds and selects an active workspace before running the command.
18+
func executeKVResume(t *testing.T, server *renderapi.Server, extraArgs ...string) (CommandResult, error) {
19+
t.Helper()
20+
t.Cleanup(resetKVResumeFlags)
21+
resetKVResumeFlags()
22+
23+
server.Owners.Add(renderapi.NewOwner(client.Owner{Id: ACTIVE_WORKSPACE_ID, Name: "Test Workspace"}))
24+
session := newCommandSession(t, server)
25+
if _, err := session.execute("workspace", "set", ACTIVE_WORKSPACE_ID, "--output", "text"); err != nil {
26+
return CommandResult{}, err
27+
}
28+
resetKVResumeFlags()
29+
30+
args := append([]string{"ea", "kv", "resume"}, extraArgs...)
31+
return session.execute(args...)
32+
}
33+
34+
func resetKVResumeFlags() {
35+
rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
36+
if f.Name == "output" {
37+
f.Changed = false
38+
f.Value.Set(f.DefValue) //nolint:errcheck
39+
}
40+
})
41+
kvResumeCmd.Flags().VisitAll(func(f *pflag.Flag) {
42+
f.Changed = false
43+
f.Value.Set(f.DefValue) //nolint:errcheck
44+
})
45+
}
46+
47+
// seedSuspendedKV adds a KV pre-seeded with Suspended status so resume tests
48+
// can assert the status flips back to Available.
49+
func seedSuspendedKV(server *renderapi.Server, name string) *client.KeyValueDetail {
50+
kv := seedKV(server, name)
51+
kv.Status = client.DatabaseStatusSuspended
52+
return kv
53+
}
54+
55+
func TestKVResume_ByID_Resumes(t *testing.T) {
56+
server := renderapi.NewServer(t)
57+
kv := seedSuspendedKV(server, "my-cache")
58+
59+
result, err := executeKVResume(t, server, kv.Id, "--output", "text")
60+
require.NoError(t, err)
61+
62+
assert.Equal(t, client.DatabaseStatusAvailable, server.KV.Instances[0].Status)
63+
assert.Contains(t, result.Stdout, "Resumed")
64+
assert.Contains(t, result.Stdout, kv.Id)
65+
}
66+
67+
func TestKVResume_ByName_Resumes(t *testing.T) {
68+
server := renderapi.NewServer(t)
69+
kv := seedSuspendedKV(server, "by-name-cache")
70+
71+
result, err := executeKVResume(t, server, "by-name-cache", "--output", "text")
72+
require.NoError(t, err)
73+
74+
assert.Equal(t, client.DatabaseStatusAvailable, server.KV.Instances[0].Status)
75+
assert.Contains(t, result.Stdout, "Resumed")
76+
assert.Contains(t, result.Stdout, kv.Id)
77+
}
78+
79+
func TestKVResume_NameCollision_Errors(t *testing.T) {
80+
server := renderapi.NewServer(t)
81+
seedSuspendedKV(server, "key-value-not-unique-name")
82+
seedSuspendedKV(server, "key-value-not-unique-name")
83+
84+
_, err := executeKVResume(t, server, "key-value-not-unique-name", "--output", "text")
85+
require.Error(t, err)
86+
assert.Contains(t, err.Error(), "Multiple Key Value instances")
87+
for _, kv := range server.KV.Instances {
88+
assert.Equal(t, client.DatabaseStatusSuspended, kv.Status, "no resume on ambiguity")
89+
}
90+
assert.False(t, server.HasRequest("POST", "/key-value/"))
91+
}
92+
93+
func TestKVResume_UnknownID_Errors(t *testing.T) {
94+
server := renderapi.NewServer(t)
95+
missing := testids.KeyValueID("missing")
96+
97+
_, err := executeKVResume(t, server, missing, "--output", "text")
98+
require.Error(t, err)
99+
assert.Contains(t, err.Error(), missing)
100+
assert.Contains(t, err.Error(), "No Key Value with ID")
101+
}
102+
103+
func TestKVResume_JSONOutput(t *testing.T) {
104+
server := renderapi.NewServer(t)
105+
kv := seedSuspendedKV(server, "json-cache")
106+
107+
result, err := executeKVResume(t, server, kv.Id, "--output", "json")
108+
require.NoError(t, err)
109+
assert.Equal(t, client.DatabaseStatusAvailable, server.KV.Instances[0].Status)
110+
111+
var body struct {
112+
ID string `json:"id"`
113+
Name string `json:"name"`
114+
Status string `json:"status"`
115+
}
116+
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &body))
117+
assert.Equal(t, kv.Id, body.ID)
118+
assert.Equal(t, "json-cache", body.Name)
119+
assert.Equal(t, string(client.DatabaseStatusAvailable), body.Status)
120+
}
121+
122+
func TestKVResume_NameCollision_NarrowedByEnvironment_Resumes(t *testing.T) {
123+
server := renderapi.NewServer(t)
124+
projectID := testids.ProjectID("project")
125+
envProdID := testids.EnvironmentID("production")
126+
envStagingID := testids.EnvironmentID("staging")
127+
server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{Id: projectID, Name: "My Project", OwnerId: ACTIVE_WORKSPACE_ID}))
128+
server.Environments.Add(renderapi.NewEnvironment(client.Environment{Id: envProdID, Name: "production", ProjectId: projectID}))
129+
server.Environments.Add(renderapi.NewEnvironment(client.Environment{Id: envStagingID, Name: "staging", ProjectId: projectID}))
130+
131+
prodKV := seedKVInEnv(server, "key-value-not-unique-name", envProdID)
132+
prodKV.Status = client.DatabaseStatusSuspended
133+
stagingKV := seedKVInEnv(server, "key-value-not-unique-name", envStagingID)
134+
stagingKV.Status = client.DatabaseStatusSuspended
135+
136+
result, err := executeKVResume(t, server, "key-value-not-unique-name", "--environment", "production", "--output", "text")
137+
require.NoError(t, err)
138+
139+
assert.Contains(t, result.Stdout, "Resumed")
140+
assert.Contains(t, result.Stdout, prodKV.Id)
141+
for _, kv := range server.KV.Instances {
142+
if kv.Id == prodKV.Id {
143+
assert.Equal(t, client.DatabaseStatusAvailable, kv.Status)
144+
}
145+
if kv.Id == stagingKV.Id {
146+
assert.Equal(t, client.DatabaseStatusSuspended, kv.Status, "staging KV must not be resumed")
147+
}
148+
}
149+
}
150+
151+
func TestKVResume_APIError_Surfaced(t *testing.T) {
152+
// First nextError is consumed by Resolve's GET; surface from there to
153+
// confirm failure propagates and no resume POST fires.
154+
server := renderapi.NewServer(t)
155+
kv := seedSuspendedKV(server, "my-cache")
156+
server.KV.RespondWith(http.StatusInternalServerError)
157+
158+
_, err := executeKVResume(t, server, kv.Id, "--output", "text")
159+
require.Error(t, err)
160+
assert.Equal(t, client.DatabaseStatusSuspended, server.KV.Instances[0].Status, "API error must not flip status")
161+
assert.False(t, server.HasRequest("POST", "/key-value/"+kv.Id+"/resume"))
162+
}

cmd/kvsuspend.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/render-oss/cli/pkg/client"
7+
"github.com/render-oss/cli/pkg/command"
8+
"github.com/render-oss/cli/pkg/keyvalue"
9+
"github.com/render-oss/cli/pkg/resolve"
10+
"github.com/render-oss/cli/pkg/text"
11+
kvtypes "github.com/render-oss/cli/pkg/types/keyvalue"
12+
)
13+
14+
var kvSuspendCmd = &cobra.Command{
15+
Use: "suspend <keyValueID|keyValueName>",
16+
Short: "Suspend a Key Value store instance",
17+
Args: cobra.ExactArgs(1),
18+
SilenceUsage: true,
19+
Long: `Suspend a Key Value store instance on Render.
20+
21+
Without --confirm, this command previews what would be suspended and makes no
22+
changes. Pass --confirm to actually suspend the instance.
23+
24+
The positional argument accepts either a Key Value ID (red-...) or a name.
25+
If the name matches more than one instance, narrow the search with
26+
--environment <id|name>, or pass the Key Value ID directly.
27+
28+
Name lookup is scoped to your active workspace. If a name isn't found, switch
29+
workspaces with 'render workspace set <name|ID>' and try again, or pass the
30+
Key Value ID instead (which works across workspaces).`,
31+
Example: ` # Preview suspension (no changes made)
32+
render ea kv suspend red-abc123def456ghi789jkl0
33+
34+
# Suspend by ID
35+
render ea kv suspend red-abc123def456ghi789jkl0 --confirm
36+
37+
# Suspend by name
38+
render ea kv suspend my-cache --confirm
39+
40+
# Disambiguate a name that exists in multiple environments
41+
render ea kv suspend my-cache --environment production --confirm
42+
43+
# JSON output
44+
render ea kv suspend red-abc123def456ghi789jkl0 --confirm --output json`,
45+
}
46+
47+
func init() {
48+
kvSuspendCmd.Flags().String("environment", "",
49+
"Environment ID or name (optional). Narrows name lookup when the same Key Value name exists in multiple environments.")
50+
51+
kvSuspendCmd.RunE = func(cmd *cobra.Command, args []string) error {
52+
command.DefaultFormatNonInteractive(cmd)
53+
54+
var input kvtypes.KeyValueSuspendInput
55+
if err := command.ParseCommand(cmd, args, &input); err != nil {
56+
return err
57+
}
58+
input = kvtypes.NormalizeSuspendInput(input)
59+
confirm := command.GetConfirmFromContext(cmd.Context())
60+
61+
loadData := func() (*keyvalue.SuspendResult, error) {
62+
var project *client.Project
63+
var env *client.Environment
64+
if input.EnvironmentIDOrName != nil {
65+
c, err := client.NewDefaultClient()
66+
if err != nil {
67+
return nil, err
68+
}
69+
scope, err := resolve.New(c).ResolveScopeInActiveWorkspace(cmd.Context(), resolve.ActiveWorkspaceScopeInput{
70+
EnvironmentIDOrName: input.EnvironmentIDOrName,
71+
})
72+
if err != nil {
73+
return nil, err
74+
}
75+
project = scope.Project
76+
env = scope.Environment
77+
}
78+
kv, err := keyvalue.Resolve(cmd.Context(), input.IDOrName, project, env)
79+
if err != nil {
80+
return nil, err
81+
}
82+
if !confirm {
83+
return &keyvalue.SuspendResult{KeyValue: kv, Suspended: false}, nil
84+
}
85+
if err := keyvalue.Suspend(cmd.Context(), kv.Id); err != nil {
86+
return nil, err
87+
}
88+
post, err := keyvalue.Resolve(cmd.Context(), kv.Id, project, env)
89+
if err != nil {
90+
return nil, err
91+
}
92+
return &keyvalue.SuspendResult{KeyValue: post, Suspended: true}, nil
93+
}
94+
95+
_, err := command.NonInteractive(cmd, loadData, formatSuspendTextOutput)
96+
return err
97+
}
98+
99+
kvCmd.AddCommand(kvSuspendCmd)
100+
}
101+
102+
func formatSuspendTextOutput(r *keyvalue.SuspendResult) string {
103+
if r.Suspended {
104+
return "Suspended this Key Value:\n\n" + text.KeyValueDetail(r.KeyValue) + "\n"
105+
}
106+
return "This command would suspend this Key Value:\n\n" +
107+
text.KeyValueDetail(r.KeyValue) +
108+
"\n\nRe-run with --confirm to proceed\n"
109+
}

0 commit comments

Comments
 (0)