diff --git a/cli/create.go b/cli/create.go index 351e4bb7eee6b..1a0c89cc62a27 100644 --- a/cli/create.go +++ b/cli/create.go @@ -49,7 +49,7 @@ func create() *cobra.Command { workspaceName, err = cliui.Prompt(cmd, cliui.PromptOptions{ Text: "Specify a name for your workspace:", Validate: func(workspaceName string) error { - _, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceByOwnerAndNameParams{}) + _, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{}) if err == nil { return xerrors.Errorf("A workspace already exists named %q!", workspaceName) } @@ -61,7 +61,7 @@ func create() *cobra.Command { } } - _, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceByOwnerAndNameParams{}) + _, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{}) if err == nil { return xerrors.Errorf("A workspace already exists named %q!", workspaceName) } diff --git a/cli/root.go b/cli/root.go index 2e56ab280d880..cb59816fe10ec 100644 --- a/cli/root.go +++ b/cli/root.go @@ -214,7 +214,7 @@ func namedWorkspace(cmd *cobra.Command, client *codersdk.Client, identifier stri return codersdk.Workspace{}, xerrors.Errorf("invalid workspace name: %q", identifier) } - return client.WorkspaceByOwnerAndName(cmd.Context(), owner, name, codersdk.WorkspaceByOwnerAndNameParams{}) + return client.WorkspaceByOwnerAndName(cmd.Context(), owner, name, codersdk.WorkspaceOptions{}) } // createConfig consumes the global configuration flag to produce a config root. diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 78f85e71c597c..8bee0158d1a11 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -375,7 +375,9 @@ func (q *fakeQuerier) GetWorkspaceByOwnerIDAndName(_ context.Context, arg databa q.mutex.RLock() defer q.mutex.RUnlock() + var found *database.Workspace for _, workspace := range q.workspaces { + workspace := workspace if workspace.OwnerID != arg.OwnerID { continue } @@ -385,7 +387,14 @@ func (q *fakeQuerier) GetWorkspaceByOwnerIDAndName(_ context.Context, arg databa if workspace.Deleted != arg.Deleted { continue } - return workspace, nil + + // Return the most recent workspace with the given name + if found == nil || workspace.CreatedAt.After(found.CreatedAt) { + found = &workspace + } + } + if found != nil { + return *found, nil } return database.Workspace{}, sql.ErrNoRows } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 06e5f61105269..3eafdd09a9c9c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3480,6 +3480,7 @@ WHERE owner_id = $1 AND deleted = $2 AND LOWER("name") = LOWER($3) +ORDER BY created_at DESC ` type GetWorkspaceByOwnerIDAndNameParams struct { diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 8c17c323b091d..1b9f6a88f6256 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -70,7 +70,8 @@ FROM WHERE owner_id = @owner_id AND deleted = @deleted - AND LOWER("name") = LOWER(@name); + AND LOWER("name") = LOWER(@name) +ORDER BY created_at DESC; -- name: GetWorkspaceOwnerCountsByTemplateIDs :many SELECT diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 255685a05901e..df84af742e66c 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -35,10 +35,8 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { return } - // The `deleted` query parameter (which defaults to `false`) MUST match the - // `Deleted` field on the workspace otherwise you will get a 410 Gone. var ( - deletedStr = r.URL.Query().Get("deleted") + deletedStr = r.URL.Query().Get("include_deleted") showDeleted = false ) if deletedStr != "" { @@ -46,7 +44,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { showDeleted, err = strconv.ParseBool(deletedStr) if err != nil { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("Invalid boolean value %q for \"deleted\" query param.", deletedStr), + Message: fmt.Sprintf("Invalid boolean value %q for \"include_deleted\" query param.", deletedStr), Validations: []httpapi.Error{ {Field: "deleted", Detail: "Must be a valid boolean"}, }, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 73fed89c80c79..e6857be3745eb 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -258,7 +258,7 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { t.Run("NotFound", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) - _, err := client.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "something", codersdk.WorkspaceByOwnerAndNameParams{}) + _, err := client.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "something", codersdk.WorkspaceOptions{}) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) @@ -271,7 +271,7 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - _, err := client.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, workspace.Name, codersdk.WorkspaceByOwnerAndNameParams{}) + _, err := client.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, workspace.Name, codersdk.WorkspaceOptions{}) require.NoError(t, err) }) t.Run("Deleted", func(t *testing.T) { @@ -294,12 +294,43 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { // Then: // When we call without includes_deleted, we don't expect to get the workspace back - _, err = client.WorkspaceByOwnerAndName(context.Background(), workspace.OwnerName, workspace.Name, codersdk.WorkspaceByOwnerAndNameParams{}) + _, err = client.WorkspaceByOwnerAndName(context.Background(), workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) require.ErrorContains(t, err, "403") // Then: // When we call with includes_deleted, we should get the workspace back - workspaceNew, err := client.WorkspaceByOwnerAndName(context.Background(), workspace.OwnerName, workspace.Name, codersdk.WorkspaceByOwnerAndNameParams{IncludeDeleted: true}) + workspaceNew, err := client.WorkspaceByOwnerAndName(context.Background(), workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{IncludeDeleted: true}) + require.NoError(t, err) + require.Equal(t, workspace.ID, workspaceNew.ID) + + // Given: + // We recreate the workspace with the same name + workspace, err = client.CreateWorkspace(context.Background(), user.OrganizationID, codersdk.CreateWorkspaceRequest{ + TemplateID: workspace.TemplateID, + Name: workspace.Name, + AutostartSchedule: workspace.AutostartSchedule, + TTLMillis: workspace.TTLMillis, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // Then: + // We can fetch the most recent workspace + workspaceNew, err = client.WorkspaceByOwnerAndName(context.Background(), workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + require.NoError(t, err) + require.Equal(t, workspace.ID, workspaceNew.ID) + + // Given: + // We delete the workspace again + build, err = client.CreateWorkspaceBuild(context.Background(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionDelete, + }) + require.NoError(t, err, "delete the workspace") + coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID) + + // Then: + // When we fetch the deleted workspace, we get the most recently deleted one + workspaceNew, err = client.WorkspaceByOwnerAndName(context.Background(), workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{IncludeDeleted: true}) require.NoError(t, err) require.Equal(t, workspace.ID, workspaceNew.ID) }) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 67db13a422459..fbc1be91ab0e5 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -39,7 +39,7 @@ type CreateWorkspaceBuildRequest struct { } type WorkspaceOptions struct { - Deleted bool `json:"deleted,omitempty"` + IncludeDeleted bool `json:"include_deleted,omitempty"` } // asRequestOption returns a function that can be used in (*Client).Request. @@ -47,8 +47,8 @@ type WorkspaceOptions struct { func (o WorkspaceOptions) asRequestOption() requestOption { return func(r *http.Request) { q := r.URL.Query() - if o.Deleted { - q.Set("deleted", "true") + if o.IncludeDeleted { + q.Set("include_deleted", "true") } r.URL.RawQuery = q.Encode() } @@ -62,7 +62,7 @@ func (c *Client) Workspace(ctx context.Context, id uuid.UUID) (Workspace, error) // DeletedWorkspace returns a single workspace that was deleted. func (c *Client) DeletedWorkspace(ctx context.Context, id uuid.UUID) (Workspace, error) { o := WorkspaceOptions{ - Deleted: true, + IncludeDeleted: true, } return c.getWorkspace(ctx, id, o.asRequestOption()) } @@ -258,12 +258,8 @@ func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) ([]Work return workspaces, json.NewDecoder(res.Body).Decode(&workspaces) } -type WorkspaceByOwnerAndNameParams struct { - IncludeDeleted bool `json:"include_deleted,omitempty"` -} - // WorkspaceByOwnerAndName returns a workspace by the owner's UUID and the workspace's name. -func (c *Client) WorkspaceByOwnerAndName(ctx context.Context, owner string, name string, params WorkspaceByOwnerAndNameParams) (Workspace, error) { +func (c *Client) WorkspaceByOwnerAndName(ctx context.Context, owner string, name string, params WorkspaceOptions) (Workspace, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/workspace/%s", owner, name), nil, func(r *http.Request) { q := r.URL.Query() q.Set("include_deleted", fmt.Sprintf("%t", params.IncludeDeleted)) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 39de3d7aff046..2e32b1ffbe707 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -144,7 +144,7 @@ export const getWorkspaces = async (filter?: TypesGen.WorkspaceFilter): Promise< export const getWorkspaceByOwnerAndName = async ( username = "me", workspaceName: string, - params?: TypesGen.WorkspaceByOwnerAndNameParams, + params?: TypesGen.WorkspaceOptions, ): Promise => { const response = await axios.get(`/api/v2/users/${username}/workspace/${workspaceName}`, { params, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 90c651c61cd9a..ce0fae95ce4b0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -464,11 +464,6 @@ export interface WorkspaceBuildsRequest extends Pagination { readonly WorkspaceID: string } -// From codersdk/workspaces.go:261:6 -export interface WorkspaceByOwnerAndNameParams { - readonly include_deleted?: boolean -} - // From codersdk/workspaces.go:219:6 export interface WorkspaceFilter { readonly organization_id?: string @@ -478,7 +473,7 @@ export interface WorkspaceFilter { // From codersdk/workspaces.go:41:6 export interface WorkspaceOptions { - readonly deleted?: boolean + readonly include_deleted?: boolean } // From codersdk/workspaceresources.go:21:6