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:
Marcin Tojek 2022-11-21 11:43:53 +01:00 committed by GitHub
parent 5fa3fdeca0
commit e86539db11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 336 additions and 162 deletions

View File

@ -13,11 +13,12 @@ import (
func templateEdit() *cobra.Command {
var (
name string
displayName string
description string
icon string
defaultTTL time.Duration
name string
displayName string
description string
icon string
defaultTTL time.Duration
allowUserCancelWorkspaceJobs bool
)
cmd := &cobra.Command{
@ -40,11 +41,12 @@ func templateEdit() *cobra.Command {
// NOTE: coderd will ignore empty fields.
req := codersdk.UpdateTemplateMeta{
Name: name,
DisplayName: displayName,
Description: description,
Icon: icon,
DefaultTTLMillis: defaultTTL.Milliseconds(),
Name: name,
DisplayName: displayName,
Description: description,
Icon: icon,
DefaultTTLMillis: defaultTTL.Milliseconds(),
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
}
_, err = client.UpdateTemplateMeta(cmd.Context(), template.ID, req)
@ -61,6 +63,7 @@ func templateEdit() *cobra.Command {
cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description")
cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path")
cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 0, "Edit the template default time before shutdown - workspaces created from this template to this value.")
cmd.Flags().BoolVarP(&allowUserCancelWorkspaceJobs, "allow-user-cancel-workspace-jobs", "", true, "Allow users to cancel in-progress workspace jobs.")
cliui.AllowSkipPrompt(cmd)
return cmd

View File

@ -2,6 +2,7 @@ package cli_test
import (
"context"
"strconv"
"testing"
"time"
@ -31,6 +32,8 @@ func TestTemplateEdit(t *testing.T) {
desc := "lorem ipsum dolor sit amet et cetera"
icon := "/icons/new-icon.png"
defaultTTL := 12 * time.Hour
allowUserCancelWorkspaceJobs := false
cmdArgs := []string{
"templates",
"edit",
@ -40,6 +43,7 @@ func TestTemplateEdit(t *testing.T) {
"--description", desc,
"--icon", icon,
"--default-ttl", defaultTTL.String(),
"--allow-user-cancel-workspace-jobs=" + strconv.FormatBool(allowUserCancelWorkspaceJobs),
}
cmd, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
@ -57,6 +61,7 @@ func TestTemplateEdit(t *testing.T) {
assert.Equal(t, desc, updated.Description)
assert.Equal(t, icon, updated.Icon)
assert.Equal(t, defaultTTL.Milliseconds(), updated.DefaultTTLMillis)
assert.Equal(t, allowUserCancelWorkspaceJobs, updated.AllowUserCancelWorkspaceJobs)
})
t.Run("FirstEmptyThenNotModified", func(t *testing.T) {
t.Parallel()
@ -75,6 +80,7 @@ func TestTemplateEdit(t *testing.T) {
"--description", template.Description,
"--icon", template.Icon,
"--default-ttl", (time.Duration(template.DefaultTTLMillis) * time.Millisecond).String(),
"--allow-user-cancel-workspace-jobs=" + strconv.FormatBool(template.AllowUserCancelWorkspaceJobs),
}
cmd, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
@ -91,6 +97,7 @@ func TestTemplateEdit(t *testing.T) {
assert.Equal(t, template.Description, updated.Description)
assert.Equal(t, template.Icon, updated.Icon)
assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis)
assert.Equal(t, template.AllowUserCancelWorkspaceJobs, updated.AllowUserCancelWorkspaceJobs)
})
t.Run("InvalidDisplayName", func(t *testing.T) {
t.Parallel()

View File

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

View File

@ -0,0 +1 @@
ALTER TABLE templates DROP COLUMN allow_user_cancel_workspace_jobs;

View File

@ -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.';

View File

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

View File

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

View File

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

View File

@ -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,
}
}

View File

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

View File

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

View File

@ -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) {

View File

@ -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,
}
}

View File

@ -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)
})
}

View File

@ -72,6 +72,10 @@ type CreateTemplateRequest struct {
// DefaultTTLMillis allows optionally specifying the default TTL
// for all workspaces created from this template.
DefaultTTLMillis *int64 `json:"default_ttl_ms,omitempty"`
// Allow users to cancel in-progress workspace jobs.
// *bool as the default value is "true".
AllowUserCancelWorkspaceJobs *bool `json:"allow_user_cancel_workspace_jobs"`
}
// CreateWorkspaceRequest provides options for creating a new workspace.

View File

@ -31,6 +31,8 @@ type Template struct {
DefaultTTLMillis int64 `json:"default_ttl_ms"`
CreatedByID uuid.UUID `json:"created_by_id"`
CreatedByName string `json:"created_by_name"`
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs"`
}
type TransitionStats struct {
@ -72,11 +74,12 @@ type UpdateTemplateACL struct {
}
type UpdateTemplateMeta struct {
Name string `json:"name,omitempty" validate:"omitempty,template_name"`
DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"`
Name string `json:"name,omitempty" validate:"omitempty,template_name"`
DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"`
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"`
}
// Template returns a single template.

View File

@ -17,21 +17,22 @@ import (
// Workspace is a deployment of a template. It references a specific
// version and can be updated.
type Workspace struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
OwnerID uuid.UUID `json:"owner_id"`
OwnerName string `json:"owner_name"`
TemplateID uuid.UUID `json:"template_id"`
TemplateName string `json:"template_name"`
TemplateDisplayName string `json:"template_display_name"`
TemplateIcon string `json:"template_icon"`
LatestBuild WorkspaceBuild `json:"latest_build"`
Outdated bool `json:"outdated"`
Name string `json:"name"`
AutostartSchedule *string `json:"autostart_schedule,omitempty"`
TTLMillis *int64 `json:"ttl_ms,omitempty"`
LastUsedAt time.Time `json:"last_used_at"`
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
OwnerID uuid.UUID `json:"owner_id"`
OwnerName string `json:"owner_name"`
TemplateID uuid.UUID `json:"template_id"`
TemplateName string `json:"template_name"`
TemplateDisplayName string `json:"template_display_name"`
TemplateIcon string `json:"template_icon"`
TemplateAllowUserCancelWorkspaceJobs bool `json:"template_allow_user_cancel_workspace_jobs"`
LatestBuild WorkspaceBuild `json:"latest_build"`
Outdated bool `json:"outdated"`
Name string `json:"name"`
AutostartSchedule *string `json:"autostart_schedule,omitempty"`
TTLMillis *int64 `json:"ttl_ms,omitempty"`
LastUsedAt time.Time `json:"last_used_at"`
}
type WorkspacesRequest struct {

View File

@ -48,23 +48,24 @@ var AuditableResources = auditMap(map[any]map[string]Action{
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
},
&database.Template{}: {
"id": ActionTrack,
"created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
"organization_id": ActionIgnore, /// Never changes.
"deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired.
"name": ActionTrack,
"display_name": ActionTrack,
"provisioner": ActionTrack,
"active_version_id": ActionTrack,
"description": ActionTrack,
"icon": ActionTrack,
"default_ttl": ActionTrack,
"min_autostart_interval": ActionTrack,
"created_by": ActionTrack,
"is_private": ActionTrack,
"group_acl": ActionTrack,
"user_acl": ActionTrack,
"id": ActionTrack,
"created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
"organization_id": ActionIgnore, /// Never changes.
"deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired.
"name": ActionTrack,
"display_name": ActionTrack,
"provisioner": ActionTrack,
"active_version_id": ActionTrack,
"description": ActionTrack,
"icon": ActionTrack,
"default_ttl": ActionTrack,
"min_autostart_interval": ActionTrack,
"created_by": ActionTrack,
"is_private": ActionTrack,
"group_acl": ActionTrack,
"user_acl": ActionTrack,
"allow_user_cancel_workspace_jobs": ActionTrack,
},
&database.TemplateVersion{}: {
"id": ActionTrack,

View File

@ -182,6 +182,7 @@ export interface CreateTemplateRequest {
readonly template_version_id: string
readonly parameter_values?: CreateParameterRequest[]
readonly default_ttl_ms?: number
readonly allow_user_cancel_workspace_jobs?: boolean
}
// From codersdk/templateversions.go
@ -642,6 +643,7 @@ export interface Template {
readonly default_ttl_ms: number
readonly created_by_id: string
readonly created_by_name: string
readonly allow_user_cancel_workspace_jobs: boolean
}
// From codersdk/templates.go
@ -725,6 +727,7 @@ export interface UpdateTemplateMeta {
readonly description?: string
readonly icon?: string
readonly default_ttl_ms?: number
readonly allow_user_cancel_workspace_jobs?: boolean
}
// From codersdk/users.go
@ -799,6 +802,7 @@ export interface Workspace {
readonly template_name: string
readonly template_display_name: string
readonly template_icon: string
readonly template_allow_user_cancel_workspace_jobs: boolean
readonly latest_build: WorkspaceBuild
readonly outdated: boolean
readonly name: string

View File

@ -10,5 +10,7 @@
"deleteCta": "Delete Template"
}
},
"displayNameLabel": "Display name"
"displayNameLabel": "Display name",
"allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.",
"allowUserCancelWorkspaceJobsNotice": "It is advised to keep the option disabled when canceling a workspace job may leave the workspace in an unhealthy state, and extra permissions are required to manually repair its resources."
}

View File

@ -1,3 +1,6 @@
import Box from "@material-ui/core/Box"
import Checkbox from "@material-ui/core/Checkbox"
import Typography from "@material-ui/core/Typography"
import data from "@emoji-mart/data/sets/14/twitter.json"
import Picker from "@emoji-mart/react"
import Button from "@material-ui/core/Button"
@ -56,6 +59,7 @@ export const validationSchema = Yup.object({
.integer()
.min(0)
.max(24 * MAX_TTL_DAYS /* 7 days in hours */, Language.ttlMaxError),
allow_user_cancel_workspace_jobs: Yup.boolean(),
})
export interface TemplateSettingsForm {
@ -86,6 +90,8 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
// on display, convert from ms => hours
default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION,
icon: template.icon,
allow_user_cancel_workspace_jobs:
template.allow_user_cancel_workspace_jobs,
},
validationSchema,
onSubmit: (formData) => {
@ -212,6 +218,27 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
{form.values.default_ttl_ms && !form.errors.default_ttl_ms && (
<span>{Language.ttlHelperText(form.values.default_ttl_ms)}</span>
)}
<Box display="flex">
<div>
{/*"getFieldHelpers" can't be used as it requires "helperText" property to be present.*/}
<Checkbox
id="allow_user_cancel_workspace_jobs"
name="allow_user_cancel_workspace_jobs"
disabled={isSubmitting}
checked={form.values.allow_user_cancel_workspace_jobs}
onChange={form.handleChange}
/>
</div>
<Box>
<Typography variant="h6" style={{ fontSize: 14 }}>
{t("allowUserCancelWorkspaceJobsLabel")}
</Typography>
<Typography variant="caption" color="textSecondary">
{t("allowUserCancelWorkspaceJobsNotice")}
</Typography>
</Box>
</Box>
</Stack>
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />

View File

@ -28,6 +28,7 @@ const validFormValues = {
description: "A description",
icon: "A string",
default_ttl_ms: 1,
allow_user_cancel_workspace_jobs: false,
}
const fillAndSubmitForm = async ({
@ -36,15 +37,14 @@ const fillAndSubmitForm = async ({
description,
default_ttl_ms,
icon,
allow_user_cancel_workspace_jobs,
}: Required<UpdateTemplateMeta>) => {
const nameField = await screen.findByLabelText(FormLanguage.nameLabel)
await userEvent.clear(nameField)
await userEvent.type(nameField, name)
const { t } = i18next
const displayNameLabel = t("displayNameLabel", {
ns: "templatePage",
})
const displayNameLabel = t("displayNameLabel", { ns: "templatePage" })
const displayNameField = await screen.findByLabelText(displayNameLabel)
await userEvent.clear(displayNameField)
@ -64,6 +64,12 @@ const fillAndSubmitForm = async ({
await userEvent.clear(maxTtlField)
await userEvent.type(maxTtlField, default_ttl_ms.toString())
const allowCancelJobsField = await screen.getByRole("checkbox")
// checkbox is checked by default, so it must be clicked to get unchecked
if (!allow_user_cancel_workspace_jobs) {
await userEvent.click(allowCancelJobsField)
}
const submitButton = await screen.findByText(
FooterFormLanguage.defaultSubmitLabel,
)

View File

@ -211,6 +211,7 @@ export const MockTemplate: TypesGen.Template = {
created_by_id: "test-creator-id",
created_by_name: "test_creator",
icon: "/icon/code.svg",
allow_user_cancel_workspace_jobs: true,
}
export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
@ -446,6 +447,8 @@ export const MockWorkspace: TypesGen.Workspace = {
template_name: MockTemplate.name,
template_icon: MockTemplate.icon,
template_display_name: MockTemplate.display_name,
template_allow_user_cancel_workspace_jobs:
MockTemplate.allow_user_cancel_workspace_jobs,
outdated: false,
owner_id: MockUser.id,
owner_name: MockUser.username,