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 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