Skip to content

Commit c8f8c95

Browse files
authored
feat: Add support for renaming workspaces (#3409)
* feat: Implement workspace renaming * feat: Add hidden rename command (and data loss warning) * feat: Implement database.IsUniqueViolation
1 parent 623fc5b commit c8f8c95

File tree

13 files changed

+343
-13
lines changed

13 files changed

+343
-13
lines changed

cli/rename.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
"golang.org/x/xerrors"
8+
9+
"github.com/coder/coder/cli/cliui"
10+
"github.com/coder/coder/codersdk"
11+
)
12+
13+
func rename() *cobra.Command {
14+
cmd := &cobra.Command{
15+
Annotations: workspaceCommand,
16+
Use: "rename <workspace> <new name>",
17+
Short: "Rename a workspace",
18+
Args: cobra.ExactArgs(2),
19+
// Keep hidden until renaming is safe, see:
20+
// * https://github.com/coder/coder/issues/3000
21+
// * https://github.com/coder/coder/issues/3386
22+
Hidden: true,
23+
RunE: func(cmd *cobra.Command, args []string) error {
24+
client, err := CreateClient(cmd)
25+
if err != nil {
26+
return err
27+
}
28+
workspace, err := namedWorkspace(cmd, client, args[0])
29+
if err != nil {
30+
return xerrors.Errorf("get workspace: %w", err)
31+
}
32+
33+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n\n",
34+
cliui.Styles.Wrap.Render("WARNING: A rename can result in data loss if a resource references the workspace name in the template (e.g volumes)."),
35+
)
36+
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
37+
Text: fmt.Sprintf("Type %q to confirm rename:", workspace.Name),
38+
Validate: func(s string) error {
39+
if s == workspace.Name {
40+
return nil
41+
}
42+
return xerrors.Errorf("Input %q does not match %q", s, workspace.Name)
43+
},
44+
})
45+
if err != nil {
46+
return err
47+
}
48+
49+
err = client.UpdateWorkspace(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceRequest{
50+
Name: args[1],
51+
})
52+
if err != nil {
53+
return xerrors.Errorf("rename workspace: %w", err)
54+
}
55+
return nil
56+
},
57+
}
58+
59+
cliui.AllowSkipPrompt(cmd)
60+
61+
return cmd
62+
}

cli/rename_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package cli_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/coder/coder/cli/clitest"
11+
"github.com/coder/coder/coderd/coderdtest"
12+
"github.com/coder/coder/pty/ptytest"
13+
"github.com/coder/coder/testutil"
14+
)
15+
16+
func TestRename(t *testing.T) {
17+
t.Parallel()
18+
19+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
20+
user := coderdtest.CreateFirstUser(t, client)
21+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
22+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
23+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
24+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
25+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
26+
27+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
28+
defer cancel()
29+
30+
want := workspace.Name + "-test"
31+
cmd, root := clitest.New(t, "rename", workspace.Name, want, "--yes")
32+
clitest.SetupConfig(t, client, root)
33+
pty := ptytest.New(t)
34+
cmd.SetIn(pty.Input())
35+
cmd.SetOut(pty.Output())
36+
37+
errC := make(chan error, 1)
38+
go func() {
39+
errC <- cmd.ExecuteContext(ctx)
40+
}()
41+
42+
pty.ExpectMatch("confirm rename:")
43+
pty.WriteLine(workspace.Name)
44+
45+
require.NoError(t, <-errC)
46+
47+
ws, err := client.Workspace(ctx, workspace.ID)
48+
assert.NoError(t, err)
49+
50+
got := ws.Name
51+
assert.Equal(t, want, got, "workspace name did not change")
52+
}

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
@@ -374,6 +374,7 @@ func New(options *Options) *API {
374374
httpmw.ExtractWorkspaceParam(options.Database),
375375
)
376376
r.Get("/", api.workspace)
377+
r.Patch("/", api.patchWorkspace)
377378
r.Route("/builds", func(r chi.Router) {
378379
r.Get("/", api.workspaceBuilds)
379380
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"
@@ -2086,6 +2087,32 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCompleteByID(_ context.Context, ar
20862087
return sql.ErrNoRows
20872088
}
20882089

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

coderd/database/errors.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package database
2+
3+
import (
4+
"errors"
5+
6+
"github.com/lib/pq"
7+
)
8+
9+
// UniqueConstraint represents a named unique constraint on a table.
10+
type UniqueConstraint string
11+
12+
// UniqueConstraint enums.
13+
// TODO(mafredri): Generate these from the database schema.
14+
const (
15+
UniqueWorkspacesOwnerIDLowerIdx UniqueConstraint = "workspaces_owner_id_lower_idx"
16+
)
17+
18+
// IsUniqueViolation checks if the error is due to a unique violation.
19+
// If one or more specific unique constraints are given as arguments,
20+
// the error must be caused by one of them. If no constraints are given,
21+
// this function returns true for any unique violation.
22+
func IsUniqueViolation(err error, uniqueConstraints ...UniqueConstraint) bool {
23+
var pqErr *pq.Error
24+
if errors.As(err, &pqErr) {
25+
if pqErr.Code.Name() == "unique_violation" {
26+
if len(uniqueConstraints) == 0 {
27+
return true
28+
}
29+
for _, uc := range uniqueConstraints {
30+
if pqErr.Constraint == string(uc) {
31+
return true
32+
}
33+
}
34+
}
35+
}
36+
37+
return false
38+
}

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: 34 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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ SET
112112
WHERE
113113
id = $1;
114114

115+
-- name: UpdateWorkspace :one
116+
UPDATE
117+
workspaces
118+
SET
119+
name = $2
120+
WHERE
121+
id = $1
122+
AND deleted = false
123+
RETURNING *;
124+
115125
-- name: UpdateWorkspaceAutostart :exec
116126
UPDATE
117127
workspaces

coderd/workspaces.go

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -316,17 +316,8 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
316316
})
317317
if err == nil {
318318
// If the workspace already exists, don't allow creation.
319-
template, err := api.Database.GetTemplateByID(r.Context(), workspace.TemplateID)
320-
if err != nil {
321-
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
322-
Message: fmt.Sprintf("Find template for conflicting workspace name %q.", createWorkspace.Name),
323-
Detail: err.Error(),
324-
})
325-
return
326-
}
327-
// The template is fetched for clarity to the user on where the conflicting name may be.
328319
httpapi.Write(rw, http.StatusConflict, codersdk.Response{
329-
Message: fmt.Sprintf("Workspace %q already exists in the %q template.", createWorkspace.Name, template.Name),
320+
Message: fmt.Sprintf("Workspace %q already exists.", createWorkspace.Name),
330321
Validations: []codersdk.ValidationError{{
331322
Field: "name",
332323
Detail: "This value is already in use and should be unique.",
@@ -479,6 +470,68 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
479470
findUser(apiKey.UserID, users), findUser(workspaceBuild.InitiatorID, users)))
480471
}
481472

473+
func (api *API) patchWorkspace(rw http.ResponseWriter, r *http.Request) {
474+
workspace := httpmw.WorkspaceParam(r)
475+
if !api.Authorize(r, rbac.ActionUpdate, workspace) {
476+
httpapi.ResourceNotFound(rw)
477+
return
478+
}
479+
480+
var req codersdk.UpdateWorkspaceRequest
481+
if !httpapi.Read(rw, r, &req) {
482+
return
483+
}
484+
485+
if req.Name == "" || req.Name == workspace.Name {
486+
// Nothing changed, optionally this could be an error.
487+
rw.WriteHeader(http.StatusNoContent)
488+
return
489+
}
490+
// The reason we double check here is in case more fields can be
491+
// patched in the future, it's enough if one changes.
492+
name := workspace.Name
493+
if req.Name != "" || req.Name != workspace.Name {
494+
name = req.Name
495+
}
496+
497+
_, err := api.Database.UpdateWorkspace(r.Context(), database.UpdateWorkspaceParams{
498+
ID: workspace.ID,
499+
Name: name,
500+
})
501+
if err != nil {
502+
// The query protects against updating deleted workspaces and
503+
// the existence of the workspace is checked in the request,
504+
// if we get ErrNoRows it means the workspace was deleted.
505+
//
506+
// We could do this check earlier but we'd need to start a
507+
// transaction.
508+
if errors.Is(err, sql.ErrNoRows) {
509+
httpapi.Write(rw, http.StatusMethodNotAllowed, codersdk.Response{
510+
Message: fmt.Sprintf("Workspace %q is deleted and cannot be updated.", workspace.Name),
511+
})
512+
return
513+
}
514+
// Check if the name was already in use.
515+
if database.IsUniqueViolation(err, database.UniqueWorkspacesOwnerIDLowerIdx) {
516+
httpapi.Write(rw, http.StatusConflict, codersdk.Response{
517+
Message: fmt.Sprintf("Workspace %q already exists.", req.Name),
518+
Validations: []codersdk.ValidationError{{
519+
Field: "name",
520+
Detail: "This value is already in use and should be unique.",
521+
}},
522+
})
523+
return
524+
}
525+
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
526+
Message: "Internal error updating workspace.",
527+
Detail: err.Error(),
528+
})
529+
return
530+
}
531+
532+
rw.WriteHeader(http.StatusNoContent)
533+
}
534+
482535
func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
483536
workspace := httpmw.WorkspaceParam(r)
484537
if !api.Authorize(r, rbac.ActionUpdate, workspace) {
@@ -556,7 +609,6 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
556609

557610
return nil
558611
})
559-
560612
if err != nil {
561613
resp := codersdk.Response{
562614
Message: "Error updating workspace time until shutdown.",
@@ -656,7 +708,6 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
656708

657709
return nil
658710
})
659-
660711
if err != nil {
661712
api.Logger.Info(r.Context(), "extending workspace", slog.Error(err))
662713
}
@@ -861,6 +912,7 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data
861912
}
862913
return apiWorkspaces, nil
863914
}
915+
864916
func convertWorkspace(
865917
workspace database.Workspace,
866918
workspaceBuild database.WorkspaceBuild,

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy