diff --git a/cli/rename.go b/cli/rename.go new file mode 100644 index 0000000000000..168dcffb3e553 --- /dev/null +++ b/cli/rename.go @@ -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 ", + 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 +} diff --git a/cli/rename_test.go b/cli/rename_test.go new file mode 100644 index 0000000000000..fd0b019ae6395 --- /dev/null +++ b/cli/rename_test.go @@ -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") +} diff --git a/cli/root.go b/cli/root.go index cbece56fcf4cc..7551e09c03c63 100644 --- a/cli/root.go +++ b/cli/root.go @@ -79,6 +79,7 @@ func Core() []*cobra.Command { start(), state(), stop(), + rename(), templates(), update(), users(), diff --git a/coderd/coderd.go b/coderd/coderd.go index a2062b736227a..832f4c4f12988 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -372,6 +372,7 @@ func New(options *Options) *API { httpmw.ExtractWorkspaceParam(options.Database), ) r.Get("/", api.workspace) + r.Patch("/", api.patchWorkspace) r.Route("/builds", func(r chi.Router) { r.Get("/", api.workspaceBuilds) r.Post("/", api.postWorkspaceBuilds) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 7ba138c820312..48860c2a90654 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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"} + } + } + + 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() diff --git a/coderd/database/errors.go b/coderd/database/errors.go new file mode 100644 index 0000000000000..e7333e4c6635b --- /dev/null +++ b/coderd/database/errors.go @@ -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 +} diff --git a/coderd/database/querier.go b/coderd/database/querier.go index cddf33e33d427..a10b108b0352c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -137,6 +137,7 @@ type querier interface { UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) + UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error UpdateWorkspaceAgentKeysByID(ctx context.Context, arg UpdateWorkspaceAgentKeysByIDParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3e6643781d30a..880f550813e65 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4685,6 +4685,40 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar return i, err } +const updateWorkspace = `-- name: UpdateWorkspace :one +UPDATE + workspaces +SET + name = $2 +WHERE + id = $1 + AND deleted = false +RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl +` + +type UpdateWorkspaceParams struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) { + row := q.db.QueryRowContext(ctx, updateWorkspace, arg.ID, arg.Name) + var i Workspace + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.Ttl, + ) + return i, err +} + const updateWorkspaceAutostart = `-- name: UpdateWorkspaceAutostart :exec UPDATE workspaces diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 5cb6508a12111..8de76e258415e 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -112,6 +112,16 @@ SET WHERE id = $1; +-- name: UpdateWorkspace :one +UPDATE + workspaces +SET + name = $2 +WHERE + id = $1 + AND deleted = false +RETURNING *; + -- name: UpdateWorkspaceAutostart :exec UPDATE workspaces diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 3b0e7a486a482..20652f919edfd 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -323,17 +323,8 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req }) if err == nil { // If the workspace already exists, don't allow creation. - template, err := api.Database.GetTemplateByID(r.Context(), workspace.TemplateID) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: fmt.Sprintf("Find template for conflicting workspace name %q.", createWorkspace.Name), - Detail: err.Error(), - }) - return - } - // The template is fetched for clarity to the user on where the conflicting name may be. httpapi.Write(rw, http.StatusConflict, codersdk.Response{ - Message: fmt.Sprintf("Workspace %q already exists in the %q template.", createWorkspace.Name, template.Name), + Message: fmt.Sprintf("Workspace %q already exists.", createWorkspace.Name), Validations: []codersdk.ValidationError{{ Field: "name", Detail: "This value is already in use and should be unique.", @@ -486,6 +477,68 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req findUser(apiKey.UserID, users), findUser(workspaceBuild.InitiatorID, users))) } +func (api *API) patchWorkspace(rw http.ResponseWriter, r *http.Request) { + workspace := httpmw.WorkspaceParam(r) + if !api.Authorize(r, rbac.ActionUpdate, workspace) { + httpapi.ResourceNotFound(rw) + return + } + + var req codersdk.UpdateWorkspaceRequest + if !httpapi.Read(rw, r, &req) { + return + } + + if req.Name == "" || req.Name == workspace.Name { + // Nothing changed, optionally this could be an error. + rw.WriteHeader(http.StatusNoContent) + return + } + // The reason we double check here is in case more fields can be + // patched in the future, it's enough if one changes. + name := workspace.Name + if req.Name != "" || req.Name != workspace.Name { + name = req.Name + } + + _, err := api.Database.UpdateWorkspace(r.Context(), database.UpdateWorkspaceParams{ + ID: workspace.ID, + Name: name, + }) + if err != nil { + // The query protects against updating deleted workspaces and + // the existence of the workspace is checked in the request, + // if we get ErrNoRows it means the workspace was deleted. + // + // We could do this check earlier but we'd need to start a + // transaction. + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusMethodNotAllowed, codersdk.Response{ + Message: fmt.Sprintf("Workspace %q is deleted and cannot be updated.", workspace.Name), + }) + return + } + // Check if the name was already in use. + if database.IsUniqueViolation(err, database.UniqueWorkspacesOwnerIDLowerIdx) { + httpapi.Write(rw, http.StatusConflict, codersdk.Response{ + Message: fmt.Sprintf("Workspace %q already exists.", req.Name), + Validations: []codersdk.ValidationError{{ + Field: "name", + Detail: "This value is already in use and should be unique.", + }}, + }) + return + } + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating workspace.", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { workspace := httpmw.WorkspaceParam(r) if !api.Authorize(r, rbac.ActionUpdate, workspace) { @@ -563,7 +616,6 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return nil }) - if err != nil { resp := codersdk.Response{ Message: "Error updating workspace time until shutdown.", @@ -663,7 +715,6 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { return nil }) - if err != nil { api.Logger.Info(r.Context(), "extending workspace", slog.Error(err)) } @@ -868,6 +919,7 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data } return apiWorkspaces, nil } + func convertWorkspace( workspace database.Workspace, workspaceBuild database.WorkspaceBuild, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index db47eb796bcd5..5139034bf2638 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -77,6 +77,37 @@ func TestWorkspace(t *testing.T) { require.Error(t, err) require.ErrorContains(t, err, "410") // gone }) + + t.Run("Rename", func(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) + ws1 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + ws2 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, ws1.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, ws2.LatestBuild.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + want := ws1.Name + "-test" + err := client.UpdateWorkspace(ctx, ws1.ID, codersdk.UpdateWorkspaceRequest{ + Name: want, + }) + require.NoError(t, err, "workspace rename failed") + + ws, err := client.Workspace(ctx, ws1.ID) + require.NoError(t, err) + require.Equal(t, want, ws.Name, "workspace name not updated") + + err = client.UpdateWorkspace(ctx, ws1.ID, codersdk.UpdateWorkspaceRequest{ + Name: ws2.Name, + }) + require.Error(t, err, "workspace rename should have failed") + }) } func TestAdminViewAllWorkspaces(t *testing.T) { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index fb2b56e4dfbf1..07da2e394dabc 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -162,6 +162,23 @@ func (c *Client) WatchWorkspace(ctx context.Context, id uuid.UUID) (<-chan Works return wc, nil } +type UpdateWorkspaceRequest struct { + Name string `json:"name,omitempty" validate:"username"` +} + +func (c *Client) UpdateWorkspace(ctx context.Context, id uuid.UUID, req UpdateWorkspaceRequest) error { + path := fmt.Sprintf("/api/v2/workspaces/%s", id.String()) + res, err := c.Request(ctx, http.MethodPatch, path, req) + if err != nil { + return xerrors.Errorf("update workspace: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return readBodyAsError(res) + } + return nil +} + // UpdateWorkspaceAutostartRequest is a request to update a workspace's autostart schedule. type UpdateWorkspaceAutostartRequest struct { Schedule *string `json:"schedule"` @@ -263,7 +280,6 @@ func (f WorkspaceFilter) asRequestOption() requestOption { // Workspaces returns all workspaces the authenticated user has access to. func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) ([]Workspace, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaces", nil, filter.asRequestOption()) - if err != nil { return nil, err } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 38ee495dbf103..999e0095c5540 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -370,6 +370,11 @@ export interface UpdateWorkspaceAutostartRequest { readonly schedule?: string } +// From codersdk/workspaces.go +export interface UpdateWorkspaceRequest { + readonly name?: string +} + // From codersdk/workspaces.go export interface UpdateWorkspaceTTLRequest { readonly ttl_ms?: number