mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
feat: Allow user to cancel workspace jobs (#5115)
* Add database column allow_user_cancel_workspace_jobs * Adjust API * site: typesGenerated.ts * Expose template.allow_ in Workspaces API * Fix: site tests * Fix: make fmt/prettier * Fix: enterprise * Database tests * Add CLI tests * Add checkbox * i18n * Logic: block cancelling * Unit tests for conditional cancel * Fix: message * Address PR comment * Address PR comments * Fix: make
This commit is contained in:
5
coderd/database/dump.sql
generated
5
coderd/database/dump.sql
generated
@ -358,13 +358,16 @@ CREATE TABLE templates (
|
||||
icon character varying(256) DEFAULT ''::character varying NOT NULL,
|
||||
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
group_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
display_name character varying(64) DEFAULT ''::character varying NOT NULL
|
||||
display_name character varying(64) DEFAULT ''::character varying NOT NULL,
|
||||
allow_user_cancel_workspace_jobs boolean DEFAULT true NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for auto-stop for workspaces created from this template.';
|
||||
|
||||
COMMENT ON COLUMN templates.display_name IS 'Display name is a custom, human-friendly template name that user can set.';
|
||||
|
||||
COMMENT ON COLUMN templates.allow_user_cancel_workspace_jobs IS 'Allow users to cancel in-progress workspace jobs.';
|
||||
|
||||
CREATE TABLE user_links (
|
||||
user_id uuid NOT NULL,
|
||||
login_type login_type NOT NULL,
|
||||
|
@ -0,0 +1 @@
|
||||
ALTER TABLE templates DROP COLUMN allow_user_cancel_workspace_jobs;
|
@ -0,0 +1,4 @@
|
||||
ALTER TABLE templates ADD COLUMN allow_user_cancel_workspace_jobs boolean NOT NULL DEFAULT true;
|
||||
|
||||
COMMENT ON COLUMN templates.allow_user_cancel_workspace_jobs
|
||||
IS 'Allow users to cancel in-progress workspace jobs.';
|
@ -596,6 +596,8 @@ type Template struct {
|
||||
GroupACL TemplateACL `db:"group_acl" json:"group_acl"`
|
||||
// Display name is a custom, human-friendly template name that user can set.
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
// Allow users to cancel in-progress workspace jobs.
|
||||
AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"`
|
||||
}
|
||||
|
||||
type TemplateVersion struct {
|
||||
|
@ -3130,7 +3130,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem
|
||||
|
||||
const getTemplateByID = `-- name: GetTemplateByID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
FROM
|
||||
templates
|
||||
WHERE
|
||||
@ -3158,13 +3158,14 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat
|
||||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one
|
||||
SELECT
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
FROM
|
||||
templates
|
||||
WHERE
|
||||
@ -3200,12 +3201,13 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G
|
||||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTemplates = `-- name: GetTemplates :many
|
||||
SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name FROM templates
|
||||
SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs FROM templates
|
||||
ORDER BY (name, id) ASC
|
||||
`
|
||||
|
||||
@ -3234,6 +3236,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) {
|
||||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -3250,7 +3253,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) {
|
||||
|
||||
const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many
|
||||
SELECT
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
FROM
|
||||
templates
|
||||
WHERE
|
||||
@ -3314,6 +3317,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate
|
||||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -3344,27 +3348,29 @@ INSERT INTO
|
||||
icon,
|
||||
user_acl,
|
||||
group_acl,
|
||||
display_name
|
||||
display_name,
|
||||
allow_user_cancel_workspace_jobs
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
`
|
||||
|
||||
type InsertTemplateParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Provisioner ProvisionerType `db:"provisioner" json:"provisioner"`
|
||||
ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"`
|
||||
Description string `db:"description" json:"description"`
|
||||
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
|
||||
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
UserACL TemplateACL `db:"user_acl" json:"user_acl"`
|
||||
GroupACL TemplateACL `db:"group_acl" json:"group_acl"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Provisioner ProvisionerType `db:"provisioner" json:"provisioner"`
|
||||
ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"`
|
||||
Description string `db:"description" json:"description"`
|
||||
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
|
||||
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
UserACL TemplateACL `db:"user_acl" json:"user_acl"`
|
||||
GroupACL TemplateACL `db:"group_acl" json:"group_acl"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) {
|
||||
@ -3383,6 +3389,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam
|
||||
arg.UserACL,
|
||||
arg.GroupACL,
|
||||
arg.DisplayName,
|
||||
arg.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
var i Template
|
||||
err := row.Scan(
|
||||
@ -3401,6 +3408,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam
|
||||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@ -3414,7 +3422,7 @@ SET
|
||||
WHERE
|
||||
id = $3
|
||||
RETURNING
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
`
|
||||
|
||||
type UpdateTemplateACLByIDParams struct {
|
||||
@ -3442,6 +3450,7 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla
|
||||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@ -3497,21 +3506,23 @@ SET
|
||||
default_ttl = $4,
|
||||
name = $5,
|
||||
icon = $6,
|
||||
display_name = $7
|
||||
display_name = $7,
|
||||
allow_user_cancel_workspace_jobs = $8
|
||||
WHERE
|
||||
id = $1
|
||||
RETURNING
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
`
|
||||
|
||||
type UpdateTemplateMetaByIDParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Description string `db:"description" json:"description"`
|
||||
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Description string `db:"description" json:"description"`
|
||||
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) (Template, error) {
|
||||
@ -3523,6 +3534,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl
|
||||
arg.Name,
|
||||
arg.Icon,
|
||||
arg.DisplayName,
|
||||
arg.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
var i Template
|
||||
err := row.Scan(
|
||||
@ -3541,6 +3553,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl
|
||||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -70,10 +70,11 @@ INSERT INTO
|
||||
icon,
|
||||
user_acl,
|
||||
group_acl,
|
||||
display_name
|
||||
display_name,
|
||||
allow_user_cancel_workspace_jobs
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *;
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *;
|
||||
|
||||
-- name: UpdateTemplateActiveVersionByID :exec
|
||||
UPDATE
|
||||
@ -102,7 +103,8 @@ SET
|
||||
default_ttl = $4,
|
||||
name = $5,
|
||||
icon = $6,
|
||||
display_name = $7
|
||||
display_name = $7,
|
||||
allow_user_cancel_workspace_jobs = $8
|
||||
WHERE
|
||||
id = $1
|
||||
RETURNING
|
||||
|
@ -220,6 +220,11 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
var allowUserCancelWorkspaceJobs bool
|
||||
if createTemplate.AllowUserCancelWorkspaceJobs != nil {
|
||||
allowUserCancelWorkspaceJobs = *createTemplate.AllowUserCancelWorkspaceJobs
|
||||
}
|
||||
|
||||
var dbTemplate database.Template
|
||||
var template codersdk.Template
|
||||
err = api.Database.InTx(func(tx database.Store) error {
|
||||
@ -239,8 +244,9 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
|
||||
GroupACL: database.TemplateACL{
|
||||
organization.ID.String(): []rbac.Action{rbac.ActionRead},
|
||||
},
|
||||
DisplayName: createTemplate.DisplayName,
|
||||
Icon: createTemplate.Icon,
|
||||
DisplayName: createTemplate.DisplayName,
|
||||
Icon: createTemplate.Icon,
|
||||
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert template: %s", err)
|
||||
@ -476,6 +482,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
||||
req.Description == template.Description &&
|
||||
req.DisplayName == template.DisplayName &&
|
||||
req.Icon == template.Icon &&
|
||||
req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs &&
|
||||
req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() {
|
||||
return nil
|
||||
}
|
||||
@ -488,6 +495,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
||||
desc := req.Description
|
||||
icon := req.Icon
|
||||
maxTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond
|
||||
allowUserCancelWorkspaceJobs := req.AllowUserCancelWorkspaceJobs
|
||||
|
||||
if name == "" {
|
||||
name = template.Name
|
||||
@ -497,13 +505,14 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
updated, err = tx.UpdateTemplateMetaByID(ctx, database.UpdateTemplateMetaByIDParams{
|
||||
ID: template.ID,
|
||||
UpdatedAt: database.Now(),
|
||||
Name: name,
|
||||
DisplayName: displayName,
|
||||
Description: desc,
|
||||
Icon: icon,
|
||||
DefaultTTL: int64(maxTTL),
|
||||
ID: template.ID,
|
||||
UpdatedAt: database.Now(),
|
||||
Name: name,
|
||||
DisplayName: displayName,
|
||||
Description: desc,
|
||||
Icon: icon,
|
||||
DefaultTTL: int64(maxTTL),
|
||||
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@ -740,21 +749,22 @@ func (api *API) convertTemplate(
|
||||
buildTimeStats := api.metricsCache.TemplateBuildTimeStats(template.ID)
|
||||
|
||||
return codersdk.Template{
|
||||
ID: template.ID,
|
||||
CreatedAt: template.CreatedAt,
|
||||
UpdatedAt: template.UpdatedAt,
|
||||
OrganizationID: template.OrganizationID,
|
||||
Name: template.Name,
|
||||
DisplayName: template.DisplayName,
|
||||
Provisioner: codersdk.ProvisionerType(template.Provisioner),
|
||||
ActiveVersionID: template.ActiveVersionID,
|
||||
WorkspaceOwnerCount: workspaceOwnerCount,
|
||||
ActiveUserCount: activeCount,
|
||||
BuildTimeStats: buildTimeStats,
|
||||
Description: template.Description,
|
||||
Icon: template.Icon,
|
||||
DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(),
|
||||
CreatedByID: template.CreatedBy,
|
||||
CreatedByName: createdByName,
|
||||
ID: template.ID,
|
||||
CreatedAt: template.CreatedAt,
|
||||
UpdatedAt: template.UpdatedAt,
|
||||
OrganizationID: template.OrganizationID,
|
||||
Name: template.Name,
|
||||
DisplayName: template.DisplayName,
|
||||
Provisioner: codersdk.ProvisionerType(template.Provisioner),
|
||||
ActiveVersionID: template.ActiveVersionID,
|
||||
WorkspaceOwnerCount: workspaceOwnerCount,
|
||||
ActiveUserCount: activeCount,
|
||||
BuildTimeStats: buildTimeStats,
|
||||
Description: template.Description,
|
||||
Icon: template.Icon,
|
||||
DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(),
|
||||
CreatedByID: template.CreatedBy,
|
||||
CreatedByName: createdByName,
|
||||
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
||||
}
|
||||
}
|
||||
|
@ -285,11 +285,12 @@ func TestPatchTemplateMeta(t *testing.T) {
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
req := codersdk.UpdateTemplateMeta{
|
||||
Name: "new-template-name",
|
||||
DisplayName: "Displayed Name 456",
|
||||
Description: "lorem ipsum dolor sit amet et cetera",
|
||||
Icon: "/icons/new-icon.png",
|
||||
DefaultTTLMillis: 12 * time.Hour.Milliseconds(),
|
||||
Name: "new-template-name",
|
||||
DisplayName: "Displayed Name 456",
|
||||
Description: "lorem ipsum dolor sit amet et cetera",
|
||||
Icon: "/icons/new-icon.png",
|
||||
DefaultTTLMillis: 12 * time.Hour.Milliseconds(),
|
||||
AllowUserCancelWorkspaceJobs: false,
|
||||
}
|
||||
// It is unfortunate we need to sleep, but the test can fail if the
|
||||
// updatedAt is too close together.
|
||||
@ -306,6 +307,7 @@ func TestPatchTemplateMeta(t *testing.T) {
|
||||
assert.Equal(t, req.Description, updated.Description)
|
||||
assert.Equal(t, req.Icon, updated.Icon)
|
||||
assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis)
|
||||
assert.False(t, req.AllowUserCancelWorkspaceJobs)
|
||||
|
||||
// Extra paranoid: did it _really_ happen?
|
||||
updated, err = client.Template(ctx, template.ID)
|
||||
@ -316,6 +318,7 @@ func TestPatchTemplateMeta(t *testing.T) {
|
||||
assert.Equal(t, req.Description, updated.Description)
|
||||
assert.Equal(t, req.Icon, updated.Icon)
|
||||
assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis)
|
||||
assert.False(t, req.AllowUserCancelWorkspaceJobs)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, 4)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[3].Action)
|
||||
|
@ -599,6 +599,21 @@ func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := api.verifyUserCanCancelWorkspaceBuilds(ctx, httpmw.APIKey(r).UserID, workspace.TemplateID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error verifying permission to cancel workspace build.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "User is not allowed to cancel workspace builds. Owner role is required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
job, err := api.Database.GetProvisionerJobByID(ctx, workspaceBuild.JobID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
@ -646,6 +661,23 @@ func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques
|
||||
})
|
||||
}
|
||||
|
||||
func (api *API) verifyUserCanCancelWorkspaceBuilds(ctx context.Context, userID uuid.UUID, templateID uuid.UUID) (bool, error) {
|
||||
template, err := api.Database.GetTemplateByID(ctx, templateID)
|
||||
if err != nil {
|
||||
return false, xerrors.New("no template exists for this workspace")
|
||||
}
|
||||
|
||||
if template.AllowUserCancelWorkspaceJobs {
|
||||
return true, nil // all users can cancel workspace builds
|
||||
}
|
||||
|
||||
user, err := api.Database.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return false, xerrors.New("user does not exist")
|
||||
}
|
||||
return slices.Contains(user.RBACRoles, rbac.RoleOwner()), nil // only user with "owner" role can cancel workspace builds
|
||||
}
|
||||
|
||||
func (api *API) workspaceBuildResources(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspaceBuild := httpmw.WorkspaceBuildParam(r)
|
||||
|
@ -367,41 +367,79 @@ func TestWorkspaceBuildsProvisionerState(t *testing.T) {
|
||||
|
||||
func TestPatchCancelWorkspaceBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Log{
|
||||
Log: &proto.Log{},
|
||||
},
|
||||
}},
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
t.Run("User is allowed to cancel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Log{
|
||||
Log: &proto.Log{},
|
||||
},
|
||||
}},
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
var build codersdk.WorkspaceBuild
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
build, err = client.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
|
||||
return assert.NoError(t, err) && build.Job.Status == codersdk.ProvisionerJobRunning
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
err := client.CancelWorkspaceBuild(ctx, build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
build, err = client.WorkspaceBuild(ctx, build.ID)
|
||||
return assert.NoError(t, err) &&
|
||||
// The job will never actually cancel successfully because it will never send a
|
||||
// provision complete response.
|
||||
assert.Empty(t, build.Job.Error) &&
|
||||
build.Job.Status == codersdk.ProvisionerJobCanceling
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
var build codersdk.WorkspaceBuild
|
||||
t.Run("User is not allowed to cancel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Log{
|
||||
Log: &proto.Log{},
|
||||
},
|
||||
}},
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
build, err = client.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
|
||||
return assert.NoError(t, err) && build.Job.Status == codersdk.ProvisionerJobRunning
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
err := client.CancelWorkspaceBuild(ctx, build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
build, err = client.WorkspaceBuild(ctx, build.ID)
|
||||
return assert.NoError(t, err) &&
|
||||
// The job will never actually cancel successfully because it will never send a
|
||||
// provision complete response.
|
||||
assert.Empty(t, build.Job.Error) &&
|
||||
build.Job.Status == codersdk.ProvisionerJobCanceling
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
userClient := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
workspace := coderdtest.CreateWorkspace(t, userClient, owner.OrganizationID, template.ID)
|
||||
var build codersdk.WorkspaceBuild
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
build, err = userClient.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
|
||||
return assert.NoError(t, err) && build.Job.Status == codersdk.ProvisionerJobRunning
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
err := userClient.CancelWorkspaceBuild(ctx, build.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkspaceBuildResources(t *testing.T) {
|
||||
|
@ -1010,21 +1010,22 @@ func convertWorkspace(
|
||||
|
||||
ttlMillis := convertWorkspaceTTLMillis(workspace.Ttl)
|
||||
return codersdk.Workspace{
|
||||
ID: workspace.ID,
|
||||
CreatedAt: workspace.CreatedAt,
|
||||
UpdatedAt: workspace.UpdatedAt,
|
||||
OwnerID: workspace.OwnerID,
|
||||
OwnerName: owner.Username,
|
||||
TemplateID: workspace.TemplateID,
|
||||
LatestBuild: workspaceBuild,
|
||||
TemplateName: template.Name,
|
||||
TemplateIcon: template.Icon,
|
||||
TemplateDisplayName: template.DisplayName,
|
||||
Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(),
|
||||
Name: workspace.Name,
|
||||
AutostartSchedule: autostartSchedule,
|
||||
TTLMillis: ttlMillis,
|
||||
LastUsedAt: workspace.LastUsedAt,
|
||||
ID: workspace.ID,
|
||||
CreatedAt: workspace.CreatedAt,
|
||||
UpdatedAt: workspace.UpdatedAt,
|
||||
OwnerID: workspace.OwnerID,
|
||||
OwnerName: owner.Username,
|
||||
TemplateID: workspace.TemplateID,
|
||||
LatestBuild: workspaceBuild,
|
||||
TemplateName: template.Name,
|
||||
TemplateIcon: template.Icon,
|
||||
TemplateDisplayName: template.DisplayName,
|
||||
TemplateAllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
||||
Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(),
|
||||
Name: workspace.Name,
|
||||
AutostartSchedule: autostartSchedule,
|
||||
TTLMillis: ttlMillis,
|
||||
LastUsedAt: workspace.LastUsedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -125,13 +125,16 @@ func TestWorkspace(t *testing.T) {
|
||||
|
||||
const templateIcon = "/img/icon.svg"
|
||||
const templateDisplayName = "This is template"
|
||||
var templateAllowUserCancelWorkspaceJobs = false
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
ctr.Icon = templateIcon
|
||||
ctr.DisplayName = templateDisplayName
|
||||
ctr.AllowUserCancelWorkspaceJobs = &templateAllowUserCancelWorkspaceJobs
|
||||
})
|
||||
require.NotEmpty(t, template.Name)
|
||||
require.NotEmpty(t, template.DisplayName)
|
||||
require.NotEmpty(t, template.Icon)
|
||||
require.False(t, template.AllowUserCancelWorkspaceJobs)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
@ -144,6 +147,7 @@ func TestWorkspace(t *testing.T) {
|
||||
assert.Equal(t, template.Name, ws.TemplateName)
|
||||
assert.Equal(t, templateIcon, ws.TemplateIcon)
|
||||
assert.Equal(t, templateDisplayName, ws.TemplateDisplayName)
|
||||
assert.Equal(t, templateAllowUserCancelWorkspaceJobs, ws.TemplateAllowUserCancelWorkspaceJobs)
|
||||
})
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user