diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 89c256ca85c48..b55e9d6d23225 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6480,6 +6480,41 @@ const docTemplate = `{ } } }, + "/workspaces/{workspace}/resolve-autostart": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Workspaces" + ], + "summary": "Resolve workspace autostart by id.", + "operationId": "resolve-workspace-autostart-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ResolveAutostartResponse" + } + } + } + } + }, "/workspaces/{workspace}/ttl": { "put": { "security": [ @@ -9720,6 +9755,14 @@ const docTemplate = `{ } } }, + "codersdk.ResolveAutostartResponse": { + "type": "object", + "properties": { + "parameter_mismatch": { + "type": "boolean" + } + } + }, "codersdk.ResourceType": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ab66040190ad1..8b474ff8ecbee 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5718,6 +5718,37 @@ } } }, + "/workspaces/{workspace}/resolve-autostart": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Workspaces"], + "summary": "Resolve workspace autostart by id.", + "operationId": "resolve-workspace-autostart-by-id", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ResolveAutostartResponse" + } + } + } + } + }, "/workspaces/{workspace}/ttl": { "put": { "security": [ @@ -8756,6 +8787,14 @@ } } }, + "codersdk.ResolveAutostartResponse": { + "type": "object", + "properties": { + "parameter_mismatch": { + "type": "boolean" + } + } + }, "codersdk.ResourceType": { "type": "string", "enum": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 81ec1e87e6b18..600e66404f327 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -885,6 +885,7 @@ func New(options *Options) *API { r.Put("/extend", api.putExtendWorkspace) r.Put("/dormant", api.putWorkspaceDormant) r.Put("/autoupdates", api.putWorkspaceAutoupdates) + r.Get("/resolve-autostart", api.resolveAutostart) }) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index a2b1d85ac89bb..6add6d0146796 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "golang.org/x/exp/slices" + "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/parameter" @@ -30,6 +31,19 @@ func WorkspaceBuildParameter(p database.WorkspaceBuildParameter) codersdk.Worksp } } +func TemplateVersionParameters(params []database.TemplateVersionParameter) ([]codersdk.TemplateVersionParameter, error) { + out := make([]codersdk.TemplateVersionParameter, len(params)) + var err error + for i, p := range params { + out[i], err = TemplateVersionParameter(p) + if err != nil { + return nil, xerrors.Errorf("convert template version parameter %q: %w", p.Name, err) + } + } + + return out, nil +} + func TemplateVersionParameter(param database.TemplateVersionParameter) (codersdk.TemplateVersionParameter, error) { options, err := templateVersionParameterOptions(param.Options) if err != nil { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index bea87fb2f427a..6a86e9e735501 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -17,6 +17,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/provisionerjobs" @@ -1059,6 +1060,100 @@ func (api *API) putWorkspaceAutoupdates(rw http.ResponseWriter, r *http.Request) rw.WriteHeader(http.StatusNoContent) } +// @Summary Resolve workspace autostart by id. +// @ID resolve-workspace-autostart-by-id +// @Security CoderSessionToken +// @Produce json +// @Tags Workspaces +// @Param workspace path string true "Workspace ID" format(uuid) +// @Success 200 {object} codersdk.ResolveAutostartResponse +// @Router /workspaces/{workspace}/resolve-autostart [get] +func (api *API) resolveAutostart(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + workspace = httpmw.WorkspaceParam(r) + ) + + template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + templateAccessControl := (*(api.AccessControlStore.Load())).GetTemplateAccessControl(template) + useActiveVersion := templateAccessControl.RequireActiveVersion || workspace.AutomaticUpdates == database.AutomaticUpdatesAlways + if !useActiveVersion { + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ResolveAutostartResponse{}) + return + } + + build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching latest workspace build.", + Detail: err.Error(), + }) + return + } + + if build.TemplateVersionID == template.ActiveVersionID { + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ResolveAutostartResponse{}) + return + } + + version, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template version.", + Detail: err.Error(), + }) + return + } + + dbVersionParams, err := api.Database.GetTemplateVersionParameters(ctx, version.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template version parameters.", + Detail: err.Error(), + }) + return + } + + dbBuildParams, err := api.Database.GetWorkspaceBuildParameters(ctx, build.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching latest workspace build parameters.", + Detail: err.Error(), + }) + return + } + + versionParams, err := db2sdk.TemplateVersionParameters(dbVersionParams) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error converting template version parameters.", + Detail: err.Error(), + }) + return + } + + resolver := codersdk.ParameterResolver{ + Rich: db2sdk.WorkspaceBuildParameters(dbBuildParams), + } + + var response codersdk.ResolveAutostartResponse + for _, param := range versionParams { + _, err := resolver.ValidateResolve(param, nil) + // There's a parameter mismatch if we get an error back from the + // resolver. + response.ParameterMismatch = err != nil + if response.ParameterMismatch { + break + } + } + httpapi.Write(ctx, rw, http.StatusOK, response) +} + // @Summary Watch workspace by ID // @ID watch-workspace-by-id // @Security CoderSessionToken diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index d3f5bfa00e276..002c8a7c00d8c 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -340,6 +340,99 @@ func TestWorkspace(t *testing.T) { }) } +func TestResolveAutostart(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + ownerClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + version1 := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, version1.ID) + template := coderdtest.CreateTemplate(t, ownerClient, owner.OrganizationID, version1.ID) + + params := &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{ + { + Name: "param", + Description: "param", + Required: true, + Mutable: true, + }, + }, + }, + }, + }, + }, + ProvisionApply: echo.ApplyComplete, + } + version2 := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, params, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, version2.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutomaticUpdates = codersdk.AutomaticUpdatesAlways + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + err := ownerClient.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: version2.ID, + }) + require.NoError(t, err) + + // Autostart shouldn't be possible if parameters do not match. + resp, err := client.ResolveAutostart(ctx, workspace.ID.String()) + require.NoError(t, err) + require.True(t, resp.ParameterMismatch) + + update, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: version2.ID, + Transition: codersdk.WorkspaceTransitionStart, + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + { + Name: "param", + Value: "Hello", + }, + }, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, update.ID) + + // We should be able to autostart since parameters are updated. + resp, err = client.ResolveAutostart(ctx, workspace.ID.String()) + require.NoError(t, err) + require.False(t, resp.ParameterMismatch) + + // Create one last version where the parameters are the same as the previous + // version. + version3 := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, params, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, version3.ID) + + err = ownerClient.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: version3.ID, + }) + require.NoError(t, err) + + // Even though we're out of date we should still be able to autostart + // since parameters resolve. + resp, err = client.ResolveAutostart(ctx, workspace.ID.String()) + require.NoError(t, err) + require.False(t, resp.ParameterMismatch) + }) +} + func TestAdminViewAllWorkspaces(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 54f79aa58725d..307bbdb0d3b93 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -449,6 +449,23 @@ func (c *Client) WorkspaceQuota(ctx context.Context, userID string) (WorkspaceQu return quota, json.NewDecoder(res.Body).Decode("a) } +type ResolveAutostartResponse struct { + ParameterMismatch bool `json:"parameter_mismatch"` +} + +func (c *Client) ResolveAutostart(ctx context.Context, workspaceID string) (ResolveAutostartResponse, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/resolve-autostart", workspaceID), nil) + if err != nil { + return ResolveAutostartResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ResolveAutostartResponse{}, ReadBodyAsError(res) + } + var response ResolveAutostartResponse + return response, json.NewDecoder(res.Body).Decode(&response) +} + // WorkspaceNotifyChannel is the PostgreSQL NOTIFY // channel to listen for updates on. The payload is empty, // because the size of a workspace payload can be very large. diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 4111224c08ee3..517e76981c567 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4100,6 +4100,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `region_id` | integer | false | | Region ID is the region of the replica. | | `relay_address` | string | false | | Relay address is the accessible address to relay DERP connections. | +## codersdk.ResolveAutostartResponse + +```json +{ + "parameter_mismatch": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------------- | ------- | -------- | ------------ | ----------- | +| `parameter_mismatch` | boolean | false | | | + ## codersdk.ResourceType ```json diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 3d53fad1711ee..209d7f34d2bac 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -1240,6 +1240,43 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/extend \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Resolve workspace autostart by id. + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/resolve-autostart \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaces/{workspace}/resolve-autostart` + +### Parameters + +| Name | In | Type | Required | Description | +| ----------- | ---- | ------------ | -------- | ------------ | +| `workspace` | path | string(uuid) | true | Workspace ID | + +### Example responses + +> 200 Response + +```json +{ + "parameter_mismatch": true +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ResolveAutostartResponse](schemas.md#codersdkresolveautostartresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Update workspace TTL by ID ### Code samples diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index a80107d379e74..45060a1686820 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/enterprise/coderd/schedule" "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" ) @@ -1061,6 +1062,72 @@ func TestWorkspaceLock(t *testing.T) { }) } +func TestResolveAutostart(t *testing.T) { + t.Parallel() + + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + TemplateScheduleStore: &schedule.EnterpriseTemplateScheduleStore{}, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAccessControl: 1, + }, + }, + }) + + version1 := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, version1.ID) + template := coderdtest.CreateTemplate(t, ownerClient, owner.OrganizationID, version1.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.RequireActiveVersion = true + }) + + params := &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: []*proto.RichParameter{ + { + Name: "param", + Description: "param", + Required: true, + Mutable: true, + }, + }, + }, + }, + }, + }, + ProvisionApply: echo.ApplyComplete, + } + version2 := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, params, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, version2.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + //nolint:gocritic + err := ownerClient.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: version2.ID, + }) + require.NoError(t, err) + + // Autostart shouldn't be possible since the template requires automatic + // updates. + resp, err := client.ResolveAutostart(ctx, workspace.ID.String()) + require.NoError(t, err) + require.True(t, resp.ParameterMismatch) +} + func must[T any](value T, err error) T { if err != nil { panic(err) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a4f52e1f46387..ee4fc68f01672 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -812,6 +812,11 @@ export interface Replica { readonly database_latency: number; } +// From codersdk/workspaces.go +export interface ResolveAutostartResponse { + readonly parameter_mismatch: boolean; +} + // From codersdk/client.go export interface Response { readonly message: string;