Skip to content

Commit 0a50886

Browse files
Implement render services update (#493)
Builds on prior PRs in this stack to implement `render services update`. Most of the logic already exists. But before this change, we were not actually building a PATCH request body, so the command was a no-op. Now it actually PATCHes! - For now, we accept the limitation that you cannot _change_ runtimes (e.g., from Python -> Docker) via the CLI. - `--num-instances` is not allowed. I will remove that in a follow-up PR. I backlogged a ticket for implementing `render services scale`. GROW-2375 GitOrigin-RevId: 718a6071051e1b6533c5761ae33720d3cd12c76c
1 parent de8898e commit 0a50886

5 files changed

Lines changed: 269 additions & 17 deletions

File tree

cmd/serviceupdate.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ 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"
1211
servicepkg "github.com/render-oss/cli/pkg/service"
@@ -38,8 +37,8 @@ Provide configuration updates with flags.`,
3837
// Interactive mode is not implemented yet; force non-interactive behavior for now.
3938
command.DefaultFormatNonInteractive(cmd)
4039

41-
cliInput = servicetypes.NormalizeServiceUpdateCLIInput(cliInput)
42-
if err := cliInput.ValidateUpdate(); err != nil {
40+
cliInput, err := servicetypes.NormalizeAndValidateUpdateInput(cliInput)
41+
if err != nil {
4342
return err
4443
}
4544

@@ -123,9 +122,19 @@ func updateServiceNonInteractive(ctx context.Context, deps *dependencies.Depende
123122
return nil, fmt.Errorf("failed to resolve service %q: %w", idOrName, err)
124123
}
125124

126-
// TODO: BuildUpdateRequest to construct the PATCH body
127-
// For now, use an empty UpdateServiceJSONRequestBody as a placeholder
128-
body := client.UpdateServiceJSONRequestBody{}
125+
before, err := serviceRepo.GetService(ctx, serviceID)
126+
if err != nil {
127+
return nil, err
128+
}
129+
130+
if err := cliInput.ValidateForServiceType(servicetypes.ServiceType(before.Type)); err != nil {
131+
return nil, err
132+
}
133+
134+
body, err := servicepkg.BuildUpdateRequest(*before, cliInput)
135+
if err != nil {
136+
return nil, err
137+
}
129138

130139
if _, err := serviceRepo.UpdateService(ctx, serviceID, body); err != nil {
131140
return nil, err

cmd/serviceupdate_test.go

Lines changed: 220 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"bytes"
5+
"net/http"
56
"testing"
67

78
renderapi "github.com/render-oss/cli/internal/fakes/renderapi"
@@ -11,6 +12,8 @@ import (
1112
"github.com/render-oss/cli/pkg/command"
1213
"github.com/render-oss/cli/pkg/config"
1314
"github.com/render-oss/cli/pkg/dependencies"
15+
"github.com/render-oss/cli/pkg/pointers"
16+
servicetypes "github.com/render-oss/cli/pkg/types/service"
1417
"github.com/spf13/cobra"
1518
"github.com/stretchr/testify/assert"
1619
"github.com/stretchr/testify/require"
@@ -194,7 +197,7 @@ func (h serviceUpdateHarness) setupCmd() (*cobra.Command, *bytes.Buffer, *bytes.
194197
return root, &stdout, &stderr
195198
}
196199

197-
func TestServiceUpdate_JSONOutput_ReturnsServiceOutEnvelope(t *testing.T) {
200+
func TestServiceUpdate_JSONOutput_UpdatesTopLevelFieldsAndReturnsEnvelope(t *testing.T) {
198201
harness := newServiceUpdateHarness(t)
199202
project := harness.server.CreateProject(
200203
renderapi.ProjectAttrs{Name: "Website", OwnerId: serviceTestWorkspaceID},
@@ -209,19 +212,22 @@ func TestServiceUpdate_JSONOutput_ReturnsServiceOutEnvelope(t *testing.T) {
209212
},
210213
}))
211214

215+
// Per-flag PATCH-body wiring is covered by TestServiceUpdate_FlagWiresIntoPatchBody.
216+
// This test owns the JSON output envelope shape, so it sets a single flag.
212217
result, err := harness.execute(svc.Id,
213218
"--name", "json-service-renamed",
214219
"--output", "json",
215220
)
216221
require.NoError(t, err)
217222

218223
assert.True(t, harness.server.HasRequest("PATCH", "/services/"+svc.Id))
224+
assert.Equal(t, "json-service-renamed", svc.Name)
219225

220226
body := testrequire.ParseJSONMap(t, result.Stdout)
221227
testassert.MapContains(t, body, map[string]any{
222228
"data": map[string]any{
223229
"id": svc.Id,
224-
"name": "json-service",
230+
"name": "json-service-renamed",
225231
"type": string(client.WebService),
226232
"projectId": project.Project.Id,
227233
"environmentId": envID,
@@ -233,3 +239,215 @@ func TestServiceUpdate_JSONOutput_ReturnsServiceOutEnvelope(t *testing.T) {
233239
})
234240
assert.NotContains(t, body, "id")
235241
}
242+
243+
// addWebService seeds a web service under a new project and returns it.
244+
func (h serviceUpdateHarness) addWebService(name string) *client.Service {
245+
h.t.Helper()
246+
247+
project := h.server.CreateProject(
248+
renderapi.ProjectAttrs{Name: "Website", OwnerId: serviceTestWorkspaceID},
249+
renderapi.EnvAttrs{Name: "production"},
250+
)
251+
return h.server.Services.Add(renderapi.NewWebService(renderapi.WebServiceAttrs{
252+
Service: renderapi.CommonServiceAttrs{
253+
Name: name,
254+
OwnerID: serviceTestWorkspaceID,
255+
EnvironmentID: project.Env("production").Id,
256+
},
257+
}))
258+
}
259+
260+
// TestServiceUpdate_ParseCommand_HappyPath verifies that every flag the update command
261+
// registers binds to the expected ServiceUpdateInput field with the expected
262+
// type. It drives the real command's flag set through ParseCommand in memory —
263+
// but does not invoke the command.
264+
// It exhaustively covers the CLI flag input <-> `cli:` tag seam that the e2e tests would only exercise indirectly.
265+
// The input->PATCH-body transform is covered separately in pkg/service/update_test.go.
266+
func TestServiceUpdate_ParseCommand_HappyPath(t *testing.T) {
267+
cmd := newServiceUpdateTestCmd()
268+
require.NoError(t, cmd.ParseFlags([]string{
269+
"--name", "svc-name",
270+
"--repo", "https://github.com/example/repo",
271+
"--branch", "main",
272+
"--image", "docker.io/library/nginx:alpine",
273+
"--plan", "starter",
274+
"--runtime", string(servicetypes.ServiceRuntimeGo),
275+
"--root-directory", "app",
276+
"--build-command", "make build",
277+
"--start-command", "./run",
278+
"--pre-deploy-command", "bin/migrate",
279+
"--health-check-path", "/ready",
280+
"--publish-directory", "public",
281+
"--cron-command", "echo hi",
282+
"--cron-schedule", "0 12 * * *",
283+
"--registry-credential", "rc-123",
284+
"--auto-deploy=false",
285+
"--build-filter-path", "src/**",
286+
"--build-filter-path", "go.mod",
287+
"--build-filter-ignored-path", "docs/**",
288+
"--num-instances", "3",
289+
"--max-shutdown-delay", "42",
290+
"--previews", string(servicetypes.PreviewsGenerationManual),
291+
"--maintenance-mode=true",
292+
"--maintenance-mode-uri", "https://status.example.com",
293+
"--ip-allow-list", "cidr=203.0.113.5/32,description=office",
294+
}))
295+
296+
var in servicetypes.ServiceUpdateInput
297+
require.NoError(t, command.ParseCommand(cmd, []string{"srv-abc123"}, &in))
298+
299+
previews := servicetypes.PreviewsGenerationManual
300+
runtime := servicetypes.ServiceRuntimeGo
301+
assert.Equal(t, servicetypes.ServiceUpdateInput{
302+
Name: "svc-name",
303+
Repo: pointers.From("https://github.com/example/repo"),
304+
Branch: pointers.From("main"),
305+
Image: pointers.From("docker.io/library/nginx:alpine"),
306+
Plan: pointers.From("starter"),
307+
Runtime: &runtime,
308+
RootDirectory: pointers.From("app"),
309+
BuildCommand: pointers.From("make build"),
310+
StartCommand: pointers.From("./run"),
311+
PreDeployCommand: pointers.From("bin/migrate"),
312+
HealthCheckPath: pointers.From("/ready"),
313+
PublishDirectory: pointers.From("public"),
314+
CronCommand: pointers.From("echo hi"),
315+
CronSchedule: pointers.From("0 12 * * *"),
316+
RegistryCredential: pointers.From("rc-123"),
317+
AutoDeploy: pointers.From(false),
318+
BuildFilterPaths: []string{"src/**", "go.mod"},
319+
BuildFilterIgnoredPaths: []string{"docs/**"},
320+
NumInstances: pointers.From(3),
321+
MaxShutdownDelay: pointers.From(42),
322+
Previews: &previews,
323+
MaintenanceMode: pointers.From(true),
324+
MaintenanceModeURI: pointers.From("https://status.example.com"),
325+
IPAllowList: []string{"cidr=203.0.113.5/32,description=office"},
326+
ServiceIDOrName: "srv-abc123",
327+
}, in)
328+
}
329+
330+
// TestServiceUpdate_FlagWiresIntoPatchBody verifies end-to-end that flags reach
331+
// the serialized PATCH body and that nothing unspecified comes along for the
332+
// ride. Each case asserts the *entire* decoded body by equality, so an extra or
333+
// misplaced field fails the test — stronger than checking individual keys.
334+
// Cases cover a single top-level field, a single serviceDetails field, a mix of
335+
// top-level and serviceDetails fields, and multiple serviceDetails fields.
336+
func TestServiceUpdate_FlagWiresIntoPatchBody(t *testing.T) {
337+
tests := []struct {
338+
name string
339+
args []string
340+
wantBody map[string]any
341+
}{
342+
{
343+
name: "name maps to top-level name only",
344+
args: []string{"--name", "renamed-service"},
345+
wantBody: map[string]any{"name": "renamed-service"},
346+
},
347+
{
348+
name: "plan maps into serviceDetails only",
349+
args: []string{"--plan", "starter"},
350+
wantBody: map[string]any{
351+
"serviceDetails": map[string]any{"plan": "starter"},
352+
},
353+
},
354+
{
355+
name: "top-level and detail fields coexist without leaking",
356+
args: []string{"--name", "renamed-service", "--health-check-path", "/ready"},
357+
wantBody: map[string]any{
358+
"name": "renamed-service",
359+
"serviceDetails": map[string]any{"healthCheckPath": "/ready"},
360+
},
361+
},
362+
{
363+
name: "multiple serviceDetails fields land together",
364+
args: []string{"--health-check-path", "/ready", "--plan", "starter"},
365+
wantBody: map[string]any{
366+
"serviceDetails": map[string]any{
367+
"healthCheckPath": "/ready",
368+
"plan": "starter",
369+
},
370+
},
371+
},
372+
}
373+
374+
for _, tt := range tests {
375+
t.Run(tt.name, func(t *testing.T) {
376+
harness := newServiceUpdateHarness(t)
377+
svc := harness.addWebService("wire-service")
378+
379+
args := append([]string{svc.Id}, tt.args...)
380+
args = append(args, "--output", "json")
381+
_, err := harness.execute(args...)
382+
require.NoError(t, err)
383+
384+
rec, ok := harness.server.LastRequest("PATCH", "/services/"+svc.Id)
385+
require.True(t, ok, "expected a PATCH request to be recorded")
386+
body := testrequire.ParseJSONMap(t, string(rec.Body))
387+
assert.Equal(t, tt.wantBody, body)
388+
})
389+
}
390+
}
391+
392+
func TestServiceUpdate_TypeIncompatibleFlag_FailsBeforePatch(t *testing.T) {
393+
harness := newServiceUpdateHarness(t)
394+
svc := harness.addWebService("web-service")
395+
396+
// --cron-schedule is rejected only by ValidateForServiceType, which runs
397+
// against the fetched service type. The command must fail before writing.
398+
_, err := harness.execute(svc.Id, "--cron-schedule", "0 12 * * *", "--output", "json")
399+
require.Error(t, err)
400+
assert.Contains(t, err.Error(), "--cron-schedule is not supported for web_service")
401+
assert.False(t, harness.server.HasRequest("PATCH", "/services/"+svc.Id))
402+
}
403+
404+
func TestServiceUpdate_DefaultTextOutput(t *testing.T) {
405+
harness := newServiceUpdateHarness(t)
406+
svc := harness.addWebService("text-service")
407+
408+
result, err := harness.execute(svc.Id, "--name", "text-service-renamed")
409+
require.NoError(t, err)
410+
411+
assert.True(t, harness.server.HasRequest("PATCH", "/services/"+svc.Id))
412+
assert.Contains(t, result.Stdout, "Updated this service")
413+
assert.Contains(t, result.Stdout, "text-service-renamed")
414+
}
415+
416+
func TestServiceUpdate_ServiceDetailsPatchReachesAPI(t *testing.T) {
417+
harness := newServiceUpdateHarness(t)
418+
svc := harness.addWebService("plan-service")
419+
420+
// --plan flows into the ServiceDetails union; this exercises that the union
421+
// serializes and is accepted end-to-end (the fake decodes the PATCH body).
422+
_, err := harness.execute(svc.Id, "--plan", "starter", "--output", "json")
423+
require.NoError(t, err)
424+
425+
assert.True(t, harness.server.HasRequest("PATCH", "/services/"+svc.Id))
426+
}
427+
428+
func TestServiceUpdate_UnknownService_FailsToResolveBeforePatch(t *testing.T) {
429+
harness := newServiceUpdateHarness(t)
430+
431+
// No service is seeded, so resolving the name finds nothing. The command
432+
// must surface a resolve error and never attempt a PATCH.
433+
_, err := harness.execute("does-not-exist", "--name", "renamed", "--output", "json")
434+
require.Error(t, err)
435+
assert.Contains(t, err.Error(), `failed to resolve service "does-not-exist"`)
436+
assert.False(t, harness.server.HasRequest("PATCH", "/services/"))
437+
}
438+
439+
func TestServiceUpdate_APIError_SurfacesWithoutWriting(t *testing.T) {
440+
harness := newServiceUpdateHarness(t)
441+
svc := harness.addWebService("api-error-service")
442+
443+
// The fake drains its error queue FIFO across all service requests, so a
444+
// single queued error lands on the first GET the command issues to resolve
445+
// and load the service — before any PATCH. This asserts the command
446+
// propagates an unexpected API error instead of swallowing it, and does not
447+
// write when a pre-PATCH step fails.
448+
harness.server.Services.RespondWith(http.StatusInternalServerError)
449+
450+
_, err := harness.execute(svc.Id, "--name", "renamed", "--output", "json")
451+
require.Error(t, err)
452+
assert.False(t, harness.server.HasRequest("PATCH", "/services/"+svc.Id))
453+
}

internal/fakes/renderapi/server.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package renderapi
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"fmt"
7+
"io"
68
"net/http"
79
"net/http/httptest"
810
"slices"
@@ -52,6 +54,9 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
5254
type RecordedRequest struct {
5355
Method string
5456
URI string
57+
// Body is the raw request body bytes, captured before the route handler
58+
// decodes them. Nil when the request had no body.
59+
Body []byte
5560
}
5661

5762
// Resource is a generic in-memory store for any fake API resource type.
@@ -281,7 +286,7 @@ type Server struct {
281286
Environments *Resource[*client.Environment]
282287
KV *KVResource
283288
Postgres *PostgresResource
284-
Services *ServiceResource
289+
Services *ServiceResource
285290
}
286291

287292
// ownerByID returns the Owner with the given ID from the seeded owners. The
@@ -317,6 +322,20 @@ func (s *Server) HasRequest(method, uriSubstring string) bool {
317322
return false
318323
}
319324

325+
// LastRequest returns the most recent recorded request matching the given
326+
// method and URI substring, and whether one was found. Returning the last match
327+
// is robust when a command issues several requests (e.g. GETs to resolve a
328+
// service before the PATCH).
329+
func (s *Server) LastRequest(method, uriSubstring string) (RecordedRequest, bool) {
330+
for i := len(s.Requests) - 1; i >= 0; i-- {
331+
r := s.Requests[i]
332+
if r.Method == method && strings.Contains(r.URI, uriSubstring) {
333+
return r, true
334+
}
335+
}
336+
return RecordedRequest{}, false
337+
}
338+
320339
// HasDeleteRequest returns true if any recorded request used the DELETE method.
321340
func (s *Server) HasDeleteRequest() bool {
322341
for _, r := range s.Requests {
@@ -344,7 +363,15 @@ func NewServer(t *testing.T) *Server {
344363
mux := http.NewServeMux()
345364

346365
record := func(r *http.Request) {
347-
s.Requests = append(s.Requests, RecordedRequest{Method: r.Method, URI: r.URL.RequestURI()})
366+
rec := RecordedRequest{Method: r.Method, URI: r.URL.RequestURI()}
367+
if r.Body != nil {
368+
if body, err := io.ReadAll(r.Body); err == nil {
369+
rec.Body = body
370+
// Restore the body so route handlers can still decode it.
371+
r.Body = io.NopCloser(bytes.NewReader(body))
372+
}
373+
}
374+
s.Requests = append(s.Requests, rec)
348375
}
349376

350377
// GET /users - retrieve the authenticated user.

pkg/service/repo.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,11 @@ func (s *Repo) CreateService(ctx context.Context, data client.CreateServiceJSONR
9696
return resp.JSON201.Service, nil
9797
}
9898

99+
// UpdateService PATCHes the given service. It does not re-fetch the service to
100+
// validate workspace ownership, so the caller must pass an id already resolved
101+
// against the active workspace (GetService and ResolveServiceIDFromNameOrID
102+
// both validate ownership and return such an id).
99103
func (s *Repo) UpdateService(ctx context.Context, id string, data client.UpdateServiceJSONRequestBody) (*client.Service, error) {
100-
// we get the Service to ensure the workspace matches. Since GetService checks the workspace, we just check
101-
// if an error was returned
102-
if _, err := s.GetService(ctx, id); err != nil {
103-
return nil, err
104-
}
105-
106104
resp, err := s.client.UpdateServiceWithResponse(ctx, id, data)
107105
if err != nil {
108106
return nil, err

0 commit comments

Comments
 (0)