Skip to content

Commit 95e95ed

Browse files
committed
feat: Implement workspace renaming
1 parent 253e6cb commit 95e95ed

File tree

10 files changed

+181
-13
lines changed

10 files changed

+181
-13
lines changed

cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ func Core() []*cobra.Command {
7979
start(),
8080
state(),
8181
stop(),
82+
rename(),
8283
templates(),
8384
update(),
8485
users(),

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ func New(options *Options) *API {
372372
httpmw.ExtractWorkspaceParam(options.Database),
373373
)
374374
r.Get("/", api.workspace)
375+
r.Patch("/", api.patchWorkspace)
375376
r.Route("/builds", func(r chi.Router) {
376377
r.Get("/", api.workspaceBuilds)
377378
r.Post("/", api.postWorkspaceBuilds)

coderd/database/databasefake/databasefake.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"time"
1010

1111
"github.com/google/uuid"
12+
"github.com/lib/pq"
1213
"golang.org/x/exp/slices"
1314

1415
"github.com/coder/coder/coderd/database"
@@ -2090,6 +2091,32 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCompleteByID(_ context.Context, ar
20902091
return sql.ErrNoRows
20912092
}
20922093

2094+
func (q *fakeQuerier) UpdateWorkspace(_ context.Context, arg database.UpdateWorkspaceParams) error {
2095+
q.mutex.Lock()
2096+
defer q.mutex.Unlock()
2097+
2098+
for i, workspace := range q.workspaces {
2099+
if workspace.Deleted || workspace.ID != arg.ID {
2100+
continue
2101+
}
2102+
for _, other := range q.workspaces {
2103+
if other.Deleted || other.ID == workspace.ID || workspace.OwnerID != other.OwnerID {
2104+
continue
2105+
}
2106+
if other.Name == arg.Name {
2107+
return &pq.Error{Code: "23505", Message: "duplicate key value violates unique constraint"}
2108+
}
2109+
}
2110+
2111+
workspace.Name = arg.Name
2112+
q.workspaces[i] = workspace
2113+
2114+
return nil
2115+
}
2116+
2117+
return sql.ErrNoRows
2118+
}
2119+
20932120
func (q *fakeQuerier) UpdateWorkspaceAutostart(_ context.Context, arg database.UpdateWorkspaceAutostartParams) error {
20942121
q.mutex.Lock()
20952122
defer q.mutex.Unlock()

coderd/database/querier.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/workspaces.sql

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,15 @@ SET
112112
WHERE
113113
id = $1;
114114

115+
-- name: UpdateWorkspace :exec
116+
UPDATE
117+
workspaces
118+
SET
119+
name = $2
120+
WHERE
121+
id = $1
122+
AND deleted = false;
123+
115124
-- name: UpdateWorkspaceAutostart :exec
116125
UPDATE
117126
workspaces

coderd/workspaces.go

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/go-chi/chi/v5"
1616
"github.com/google/uuid"
17+
"github.com/lib/pq"
1718
"github.com/moby/moby/pkg/namesgenerator"
1819
"golang.org/x/sync/errgroup"
1920
"golang.org/x/xerrors"
@@ -323,17 +324,8 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
323324
})
324325
if err == nil {
325326
// If the workspace already exists, don't allow creation.
326-
template, err := api.Database.GetTemplateByID(r.Context(), workspace.TemplateID)
327-
if err != nil {
328-
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
329-
Message: fmt.Sprintf("Find template for conflicting workspace name %q.", createWorkspace.Name),
330-
Detail: err.Error(),
331-
})
332-
return
333-
}
334-
// The template is fetched for clarity to the user on where the conflicting name may be.
335327
httpapi.Write(rw, http.StatusConflict, codersdk.Response{
336-
Message: fmt.Sprintf("Workspace %q already exists in the %q template.", createWorkspace.Name, template.Name),
328+
Message: fmt.Sprintf("Workspace %q already exists.", createWorkspace.Name),
337329
Validations: []codersdk.ValidationError{{
338330
Field: "name",
339331
Detail: "This value is already in use and should be unique.",
@@ -486,6 +478,72 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
486478
findUser(apiKey.UserID, users), findUser(workspaceBuild.InitiatorID, users)))
487479
}
488480

481+
func (api *API) patchWorkspace(rw http.ResponseWriter, r *http.Request) {
482+
workspace := httpmw.WorkspaceParam(r)
483+
if !api.Authorize(r, rbac.ActionUpdate, workspace) {
484+
httpapi.ResourceNotFound(rw)
485+
return
486+
}
487+
488+
var req codersdk.UpdateWorkspaceRequest
489+
if !httpapi.Read(rw, r, &req) {
490+
return
491+
}
492+
493+
if req.Name == "" || req.Name == workspace.Name {
494+
// Nothing changed, optionally this could be an error.
495+
rw.WriteHeader(http.StatusNoContent)
496+
return
497+
}
498+
// The reason we double check here is in case more fields can be
499+
// patched in the future, it's enough if one changes.
500+
name := workspace.Name
501+
if req.Name != "" || req.Name != workspace.Name {
502+
name = req.Name
503+
}
504+
505+
err := api.Database.UpdateWorkspace(r.Context(), database.UpdateWorkspaceParams{
506+
ID: workspace.ID,
507+
Name: name,
508+
})
509+
if err != nil {
510+
// The query protects against updating deleted workspaces and
511+
// the existence of the workspace is checked in the request,
512+
// the only conclusion we can make is that we're trying to
513+
// update a deleted workspace.
514+
//
515+
// We could do this check earlier but since we're not in a
516+
// transaction, it's pointless.
517+
if errors.Is(err, sql.ErrNoRows) {
518+
httpapi.Write(rw, http.StatusMethodNotAllowed, codersdk.Response{
519+
Message: fmt.Sprintf("Workspace %q is deleted and cannot be updated.", workspace.Name),
520+
})
521+
return
522+
}
523+
// Check if we triggered the one-unique-name-per-owner
524+
// constraint.
525+
var pqErr *pq.Error
526+
if errors.As(err, &pqErr) && pqErr.Code.Name() == "unique_violation" {
527+
httpapi.Write(rw, http.StatusConflict, codersdk.Response{
528+
Message: fmt.Sprintf("Workspace %q already exists.", req.Name),
529+
Validations: []codersdk.ValidationError{{
530+
Field: "name",
531+
Detail: "This value is already in use and should be unique.",
532+
}},
533+
})
534+
return
535+
}
536+
537+
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
538+
Message: "Internal error updating workspace.",
539+
Detail: err.Error(),
540+
})
541+
return
542+
}
543+
544+
rw.WriteHeader(http.StatusNoContent)
545+
}
546+
489547
func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
490548
workspace := httpmw.WorkspaceParam(r)
491549
if !api.Authorize(r, rbac.ActionUpdate, workspace) {
@@ -563,7 +621,6 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
563621

564622
return nil
565623
})
566-
567624
if err != nil {
568625
resp := codersdk.Response{
569626
Message: "Error updating workspace time until shutdown.",
@@ -663,7 +720,6 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
663720

664721
return nil
665722
})
666-
667723
if err != nil {
668724
api.Logger.Info(r.Context(), "extending workspace", slog.Error(err))
669725
}
@@ -868,6 +924,7 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data
868924
}
869925
return apiWorkspaces, nil
870926
}
927+
871928
func convertWorkspace(
872929
workspace database.Workspace,
873930
workspaceBuild database.WorkspaceBuild,

coderd/workspaces_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,37 @@ func TestWorkspace(t *testing.T) {
7777
require.Error(t, err)
7878
require.ErrorContains(t, err, "410") // gone
7979
})
80+
81+
t.Run("Rename", func(t *testing.T) {
82+
t.Parallel()
83+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
84+
user := coderdtest.CreateFirstUser(t, client)
85+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
86+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
87+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
88+
ws1 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
89+
ws2 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
90+
coderdtest.AwaitWorkspaceBuildJob(t, client, ws1.LatestBuild.ID)
91+
coderdtest.AwaitWorkspaceBuildJob(t, client, ws2.LatestBuild.ID)
92+
93+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
94+
defer cancel()
95+
96+
want := ws1.Name + "_2"
97+
err := client.UpdateWorkspace(ctx, ws1.ID, codersdk.UpdateWorkspaceRequest{
98+
Name: want,
99+
})
100+
require.NoError(t, err, "workspace rename failed")
101+
102+
ws, err := client.Workspace(ctx, ws1.ID)
103+
require.NoError(t, err)
104+
require.Equal(t, want, ws.Name, "workspace name not updated")
105+
106+
err = client.UpdateWorkspace(ctx, ws1.ID, codersdk.UpdateWorkspaceRequest{
107+
Name: ws2.Name,
108+
})
109+
require.Error(t, err, "workspace rename should have failed")
110+
})
80111
}
81112

82113
func TestAdminViewAllWorkspaces(t *testing.T) {

codersdk/workspaces.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,23 @@ func (c *Client) WatchWorkspace(ctx context.Context, id uuid.UUID) (<-chan Works
162162
return wc, nil
163163
}
164164

165+
type UpdateWorkspaceRequest struct {
166+
Name string `json:"name,omitempty" validate:"username"`
167+
}
168+
169+
func (c *Client) UpdateWorkspace(ctx context.Context, id uuid.UUID, req UpdateWorkspaceRequest) error {
170+
path := fmt.Sprintf("/api/v2/workspaces/%s", id.String())
171+
res, err := c.Request(ctx, http.MethodPatch, path, req)
172+
if err != nil {
173+
return xerrors.Errorf("update workspace: %w", err)
174+
}
175+
defer res.Body.Close()
176+
if res.StatusCode != http.StatusNoContent {
177+
return readBodyAsError(res)
178+
}
179+
return nil
180+
}
181+
165182
// UpdateWorkspaceAutostartRequest is a request to update a workspace's autostart schedule.
166183
type UpdateWorkspaceAutostartRequest struct {
167184
Schedule *string `json:"schedule"`
@@ -263,7 +280,6 @@ func (f WorkspaceFilter) asRequestOption() requestOption {
263280
// Workspaces returns all workspaces the authenticated user has access to.
264281
func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) ([]Workspace, error) {
265282
res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaces", nil, filter.asRequestOption())
266-
267283
if err != nil {
268284
return nil, err
269285
}

site/src/api/typesGenerated.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,11 @@ export interface UpdateWorkspaceAutostartRequest {
370370
readonly schedule?: string
371371
}
372372

373+
// From codersdk/workspaces.go
374+
export interface UpdateWorkspaceRequest {
375+
readonly name?: string
376+
}
377+
373378
// From codersdk/workspaces.go
374379
export interface UpdateWorkspaceTTLRequest {
375380
readonly ttl_ms?: number

0 commit comments

Comments
 (0)