-
Notifications
You must be signed in to change notification settings - Fork 928
feat: Add support for renaming workspaces #3409
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
95e95ed
c8adca1
3f8e780
a1da1fe
0b23095
055d05a
721eb87
9320850
256b7ef
0455b67
7ce1b3b
f8dcf51
846333b
f8dad71
54771b8
6b221ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
package cli | ||
|
||
import ( | ||
"fmt" | ||
|
||
"github.com/spf13/cobra" | ||
"golang.org/x/xerrors" | ||
|
||
"github.com/coder/coder/cli/cliui" | ||
"github.com/coder/coder/codersdk" | ||
) | ||
|
||
func rename() *cobra.Command { | ||
cmd := &cobra.Command{ | ||
Annotations: workspaceCommand, | ||
Use: "rename <workspace> <new name>", | ||
Short: "Rename a workspace", | ||
Args: cobra.ExactArgs(2), | ||
// Keep hidden until renaming is safe, see: | ||
// * https://github.com/coder/coder/issues/3000 | ||
// * https://github.com/coder/coder/issues/3386 | ||
Hidden: true, | ||
RunE: func(cmd *cobra.Command, args []string) error { | ||
client, err := CreateClient(cmd) | ||
if err != nil { | ||
return err | ||
} | ||
workspace, err := namedWorkspace(cmd, client, args[0]) | ||
if err != nil { | ||
return xerrors.Errorf("get workspace: %w", err) | ||
} | ||
|
||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n\n", | ||
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)."), | ||
) | ||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{ | ||
Text: fmt.Sprintf("Type %q to confirm rename:", workspace.Name), | ||
Validate: func(s string) error { | ||
if s == workspace.Name { | ||
return nil | ||
} | ||
return xerrors.Errorf("Input %q does not match %q", s, workspace.Name) | ||
}, | ||
}) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
err = client.UpdateWorkspace(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceRequest{ | ||
Name: args[1], | ||
}) | ||
if err != nil { | ||
return xerrors.Errorf("rename workspace: %w", err) | ||
} | ||
return nil | ||
}, | ||
} | ||
|
||
cliui.AllowSkipPrompt(cmd) | ||
|
||
return cmd | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
package cli_test | ||
|
||
import ( | ||
"context" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
|
||
"github.com/coder/coder/cli/clitest" | ||
"github.com/coder/coder/coderd/coderdtest" | ||
"github.com/coder/coder/pty/ptytest" | ||
"github.com/coder/coder/testutil" | ||
) | ||
|
||
func TestRename(t *testing.T) { | ||
t.Parallel() | ||
|
||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) | ||
user := coderdtest.CreateFirstUser(t, client) | ||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) | ||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID) | ||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) | ||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) | ||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) | ||
|
||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) | ||
defer cancel() | ||
|
||
want := workspace.Name + "-test" | ||
cmd, root := clitest.New(t, "rename", workspace.Name, want, "--yes") | ||
clitest.SetupConfig(t, client, root) | ||
pty := ptytest.New(t) | ||
cmd.SetIn(pty.Input()) | ||
cmd.SetOut(pty.Output()) | ||
|
||
errC := make(chan error, 1) | ||
go func() { | ||
errC <- cmd.ExecuteContext(ctx) | ||
}() | ||
|
||
pty.ExpectMatch("confirm rename:") | ||
pty.WriteLine(workspace.Name) | ||
|
||
require.NoError(t, <-errC) | ||
|
||
ws, err := client.Workspace(ctx, workspace.ID) | ||
assert.NoError(t, err) | ||
|
||
got := ws.Name | ||
assert.Equal(t, want, got, "workspace name did not change") | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -79,6 +79,7 @@ func Core() []*cobra.Command { | |
start(), | ||
state(), | ||
stop(), | ||
rename(), | ||
templates(), | ||
update(), | ||
users(), | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,7 @@ import ( | |
"time" | ||
|
||
"github.com/google/uuid" | ||
"github.com/lib/pq" | ||
"golang.org/x/exp/slices" | ||
|
||
"github.com/coder/coder/coderd/database" | ||
|
@@ -2090,6 +2091,32 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCompleteByID(_ context.Context, ar | |
return sql.ErrNoRows | ||
} | ||
|
||
func (q *fakeQuerier) UpdateWorkspace(_ context.Context, arg database.UpdateWorkspaceParams) (database.Workspace, error) { | ||
q.mutex.Lock() | ||
defer q.mutex.Unlock() | ||
|
||
for i, workspace := range q.workspaces { | ||
if workspace.Deleted || workspace.ID != arg.ID { | ||
continue | ||
} | ||
for _, other := range q.workspaces { | ||
if other.Deleted || other.ID == workspace.ID || workspace.OwnerID != other.OwnerID { | ||
continue | ||
} | ||
if other.Name == arg.Name { | ||
return database.Workspace{}, &pq.Error{Code: "23505", Message: "duplicate key value violates unique constraint"} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TIL you can just return the correct SQL error codes 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a bit janky 😅.. but at least the codes will never change. |
||
} | ||
} | ||
|
||
workspace.Name = arg.Name | ||
q.workspaces[i] = workspace | ||
|
||
return workspace, nil | ||
} | ||
|
||
return database.Workspace{}, sql.ErrNoRows | ||
} | ||
|
||
func (q *fakeQuerier) UpdateWorkspaceAutostart(_ context.Context, arg database.UpdateWorkspaceAutostartParams) error { | ||
q.mutex.Lock() | ||
defer q.mutex.Unlock() | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
package database | ||
|
||
import ( | ||
"errors" | ||
|
||
"github.com/lib/pq" | ||
) | ||
|
||
// UniqueConstraint represents a named unique constraint on a table. | ||
type UniqueConstraint string | ||
|
||
// UniqueConstraint enums. | ||
// TODO(mafredri): Generate these from the database schema. | ||
const ( | ||
UniqueWorkspacesOwnerIDLowerIdx UniqueConstraint = "workspaces_owner_id_lower_idx" | ||
) | ||
|
||
// IsUniqueViolation checks if the error is due to a unique violation. | ||
// If one or more specific unique constraints are given as arguments, | ||
// the error must be caused by one of them. If no constraints are given, | ||
// this function returns true for any unique violation. | ||
func IsUniqueViolation(err error, uniqueConstraints ...UniqueConstraint) bool { | ||
var pqErr *pq.Error | ||
if errors.As(err, &pqErr) { | ||
if pqErr.Code.Name() == "unique_violation" { | ||
if len(uniqueConstraints) == 0 { | ||
return true | ||
} | ||
for _, uc := range uniqueConstraints { | ||
if pqErr.Constraint == string(uc) { | ||
return true | ||
} | ||
} | ||
} | ||
} | ||
|
||
return false | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.