Skip to content

Commit 2a7fb9b

Browse files
Command to delete a service (#488)
Adds `render services delete [idOrName]` command to delete a service from the command line. - Only supports text, json, and yaml output modes (no interactive) - Requires `--confirm` to proceed GROW-2058 GitOrigin-RevId: a561fdad3de519be5c0b46997e5b75e73c41491f
1 parent 7b4468d commit 2a7fb9b

14 files changed

Lines changed: 594 additions & 8 deletions

File tree

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ func SetupCommands() error {
180180
setupWorkflowCommands(deps)
181181
setupLogCommands(deps)
182182
setupWorkspaceCommands(deps)
183+
servicesCmd.AddCommand(newServiceDeleteCmd(deps))
183184
setupKVCommands(EarlyAccessCmd, deps)
184185
setupPGCommands(EarlyAccessCmd, deps)
185186
setupRootCmdPersistentRun(rootCmd, deps)

cmd/servicedelete.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/render-oss/cli/pkg/command"
7+
"github.com/render-oss/cli/pkg/config"
8+
"github.com/render-oss/cli/pkg/dependencies"
9+
servicepkg "github.com/render-oss/cli/pkg/service"
10+
"github.com/render-oss/cli/pkg/text"
11+
)
12+
13+
type serviceDeleteInput struct {
14+
IDOrName string `cli:"arg:0"`
15+
}
16+
17+
func newServiceDeleteCmd(deps *dependencies.Dependencies) *cobra.Command {
18+
cmd := &cobra.Command{
19+
Use: "delete <serviceID|serviceName>",
20+
Short: "Delete a service",
21+
Args: cobra.ExactArgs(1),
22+
SilenceUsage: true,
23+
Long: `Delete a service on Render.
24+
25+
Without --confirm, this command previews what would be deleted and makes no
26+
changes. Pass --confirm to actually delete the service.
27+
28+
The positional argument accepts a service ID (including srv- or crn- IDs) or a
29+
name. Name lookup is scoped to your active workspace. If the name matches more
30+
than one service, pass the service ID directly.
31+
32+
This command only runs non-interactively. If --output interactive is requested,
33+
it falls back to text output.`,
34+
Example: ` # Preview deletion (no changes made)
35+
render services delete srv-abc123def456ghi789jkl0
36+
37+
# Delete by ID
38+
render services delete srv-abc123def456ghi789jkl0 --confirm
39+
40+
# Delete by name
41+
render services delete my-api --confirm
42+
43+
# JSON output
44+
render services delete srv-abc123def456ghi789jkl0 --confirm --output json`,
45+
}
46+
47+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
48+
command.DefaultFormatNonInteractive(cmd)
49+
50+
var input serviceDeleteInput
51+
if err := command.ParseCommand(cmd, args, &input); err != nil {
52+
return err
53+
}
54+
confirm := command.GetConfirmFromContext(cmd.Context())
55+
56+
loadData := func() (*servicepkg.DeleteOut, error) {
57+
if _, err := config.WorkspaceID(); err != nil {
58+
return nil, err
59+
}
60+
61+
repo := deps.ServiceRepo()
62+
serviceID, err := repo.ResolveServiceIDFromNameOrID(cmd.Context(), input.IDOrName)
63+
if err != nil {
64+
return nil, err
65+
}
66+
67+
model, err := deps.ServiceService().GetService(cmd.Context(), serviceID)
68+
if err != nil {
69+
return nil, err
70+
}
71+
out := servicepkg.NewDeleteOutFromModel(model)
72+
out.Meta = servicepkg.DeleteOutMeta{
73+
Deleted: confirm,
74+
}
75+
if confirm {
76+
if err := repo.DeleteService(cmd.Context(), model.Service.Id); err != nil {
77+
return nil, err
78+
}
79+
} else {
80+
out.Meta.Message = "re-run with --confirm to delete"
81+
}
82+
return &out, nil
83+
}
84+
85+
_, err := command.NonInteractive(cmd, loadData, serviceDeleteTextOutput)
86+
return err
87+
}
88+
89+
return cmd
90+
}
91+
92+
func serviceDeleteTextOutput(r *servicepkg.DeleteOut) string {
93+
if r.Meta.Deleted {
94+
return "Deleted this service:\n\n" + text.ServiceDetail(&r.Data) + "\n"
95+
}
96+
return "This command would delete this service:\n\n" +
97+
text.ServiceDetail(&r.Data) +
98+
"\n\nRe-run with --confirm to proceed\n"
99+
}

cmd/servicedelete_test.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
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/render-oss/cli/pkg/command"
12+
"github.com/render-oss/cli/pkg/config"
13+
"github.com/render-oss/cli/pkg/dependencies"
14+
"github.com/spf13/cobra"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
var serviceTestWorkspaceID = testids.WorkspaceID("active")
20+
21+
const serviceTestWorkspaceName = "Test Workspace"
22+
23+
func seedService(server *renderapi.Server, name string) *client.Service {
24+
return server.Services.Add(renderapi.NewWebService(renderapi.WebServiceAttrs{
25+
Service: renderapi.CommonServiceAttrs{
26+
Name: name,
27+
OwnerID: serviceTestWorkspaceID,
28+
},
29+
}))
30+
}
31+
32+
func executeServiceDelete(t *testing.T, server *renderapi.Server, extraArgs ...string) (CommandResult, error) {
33+
t.Helper()
34+
return executeServiceDeleteWithConfig(t, server, &config.Config{
35+
Workspace: serviceTestWorkspaceID,
36+
WorkspaceName: serviceTestWorkspaceName,
37+
}, extraArgs...)
38+
}
39+
40+
func executeServiceDeleteWithoutActiveWorkspace(t *testing.T, server *renderapi.Server, extraArgs ...string) (CommandResult, error) {
41+
t.Helper()
42+
return executeServiceDeleteWithConfig(t, server, &config.Config{}, extraArgs...)
43+
}
44+
45+
func executeServiceDeleteWithConfig(t *testing.T, server *renderapi.Server, cfg *config.Config, extraArgs ...string) (CommandResult, error) {
46+
t.Helper()
47+
48+
server.Owners.Add(renderapi.NewOwner(client.Owner{Id: serviceTestWorkspaceID, Name: serviceTestWorkspaceName}))
49+
t.Setenv("RENDER_CLI_CONFIG_PATH", newTestConfigPath(t))
50+
t.Setenv("RENDER_HOST", server.URL())
51+
t.Setenv("RENDER_API_KEY", "test-api-key")
52+
t.Setenv("RENDER_WORKSPACE", "")
53+
if cfg != nil {
54+
require.NoError(t, cfg.Persist())
55+
}
56+
57+
c, err := client.NewClientWithResponses(server.URL())
58+
require.NoError(t, err)
59+
deps := dependencies.New(c)
60+
deps.DetectRuntimeSignals = func() (command.RuntimeSignals, error) {
61+
return command.RuntimeSignals{
62+
StdinTTY: false,
63+
StdoutTTY: false,
64+
StderrTTY: false,
65+
}, nil
66+
}
67+
68+
root := newRootCmd()
69+
services := cobraServicesCommand()
70+
services.AddCommand(newServiceDeleteCmd(deps))
71+
root.AddCommand(services)
72+
setupRootCmdPersistentRun(root, deps)
73+
74+
var stdout, stderr bytes.Buffer
75+
root.SetOut(&stdout)
76+
root.SetErr(&stderr)
77+
root.SetArgs(append([]string{"services", "delete"}, extraArgs...))
78+
79+
execErr := root.Execute()
80+
return CommandResult{Stdout: stdout.String(), Stderr: stderr.String()}, execErr
81+
}
82+
83+
func cobraServicesCommand() *cobra.Command {
84+
return &cobra.Command{
85+
Use: "services",
86+
Aliases: []string{"service"},
87+
}
88+
}
89+
90+
func TestServiceDelete_PreviewByID_DoesNotDelete(t *testing.T) {
91+
server := renderapi.NewServer(t)
92+
svc := seedService(server, "my-api")
93+
94+
result, err := executeServiceDelete(t, server, svc.Id, "--output", "text")
95+
require.NoError(t, err)
96+
97+
assert.Len(t, server.Services.Instances, 1, "preview must not delete")
98+
assert.False(t, server.HasDeleteRequest(), "no DELETE call should be made in preview")
99+
assert.Contains(t, result.Stdout, "would delete")
100+
assert.Contains(t, result.Stdout, "--confirm")
101+
assert.Contains(t, result.Stdout, svc.Id)
102+
assert.Contains(t, result.Stdout, "my-api")
103+
}
104+
105+
func TestServiceDelete_ConfirmByName_Deletes(t *testing.T) {
106+
server := renderapi.NewServer(t)
107+
svc := seedService(server, "by-name-api")
108+
109+
result, err := executeServiceDelete(t, server, "by-name-api", "--confirm", "--output", "text")
110+
require.NoError(t, err)
111+
112+
assert.Empty(t, server.Services.Instances)
113+
assert.True(t, server.HasRequest("DELETE", "/services/"+svc.Id))
114+
assert.Contains(t, result.Stdout, "Deleted")
115+
assert.Contains(t, result.Stdout, svc.Id)
116+
}
117+
118+
func TestServiceDelete_DatastoreIDsAreNotServices(t *testing.T) {
119+
server := renderapi.NewServer(t)
120+
pg := server.Postgres.Add(renderapi.NewPostgres(client.PostgresDetail{
121+
Name: "app-db",
122+
Owner: client.Owner{Id: serviceTestWorkspaceID},
123+
}))
124+
125+
_, err := executeServiceDelete(t, server, pg.Id, "--confirm", "--output", "text")
126+
require.Error(t, err)
127+
128+
assert.Contains(t, err.Error(), "No service named")
129+
assert.Contains(t, err.Error(), serviceTestWorkspaceName)
130+
assert.Len(t, server.Postgres.Instances, 1)
131+
assert.False(t, server.HasDeleteRequest())
132+
}
133+
134+
func TestServiceDelete_NameCollision_Errors(t *testing.T) {
135+
server := renderapi.NewServer(t)
136+
seedService(server, "not-unique")
137+
seedService(server, "not-unique")
138+
139+
_, err := executeServiceDelete(t, server, "not-unique", "--confirm", "--output", "text")
140+
require.Error(t, err)
141+
142+
assert.Contains(t, err.Error(), "Multiple services found")
143+
assert.Len(t, server.Services.Instances, 2, "no delete on ambiguity")
144+
assert.False(t, server.HasDeleteRequest())
145+
}
146+
147+
func TestServiceDelete_JSONOutput_AfterConfirm(t *testing.T) {
148+
server := renderapi.NewServer(t)
149+
svc := seedService(server, "json-api")
150+
151+
result, err := executeServiceDelete(t, server, svc.Id, "--confirm", "--output", "json")
152+
require.NoError(t, err)
153+
assert.Empty(t, server.Services.Instances)
154+
155+
var body map[string]any
156+
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &body))
157+
require.Len(t, body, 2)
158+
159+
data := requireSubMap(t, body, "data")
160+
meta := requireSubMap(t, body, "meta")
161+
162+
assert.Equal(t, svc.Id, data["id"])
163+
assert.Equal(t, "json-api", data["name"])
164+
assert.Equal(t, serviceTestWorkspaceID, data["ownerId"])
165+
assert.Equal(t, string(client.WebService), data["type"])
166+
assert.Contains(t, data, "serviceDetails")
167+
assert.Equal(t, true, meta["deleted"])
168+
}
169+
170+
func TestServiceDelete_JSONOutput_PreviewIncludesConfirmMessage(t *testing.T) {
171+
server := renderapi.NewServer(t)
172+
svc := seedService(server, "json-api")
173+
174+
result, err := executeServiceDelete(t, server, svc.Id, "--output", "json")
175+
require.NoError(t, err)
176+
assert.Len(t, server.Services.Instances, 1, "preview must not delete")
177+
178+
var body map[string]any
179+
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &body))
180+
181+
data := requireSubMap(t, body, "data")
182+
meta := requireSubMap(t, body, "meta")
183+
assert.Equal(t, svc.Id, data["id"])
184+
assert.Equal(t, false, meta["deleted"])
185+
assert.Equal(t, "re-run with --confirm to delete", meta["message"])
186+
}
187+
188+
func TestServiceDelete_RequiresActiveWorkspace(t *testing.T) {
189+
server := renderapi.NewServer(t)
190+
svc := seedService(server, "my-api")
191+
192+
_, err := executeServiceDeleteWithoutActiveWorkspace(t, server, svc.Id, "--confirm", "--output", "text")
193+
require.Error(t, err)
194+
195+
assert.Contains(t, err.Error(), "workspace")
196+
assert.Len(t, server.Services.Instances, 1)
197+
assert.False(t, server.HasDeleteRequest())
198+
}

cmd/testhelpers_test.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"os"
55
"testing"
66

7+
"github.com/render-oss/cli/internal/testrequire"
78
"github.com/stretchr/testify/require"
89
)
910

@@ -26,9 +27,7 @@ func newTestConfigPath(t *testing.T) string {
2627
// key is absent or the value is not a map[string]any.
2728
func requireSubMap(t *testing.T, body map[string]any, key string) map[string]any {
2829
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)
30+
return testrequire.SubMap(t, body, key)
3231
}
3332

3433
// requireSubSlice returns the nested slice stored at key, failing the test if

internal/fakes/renderapi/server.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,16 @@ func (s *Server) HasRequest(method, uriSubstring string) bool {
317317
return false
318318
}
319319

320+
// HasDeleteRequest returns true if any recorded request used the DELETE method.
321+
func (s *Server) HasDeleteRequest() bool {
322+
for _, r := range s.Requests {
323+
if r.Method == http.MethodDelete {
324+
return true
325+
}
326+
}
327+
return false
328+
}
329+
320330
// NewServer starts a fake Render API server covering all routes used by cmd-level tests.
321331
// The server is closed automatically when t completes. Seed state via server.Owners.Add(), etc.
322332
func NewServer(t *testing.T) *Server {

internal/testrequire/maps.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package testrequire
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
// SubMap returns the nested map stored at key, failing the test if the key is
10+
// absent or the value is not a map[string]any.
11+
func SubMap(t *testing.T, body map[string]any, key string) map[string]any {
12+
t.Helper()
13+
require.Contains(t, body, key, "expected %q", key)
14+
require.IsType(t, map[string]any{}, body[key], "expected %q to contain a map", key)
15+
return body[key].(map[string]any)
16+
}

0 commit comments

Comments
 (0)