feat: add activity bumping to template scheduling (#9040)

This commit is contained in:
Jon Ayers 2023-08-22 15:15:13 -05:00 committed by GitHub
parent 6214117d3d
commit 6e41cd1eda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 672 additions and 193 deletions

View File

@ -2385,6 +2385,14 @@ func (q *querier) UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.Conte
return q.db.UpdateTemplateVersionGitAuthProvidersByJobID(ctx, arg)
}
func (q *querier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error {
fetch := func(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) (database.Template, error) {
return q.db.GetTemplateByID(ctx, arg.TemplateID)
}
return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateTemplateWorkspacesLastUsedAt)(ctx, arg)
}
// UpdateUserDeletedByID
// Deprecated: Delete this function in favor of 'SoftDeleteUserByID'. Deletes are
// irreversible.
@ -2663,12 +2671,12 @@ func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWor
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceTTL)(ctx, arg)
}
func (q *querier) UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) error {
fetch := func(ctx context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) (database.Template, error) {
func (q *querier) UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error {
fetch := func(ctx context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) (database.Template, error) {
return q.db.GetTemplateByID(ctx, arg.TemplateID)
}
return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspacesDeletingAtByTemplateID)(ctx, arg)
return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspacesLockedDeletingAtByTemplateID)(ctx, arg)
}
func (q *querier) UpsertAppSecurityKey(ctx context.Context, data string) error {

View File

@ -5199,6 +5199,26 @@ func (q *FakeQuerier) UpdateTemplateVersionGitAuthProvidersByJobID(_ context.Con
return sql.ErrNoRows
}
func (q *FakeQuerier) UpdateTemplateWorkspacesLastUsedAt(_ context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error {
err := validateDatabaseType(arg)
if err != nil {
return err
}
q.mutex.Lock()
defer q.mutex.Unlock()
for i, ws := range q.workspaces {
if ws.TemplateID != arg.TemplateID {
continue
}
ws.LastUsedAt = arg.LastUsedAt
q.workspaces[i] = ws
}
return nil
}
func (q *FakeQuerier) UpdateUserDeletedByID(_ context.Context, params database.UpdateUserDeletedByIDParams) error {
if err := validateDatabaseType(params); err != nil {
return err
@ -5796,7 +5816,7 @@ func (q *FakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW
return sql.ErrNoRows
}
func (q *FakeQuerier) UpdateWorkspacesDeletingAtByTemplateID(_ context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) error {
func (q *FakeQuerier) UpdateWorkspacesLockedDeletingAtByTemplateID(_ context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
@ -5806,9 +5826,21 @@ func (q *FakeQuerier) UpdateWorkspacesDeletingAtByTemplateID(_ context.Context,
}
for i, ws := range q.workspaces {
if ws.TemplateID != arg.TemplateID {
continue
}
if ws.LockedAt.Time.IsZero() {
continue
}
if !arg.LockedAt.IsZero() {
ws.LockedAt = sql.NullTime{
Valid: true,
Time: arg.LockedAt,
}
}
deletingAt := sql.NullTime{
Valid: arg.LockedTtlMs > 0,
}

View File

@ -1453,6 +1453,13 @@ func (m metricsStore) UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.C
return err
}
func (m metricsStore) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error {
start := time.Now()
r0 := m.s.UpdateTemplateWorkspacesLastUsedAt(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateTemplateWorkspacesLastUsedAt").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) UpdateUserDeletedByID(ctx context.Context, arg database.UpdateUserDeletedByIDParams) error {
start := time.Now()
err := m.s.UpdateUserDeletedByID(ctx, arg)
@ -1635,10 +1642,10 @@ func (m metricsStore) UpdateWorkspaceTTL(ctx context.Context, arg database.Updat
return r0
}
func (m metricsStore) UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) error {
func (m metricsStore) UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error {
start := time.Now()
r0 := m.s.UpdateWorkspacesDeletingAtByTemplateID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateWorkspacesDeletingAtByTemplateID").Observe(time.Since(start).Seconds())
r0 := m.s.UpdateWorkspacesLockedDeletingAtByTemplateID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateWorkspacesLockedDeletingAtByTemplateID").Observe(time.Since(start).Seconds())
return r0
}

View File

@ -3062,6 +3062,20 @@ func (mr *MockStoreMockRecorder) UpdateTemplateVersionGitAuthProvidersByJobID(ar
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionGitAuthProvidersByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionGitAuthProvidersByJobID), arg0, arg1)
}
// UpdateTemplateWorkspacesLastUsedAt mocks base method.
func (m *MockStore) UpdateTemplateWorkspacesLastUsedAt(arg0 context.Context, arg1 database.UpdateTemplateWorkspacesLastUsedAtParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateTemplateWorkspacesLastUsedAt", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateTemplateWorkspacesLastUsedAt indicates an expected call of UpdateTemplateWorkspacesLastUsedAt.
func (mr *MockStoreMockRecorder) UpdateTemplateWorkspacesLastUsedAt(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateWorkspacesLastUsedAt", reflect.TypeOf((*MockStore)(nil).UpdateTemplateWorkspacesLastUsedAt), arg0, arg1)
}
// UpdateUserDeletedByID mocks base method.
func (m *MockStore) UpdateUserDeletedByID(arg0 context.Context, arg1 database.UpdateUserDeletedByIDParams) error {
m.ctrl.T.Helper()
@ -3437,18 +3451,18 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceTTL(arg0, arg1 interface{}) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceTTL), arg0, arg1)
}
// UpdateWorkspacesDeletingAtByTemplateID mocks base method.
func (m *MockStore) UpdateWorkspacesDeletingAtByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesDeletingAtByTemplateIDParams) error {
// UpdateWorkspacesLockedDeletingAtByTemplateID mocks base method.
func (m *MockStore) UpdateWorkspacesLockedDeletingAtByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateWorkspacesDeletingAtByTemplateID", arg0, arg1)
ret := m.ctrl.Call(m, "UpdateWorkspacesLockedDeletingAtByTemplateID", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateWorkspacesDeletingAtByTemplateID indicates an expected call of UpdateWorkspacesDeletingAtByTemplateID.
func (mr *MockStoreMockRecorder) UpdateWorkspacesDeletingAtByTemplateID(arg0, arg1 interface{}) *gomock.Call {
// UpdateWorkspacesLockedDeletingAtByTemplateID indicates an expected call of UpdateWorkspacesLockedDeletingAtByTemplateID.
func (mr *MockStoreMockRecorder) UpdateWorkspacesLockedDeletingAtByTemplateID(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesDeletingAtByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesDeletingAtByTemplateID), arg0, arg1)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesLockedDeletingAtByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesLockedDeletingAtByTemplateID), arg0, arg1)
}
// UpsertAppSecurityKey mocks base method.

View File

@ -270,6 +270,7 @@ type sqlcQuerier interface {
UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error
UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error
UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionGitAuthProvidersByJobIDParams) error
UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error
UpdateUserDeletedByID(ctx context.Context, arg UpdateUserDeletedByIDParams) error
UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error
UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error)
@ -297,7 +298,7 @@ type sqlcQuerier interface {
UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error)
UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error
UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDeletingAtByTemplateIDParams) error
UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error
UpsertAppSecurityKey(ctx context.Context, value string) error
// The default proxy is implied and not actually stored in the database.
// So we need to store it's configuration here for display purposes.

View File

@ -9786,6 +9786,24 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar
return i, err
}
const updateTemplateWorkspacesLastUsedAt = `-- name: UpdateTemplateWorkspacesLastUsedAt :exec
UPDATE workspaces
SET
last_used_at = $1::timestamptz
WHERE
template_id = $2
`
type UpdateTemplateWorkspacesLastUsedAtParams struct {
LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"`
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
}
func (q *sqlQuerier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error {
_, err := q.db.ExecContext(ctx, updateTemplateWorkspacesLastUsedAt, arg.LastUsedAt, arg.TemplateID)
return err
}
const updateWorkspace = `-- name: UpdateWorkspace :one
UPDATE
workspaces
@ -9945,23 +9963,28 @@ func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspace
return err
}
const updateWorkspacesDeletingAtByTemplateID = `-- name: UpdateWorkspacesDeletingAtByTemplateID :exec
UPDATE
workspaces
const updateWorkspacesLockedDeletingAtByTemplateID = `-- name: UpdateWorkspacesLockedDeletingAtByTemplateID :exec
UPDATE workspaces
SET
deleting_at = CASE WHEN $1::bigint = 0 THEN NULL ELSE locked_at + interval '1 milliseconds' * $1::bigint END
deleting_at = CASE
WHEN $1::bigint = 0 THEN NULL
WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN ($2::timestamptz) + interval '1 milliseconds' * $1::bigint
ELSE locked_at + interval '1 milliseconds' * $1::bigint
END,
locked_at = CASE WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN $2::timestamptz ELSE locked_at END
WHERE
template_id = $2
template_id = $3
AND
locked_at IS NOT NULL
locked_at IS NOT NULL
`
type UpdateWorkspacesDeletingAtByTemplateIDParams struct {
type UpdateWorkspacesLockedDeletingAtByTemplateIDParams struct {
LockedTtlMs int64 `db:"locked_ttl_ms" json:"locked_ttl_ms"`
LockedAt time.Time `db:"locked_at" json:"locked_at"`
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
}
func (q *sqlQuerier) UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDeletingAtByTemplateIDParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspacesDeletingAtByTemplateID, arg.LockedTtlMs, arg.TemplateID)
func (q *sqlQuerier) UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspacesLockedDeletingAtByTemplateID, arg.LockedTtlMs, arg.LockedAt, arg.TemplateID)
return err
}

View File

@ -512,12 +512,23 @@ AND
workspaces.id = $1
RETURNING workspaces.*;
-- name: UpdateWorkspacesDeletingAtByTemplateID :exec
UPDATE
workspaces
-- name: UpdateWorkspacesLockedDeletingAtByTemplateID :exec
UPDATE workspaces
SET
deleting_at = CASE WHEN @locked_ttl_ms::bigint = 0 THEN NULL ELSE locked_at + interval '1 milliseconds' * @locked_ttl_ms::bigint END
deleting_at = CASE
WHEN @locked_ttl_ms::bigint = 0 THEN NULL
WHEN @locked_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN (@locked_at::timestamptz) + interval '1 milliseconds' * @locked_ttl_ms::bigint
ELSE locked_at + interval '1 milliseconds' * @locked_ttl_ms::bigint
END,
locked_at = CASE WHEN @locked_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN @locked_at::timestamptz ELSE locked_at END
WHERE
template_id = @template_id
template_id = @template_id
AND
locked_at IS NOT NULL;
locked_at IS NOT NULL;
-- name: UpdateTemplateWorkspacesLastUsedAt :exec
UPDATE workspaces
SET
last_used_at = @last_used_at::timestamptz
WHERE
template_id = @template_id;

View File

@ -105,6 +105,18 @@ type TemplateScheduleOptions struct {
// LockedTTL dictates the duration after which locked workspaces will be
// permanently deleted.
LockedTTL time.Duration `json:"locked_ttl"`
// UpdateWorkspaceLastUsedAt updates the template's workspaces'
// last_used_at field. This is useful for preventing updates to the
// templates inactivity_ttl immediately triggering a lock action against
// workspaces whose last_used_at field violates the new template
// inactivity_ttl threshold.
UpdateWorkspaceLastUsedAt bool `json:"update_workspace_last_used_at"`
// UpdateWorkspaceLockedAt updates the template's workspaces'
// locked_at field. This is useful for preventing updates to the
// templates locked_ttl immediately triggering a delete action against
// workspaces whose locked_at field violates the new template locked_ttl
// threshold.
UpdateWorkspaceLockedAt bool `json:"update_workspace_locked_at"`
}
// TemplateScheduleStore provides an interface for retrieving template

View File

@ -622,9 +622,11 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
DaysOfWeek: restartRequirementDaysOfWeekParsed,
Weeks: req.RestartRequirement.Weeks,
},
FailureTTL: failureTTL,
InactivityTTL: inactivityTTL,
LockedTTL: lockedTTL,
FailureTTL: failureTTL,
InactivityTTL: inactivityTTL,
LockedTTL: lockedTTL,
UpdateWorkspaceLastUsedAt: req.UpdateWorkspaceLastUsedAt,
UpdateWorkspaceLockedAt: req.UpdateWorkspaceLockedAt,
})
if err != nil {
return xerrors.Errorf("set template schedule options: %w", err)

View File

@ -192,6 +192,15 @@ type UpdateTemplateMeta struct {
FailureTTLMillis int64 `json:"failure_ttl_ms,omitempty"`
InactivityTTLMillis int64 `json:"inactivity_ttl_ms,omitempty"`
LockedTTLMillis int64 `json:"locked_ttl_ms,omitempty"`
// UpdateWorkspaceLastUsedAt updates the last_used_at field of workspaces
// spawned from the template. This is useful for preventing workspaces being
// immediately locked when updating the inactivity_ttl field to a new, shorter
// value.
UpdateWorkspaceLastUsedAt bool `json:"update_workspace_last_used_at"`
// UpdateWorkspaceLockedAt updates the locked_at field of workspaces spawned
// from the template. This is useful for preventing locked workspaces being immediately
// deleted when updating the locked_ttl field to a new, shorter value.
UpdateWorkspaceLockedAt bool `json:"update_workspace_locked_at"`
}
type TemplateExample struct {

View File

@ -113,11 +113,11 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
}
var template database.Template
err = db.InTx(func(db database.Store) error {
err = db.InTx(func(tx database.Store) error {
ctx, span := tracing.StartSpanWithName(ctx, "(*schedule.EnterpriseTemplateScheduleStore).Set()-InTx()")
defer span.End()
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
err := tx.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
ID: tpl.ID,
UpdatedAt: s.now(),
AllowUserAutostart: opts.UserAutostartEnabled,
@ -134,19 +134,36 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
return xerrors.Errorf("update template schedule: %w", err)
}
var lockedAt time.Time
if opts.UpdateWorkspaceLockedAt {
lockedAt = database.Now()
}
// If we updated the locked_ttl we need to update all the workspaces deleting_at
// to ensure workspaces are being cleaned up correctly. Similarly if we are
// disabling it (by passing 0), then we want to delete nullify the deleting_at
// fields of all the template workspaces.
err = db.UpdateWorkspacesDeletingAtByTemplateID(ctx, database.UpdateWorkspacesDeletingAtByTemplateIDParams{
err = tx.UpdateWorkspacesLockedDeletingAtByTemplateID(ctx, database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams{
TemplateID: tpl.ID,
LockedTtlMs: opts.LockedTTL.Milliseconds(),
LockedAt: lockedAt,
})
if err != nil {
return xerrors.Errorf("update deleting_at of all workspaces for new locked_ttl %q: %w", opts.LockedTTL, err)
}
template, err = db.GetTemplateByID(ctx, tpl.ID)
if opts.UpdateWorkspaceLastUsedAt {
err = tx.UpdateTemplateWorkspacesLastUsedAt(ctx, database.UpdateTemplateWorkspacesLastUsedAtParams{
TemplateID: tpl.ID,
LastUsedAt: database.Now(),
})
if err != nil {
return xerrors.Errorf("update template workspaces last_used_at: %w", err)
}
}
// TODO: update all workspace max_deadlines to be within new bounds
template, err = tx.GetTemplateByID(ctx, tpl.ID)
if err != nil {
return xerrors.Errorf("get updated template schedule: %w", err)
}
@ -154,7 +171,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
// Recalculate max_deadline and deadline for all running workspace
// builds on this template.
if s.UseRestartRequirement.Load() {
err = s.updateWorkspaceBuilds(ctx, db, template)
err = s.updateWorkspaceBuilds(ctx, tx, template)
if err != nil {
return xerrors.Errorf("update workspace builds: %w", err)
}

View File

@ -283,10 +283,11 @@ func TestTemplates(t *testing.T) {
require.Nil(t, unlockedWorkspace.LockedAt)
require.Nil(t, unlockedWorkspace.DeletingAt)
lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID)
require.NotNil(t, lockedWorkspace.LockedAt)
require.NotNil(t, lockedWorkspace.DeletingAt)
require.Equal(t, lockedWorkspace.LockedAt.Add(lockedTTL), *lockedWorkspace.DeletingAt)
updatedLockedWorkspace := coderdtest.MustWorkspace(t, client, lockedWorkspace.ID)
require.NotNil(t, updatedLockedWorkspace.LockedAt)
require.NotNil(t, updatedLockedWorkspace.DeletingAt)
require.Equal(t, updatedLockedWorkspace.LockedAt.Add(lockedTTL), *updatedLockedWorkspace.DeletingAt)
require.Equal(t, updatedLockedWorkspace.LockedAt, lockedWorkspace.LockedAt)
// Disable the locked_ttl on the template, then we can assert that the workspaces
// no longer have a deleting_at field.
@ -307,6 +308,119 @@ func TestTemplates(t *testing.T) {
require.NotNil(t, lockedWorkspace.LockedAt)
require.Nil(t, lockedWorkspace.DeletingAt)
})
t.Run("UpdateLockedAt", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client, user := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAdvancedTemplateScheduling: 1,
},
},
})
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
unlockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
require.Nil(t, unlockedWorkspace.DeletingAt)
require.Nil(t, lockedWorkspace.DeletingAt)
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, unlockedWorkspace.LatestBuild.ID)
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID)
err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{
Lock: true,
})
require.NoError(t, err)
lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID)
require.NotNil(t, lockedWorkspace.LockedAt)
// The deleting_at field should be nil since there is no template locked_ttl set.
require.Nil(t, lockedWorkspace.DeletingAt)
lockedTTL := time.Minute
updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
LockedTTLMillis: lockedTTL.Milliseconds(),
UpdateWorkspaceLockedAt: true,
})
require.NoError(t, err)
require.Equal(t, lockedTTL.Milliseconds(), updated.LockedTTLMillis)
unlockedWorkspace = coderdtest.MustWorkspace(t, client, unlockedWorkspace.ID)
require.Nil(t, unlockedWorkspace.LockedAt)
require.Nil(t, unlockedWorkspace.DeletingAt)
updatedLockedWorkspace := coderdtest.MustWorkspace(t, client, lockedWorkspace.ID)
require.NotNil(t, updatedLockedWorkspace.LockedAt)
require.NotNil(t, updatedLockedWorkspace.DeletingAt)
// Validate that the workspace locked_at value is updated.
require.True(t, updatedLockedWorkspace.LockedAt.After(*lockedWorkspace.LockedAt))
require.Equal(t, updatedLockedWorkspace.LockedAt.Add(lockedTTL), *updatedLockedWorkspace.DeletingAt)
})
t.Run("UpdateLastUsedAt", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
client, user := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAdvancedTemplateScheduling: 1,
},
},
})
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
unlockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
require.Nil(t, unlockedWorkspace.DeletingAt)
require.Nil(t, lockedWorkspace.DeletingAt)
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, unlockedWorkspace.LatestBuild.ID)
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID)
err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{
Lock: true,
})
require.NoError(t, err)
lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID)
require.NotNil(t, lockedWorkspace.LockedAt)
// The deleting_at field should be nil since there is no template locked_ttl set.
require.Nil(t, lockedWorkspace.DeletingAt)
inactivityTTL := time.Minute
updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
InactivityTTLMillis: inactivityTTL.Milliseconds(),
UpdateWorkspaceLastUsedAt: true,
})
require.NoError(t, err)
require.Equal(t, inactivityTTL.Milliseconds(), updated.InactivityTTLMillis)
updatedUnlockedWS := coderdtest.MustWorkspace(t, client, unlockedWorkspace.ID)
require.Nil(t, updatedUnlockedWS.LockedAt)
require.Nil(t, updatedUnlockedWS.DeletingAt)
require.True(t, updatedUnlockedWS.LastUsedAt.After(unlockedWorkspace.LastUsedAt))
updatedLockedWorkspace := coderdtest.MustWorkspace(t, client, lockedWorkspace.ID)
require.NotNil(t, updatedLockedWorkspace.LockedAt)
require.Nil(t, updatedLockedWorkspace.DeletingAt)
// Validate that the workspace locked_at value is updated.
require.Equal(t, updatedLockedWorkspace.LockedAt, lockedWorkspace.LockedAt)
require.True(t, updatedLockedWorkspace.LastUsedAt.After(lockedWorkspace.LastUsedAt))
})
}
func TestTemplateACL(t *testing.T) {

View File

@ -1153,6 +1153,8 @@ export interface UpdateTemplateMeta {
readonly failure_ttl_ms?: number
readonly inactivity_ttl_ms?: number
readonly locked_ttl_ms?: number
readonly update_workspace_last_used_at: boolean
readonly update_workspace_locked_at: boolean
}
// From codersdk/users.go

View File

@ -7,6 +7,9 @@ import {
DialogActionButtonsProps,
} from "../Dialog"
import { ConfirmDialogType } from "../types"
import Checkbox from "@mui/material/Checkbox"
import FormControlLabel from "@mui/material/FormControlLabel"
import { Stack } from "@mui/system"
interface ConfirmDialogTypeConfig {
confirmText: ReactNode
@ -151,3 +154,168 @@ export const ConfirmDialog: FC<PropsWithChildren<ConfirmDialogProps>> = ({
</Dialog>
)
}
export interface ScheduleDialogProps extends ConfirmDialogProps {
readonly inactiveWorkspacesToGoDormant: number
readonly inactiveWorkspacesToGoDormantInWeek: number
readonly dormantWorkspacesToBeDeleted: number
readonly dormantWorkspacesToBeDeletedInWeek: number
readonly updateLockedWorkspaces: (confirm: boolean) => void
readonly updateInactiveWorkspaces: (confirm: boolean) => void
readonly dormantValueChanged: boolean
readonly deletionValueChanged: boolean
}
export const ScheduleDialog: FC<PropsWithChildren<ScheduleDialogProps>> = ({
cancelText,
confirmLoading,
disabled = false,
hideCancel,
onClose,
onConfirm,
type,
open = false,
title,
inactiveWorkspacesToGoDormant,
inactiveWorkspacesToGoDormantInWeek,
dormantWorkspacesToBeDeleted,
dormantWorkspacesToBeDeletedInWeek,
updateLockedWorkspaces,
updateInactiveWorkspaces,
dormantValueChanged,
deletionValueChanged,
}) => {
const styles = useScheduleStyles({ type })
const defaults = CONFIRM_DIALOG_DEFAULTS["delete"]
if (typeof hideCancel === "undefined") {
hideCancel = defaults.hideCancel
}
const showDormancyWarning =
dormantValueChanged &&
(inactiveWorkspacesToGoDormant > 0 ||
inactiveWorkspacesToGoDormantInWeek > 0)
const showDeletionWarning =
deletionValueChanged &&
(dormantWorkspacesToBeDeleted > 0 || dormantWorkspacesToBeDeletedInWeek > 0)
return (
<Dialog
className={styles.dialogWrapper}
onClose={onClose}
open={open}
data-testid="dialog"
>
<div className={styles.dialogContent}>
<h3 className={styles.dialogTitle}>{title}</h3>
<>
{showDormancyWarning && (
<>
<h4>{"Dormancy Threshold"}</h4>
<Stack direction="row" spacing={5}>
<div className={styles.dialogDescription}>{`
This change will result in ${inactiveWorkspacesToGoDormant} workspaces being immediately transitioned to the dormant state and ${inactiveWorkspacesToGoDormantInWeek} over the next seven days. To prevent this, do you want to reset the inactivity period for all template workspaces?`}</div>
<FormControlLabel
sx={{
marginTop: 2,
}}
control={
<Checkbox
size="small"
onChange={(e) => {
updateInactiveWorkspaces(e.target.checked)
}}
/>
}
label="Reset"
/>
</Stack>
</>
)}
{showDeletionWarning && (
<>
<h4>{"Dormancy Auto-Deletion"}</h4>
<Stack direction="row" spacing={5}>
<div
className={styles.dialogDescription}
>{`This change will result in ${dormantWorkspacesToBeDeleted} workspaces being immediately deleted and ${dormantWorkspacesToBeDeletedInWeek} over the next 7 days. To prevent this, do you want to reset the dormancy period for all template workspaces?`}</div>
<FormControlLabel
sx={{
marginTop: 2,
}}
control={
<Checkbox
size="small"
onChange={(e) => {
updateLockedWorkspaces(e.target.checked)
}}
/>
}
label="Reset"
/>
</Stack>
</>
)}
</>
</div>
<DialogActions>
<DialogActionButtons
cancelText={cancelText}
confirmDialog
confirmLoading={confirmLoading}
confirmText="Submit"
disabled={disabled}
onCancel={!hideCancel ? onClose : undefined}
onConfirm={onConfirm || onClose}
type="delete"
/>
</DialogActions>
</Dialog>
)
}
const useScheduleStyles = makeStyles((theme) => ({
dialogWrapper: {
"& .MuiPaper-root": {
background: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
width: "100%",
maxWidth: theme.spacing(125),
},
"& .MuiDialogActions-spacing": {
padding: `0 ${theme.spacing(5)} ${theme.spacing(5)}`,
},
},
dialogContent: {
color: theme.palette.text.secondary,
padding: theme.spacing(5),
},
dialogTitle: {
margin: 0,
marginBottom: theme.spacing(2),
color: theme.palette.text.primary,
fontWeight: 400,
fontSize: theme.spacing(2.5),
},
dialogDescription: {
color: theme.palette.text.secondary,
lineHeight: "160%",
fontSize: 16,
"& strong": {
color: theme.palette.text.primary,
},
"& p:not(.MuiFormHelperText-root)": {
margin: 0,
},
"& > p": {
margin: theme.spacing(1, 0),
},
},
}))

View File

@ -1,6 +1,5 @@
import Button from "@mui/material/Button"
import { makeStyles } from "@mui/styles"
import LockIcon from "@mui/icons-material/Lock"
import { Avatar } from "components/Avatar/Avatar"
import { AgentRow } from "components/Resources/AgentRow"
import {
@ -27,7 +26,7 @@ import {
} from "components/PageHeader/FullWidthPageHeader"
import { TemplateVersionWarnings } from "components/TemplateVersionWarnings/TemplateVersionWarnings"
import { ErrorAlert } from "components/Alert/ErrorAlert"
import { LockedWorkspaceBanner } from "components/WorkspaceDeletion"
import { DormantWorkspaceBanner } from "components/WorkspaceDeletion"
import { useLocalStorage } from "hooks"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import AlertTitle from "@mui/material/AlertTitle"
@ -54,7 +53,7 @@ export interface WorkspaceProps {
handleCancel: () => void
handleSettings: () => void
handleChangeVersion: () => void
handleUnlock: () => void
handleDormantActivate: () => void
isUpdating: boolean
isRestarting: boolean
workspace: TypesGen.Workspace
@ -88,7 +87,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleCancel,
handleSettings,
handleChangeVersion,
handleUnlock,
handleDormantActivate: handleDormantActivate,
workspace,
isUpdating,
isRestarting,
@ -170,19 +169,14 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
<>
<FullWidthPageHeader>
<Stack direction="row" spacing={3} alignItems="center">
{workspace.locked_at ? (
<LockIcon fontSize="large" color="error" />
) : (
<Avatar
size="md"
src={workspace.template_icon}
variant={workspace.template_icon ? "square" : undefined}
fitImage={Boolean(workspace.template_icon)}
>
{workspace.name}
</Avatar>
)}
<Avatar
size="md"
src={workspace.template_icon}
variant={workspace.template_icon ? "square" : undefined}
fitImage={Boolean(workspace.template_icon)}
>
{workspace.name}
</Avatar>
<div>
<PageHeaderTitle>{workspace.name}</PageHeaderTitle>
<PageHeaderSubtitle>{workspace.owner_name}</PageHeaderSubtitle>
@ -211,7 +205,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleCancel={handleCancel}
handleSettings={handleSettings}
handleChangeVersion={handleChangeVersion}
handleUnlock={handleUnlock}
handleDormantActivate={handleDormantActivate}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
@ -262,7 +256,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
</Cond>
<Cond>
{/* <ImpendingDeletionBanner/> determines its own visibility */}
<LockedWorkspaceBanner
<DormantWorkspaceBanner
workspaces={[workspace]}
shouldRedisplayBanner={
getLocal("dismissedWorkspace") !== workspace.id

View File

@ -3,7 +3,6 @@ import BlockIcon from "@mui/icons-material/Block"
import CloudQueueIcon from "@mui/icons-material/CloudQueue"
import CropSquareIcon from "@mui/icons-material/CropSquare"
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline"
import LockOpenIcon from "@mui/icons-material/LockOpen"
import ReplayIcon from "@mui/icons-material/Replay"
import { LoadingButton } from "components/LoadingButton/LoadingButton"
import { FC } from "react"
@ -11,6 +10,7 @@ import BlockOutlined from "@mui/icons-material/BlockOutlined"
import ButtonGroup from "@mui/material/ButtonGroup"
import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"
import { BuildParametersPopover } from "./BuildParametersPopover"
import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew"
interface WorkspaceAction {
loading?: boolean
@ -35,19 +35,19 @@ export const UpdateButton: FC<WorkspaceAction> = ({
)
}
export const UnlockButton: FC<WorkspaceAction> = ({
export const ActivateButton: FC<WorkspaceAction> = ({
handleAction,
loading,
}) => {
return (
<LoadingButton
loading={loading}
loadingIndicator="Unlocking..."
loadingIndicator="Activating..."
loadingPosition="start"
startIcon={<LockOpenIcon />}
startIcon={<PowerSettingsNewIcon />}
onClick={handleAction}
>
Unlock
Activate
</LoadingButton>
)
}

View File

@ -12,7 +12,7 @@ import {
StopButton,
RestartButton,
UpdateButton,
UnlockButton,
ActivateButton,
} from "./Buttons"
import {
ButtonMapping,
@ -34,7 +34,7 @@ export interface WorkspaceActionsProps {
handleCancel: () => void
handleSettings: () => void
handleChangeVersion: () => void
handleUnlock: () => void
handleDormantActivate: () => void
isUpdating: boolean
isRestarting: boolean
children?: ReactNode
@ -51,7 +51,7 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
handleCancel,
handleSettings,
handleChangeVersion,
handleUnlock,
handleDormantActivate: handleDormantActivate,
isUpdating,
isRestarting,
canChangeVersions,
@ -96,9 +96,11 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
[ButtonTypesEnum.canceling]: <DisabledButton label="Canceling..." />,
[ButtonTypesEnum.deleted]: <DisabledButton label="Deleted" />,
[ButtonTypesEnum.pending]: <ActionLoadingButton label="Pending..." />,
[ButtonTypesEnum.unlock]: <UnlockButton handleAction={handleUnlock} />,
[ButtonTypesEnum.unlocking]: (
<UnlockButton loading handleAction={handleUnlock} />
[ButtonTypesEnum.activate]: (
<ActivateButton handleAction={handleDormantActivate} />
),
[ButtonTypesEnum.activating]: (
<ActivateButton loading handleAction={handleDormantActivate} />
),
}

View File

@ -12,8 +12,8 @@ export enum ButtonTypesEnum {
deleting = "deleting",
update = "update",
updating = "updating",
unlock = "lock",
unlocking = "unlocking",
activate = "activate",
activating = "activating",
// disabled buttons
canceling = "canceling",
deleted = "deleted",
@ -36,7 +36,7 @@ export const actionsByWorkspaceStatus = (
): WorkspaceAbilities => {
if (workspace.locked_at) {
return {
actions: [ButtonTypesEnum.unlock],
actions: [ButtonTypesEnum.activate],
canCancel: false,
canAcceptJobs: false,
}

View File

@ -10,7 +10,7 @@ export enum Count {
Multiple,
}
export const LockedWorkspaceBanner = ({
export const DormantWorkspaceBanner = ({
workspaces,
onDismiss,
shouldRedisplayBanner,
@ -61,18 +61,18 @@ export const LockedWorkspaceBanner = ({
hasDeletionScheduledWorkspaces.deleting_at &&
hasDeletionScheduledWorkspaces.locked_at
) {
return `This workspace has been locked since ${formatDistanceToNow(
return `This workspace has been dormant for ${formatDistanceToNow(
Date.parse(hasDeletionScheduledWorkspaces.locked_at),
)} and is scheduled to be deleted at ${formatDate(
)} and is scheduled to be deleted on ${formatDate(
hasDeletionScheduledWorkspaces.deleting_at,
)} . To keep it you must unlock the workspace.`
)} . To keep it you must activate the workspace.`
} else if (hasLockedWorkspaces && hasLockedWorkspaces.locked_at) {
return `This workspace has been locked since ${formatDate(
hasLockedWorkspaces.locked_at,
return `This workspace has been dormant for ${formatDistanceToNow(
Date.parse(hasLockedWorkspaces.locked_at),
)}
and cannot be interacted
with. Locked workspaces are eligible for
permanent deletion. To prevent deletion, unlock
with. Dormant workspaces are eligible for
permanent deletion. To prevent deletion, activate
the workspace.`
}
}
@ -92,8 +92,8 @@ export const LockedWorkspaceBanner = ({
>
workspaces
</Link>{" "}
that may be deleted soon due to inactivity. Unlock the workspaces you
wish to retain.
that may be deleted soon due to inactivity. Activate the workspaces
you wish to retain.
</>
)}
</Alert>

View File

@ -4,10 +4,7 @@ import { FC, PropsWithChildren } from "react"
import { makeStyles } from "@mui/styles"
import { combineClasses } from "utils/combineClasses"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import {
LockedBadge,
ImpendingDeletionText,
} from "components/WorkspaceDeletion"
import { ImpendingDeletionText } from "components/WorkspaceDeletion"
import { getDisplayWorkspaceStatus } from "utils/workspace"
import Tooltip, { TooltipProps, tooltipClasses } from "@mui/material/Tooltip"
import { styled } from "@mui/material/styles"
@ -28,10 +25,6 @@ export const WorkspaceStatusBadge: FC<
)
return (
<ChooseOne>
{/* <ImpendingDeletionBadge/> determines its own visibility */}
<Cond condition={Boolean(LockedBadge({ workspace }))}>
<LockedBadge workspace={workspace} />
</Cond>
<Cond condition={workspace.latest_build.status === "failed"}>
<FailureTooltip
title={

View File

@ -22,12 +22,12 @@
"failureTTLHelperText_zero": "Coder will not automatically stop failed workspaces",
"failureTTLHelperText_one": "Coder will attempt to stop failed workspaces after {{count}} day.",
"failureTTLHelperText_other": "Coder will attempt to stop failed workspaces after {{count}} days.",
"inactivityTTLHelperText_zero": "Coder will not automatically lock inactive workspaces",
"inactivityTTLHelperText_one": "Coder will automatically lock inactive workspaces after {{count}} day.",
"inactivityTTLHelperText_other": "Coder will automatically lock inactive workspaces after {{count}} days.",
"lockedTTLHelperText_zero": "Coder will not automatically delete locked workspaces",
"lockedTTLHelperText_one": "Coder will automatically delete locked workspaces after {{count}} day.",
"lockedTTLHelperText_other": "Coder will automatically delete locked workspaces after {{count}} days.",
"dormancyThresholdHelperText_zero": "Coder will not mark workspaces as dormant.",
"dormancyThresholdHelperText_one": "Coder will mark workspaces as dormant after {{count}} day without user connections.",
"dormancyThresholdHelperText_other": "Coder will mark workspaces as dormant after {{count}} days without user connections.",
"dormancyAutoDeletionHelperText_zero": "Coder will not automatically delete dormant workspaces.",
"dormancyAutoDeletionHelperText_one": "Coder will automatically delete dormant workspaces after {{count}} day.",
"dormancyAutoDeletionHelperText_other": "Coder will automatically delete dormant workspaces after {{count}} days.",
"allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.",
"allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases.",
"allowUsersCancelHelperText": "If checked, users may be able to corrupt their workspace.",

View File

@ -72,6 +72,8 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
icon: template.icon,
allow_user_cancel_workspace_jobs:
template.allow_user_cancel_workspace_jobs,
update_workspace_last_used_at: false,
update_workspace_locked_at: false,
},
validationSchema,
onSubmit,

View File

@ -33,6 +33,8 @@ const validFormValues: FormValues = {
failure_ttl_ms: 0,
inactivity_ttl_ms: 0,
locked_ttl_ms: 0,
update_workspace_last_used_at: false,
update_workspace_locked_at: false,
}
const renderTemplateSettingsPage = async () => {

View File

@ -16,7 +16,6 @@ import Link from "@mui/material/Link"
import Checkbox from "@mui/material/Checkbox"
import FormControlLabel from "@mui/material/FormControlLabel"
import Switch from "@mui/material/Switch"
import { DeleteLockedDialog, InactivityDialog } from "./InactivityDialog"
import {
useWorkspacesToBeLocked,
useWorkspacesToBeDeleted,
@ -24,6 +23,7 @@ import {
import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers"
import { TTLHelperText } from "./TTLHelperText"
import { docs } from "utils/docs"
import { ScheduleDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
const MS_HOUR_CONVERSION = 3600000
const MS_DAY_CONVERSION = 86400000
@ -87,21 +87,30 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
allowAdvancedScheduling && Boolean(template.inactivity_ttl_ms),
locked_cleanup_enabled:
allowAdvancedScheduling && Boolean(template.locked_ttl_ms),
update_workspace_last_used_at: false,
update_workspace_locked_at: false,
},
validationSchema,
onSubmit: () => {
if (
const dormancyChanged =
form.initialValues.inactivity_ttl_ms !== form.values.inactivity_ttl_ms
const deletionChanged =
form.initialValues.locked_ttl_ms !== form.values.locked_ttl_ms
const dormancyScheduleChanged =
form.values.inactivity_cleanup_enabled &&
workspacesToBeLockedToday &&
workspacesToBeLockedToday.length > 0
) {
setIsInactivityDialogOpen(true)
} else if (
form.values.locked_cleanup_enabled &&
workspacesToBeDeletedToday &&
workspacesToBeDeletedToday.length > 0
) {
setIsLockedDialogOpen(true)
dormancyChanged &&
workspacesToDormancyInWeek &&
workspacesToDormancyInWeek.length > 0
const deletionScheduleChanged =
form.values.inactivity_cleanup_enabled &&
deletionChanged &&
workspacesToBeDeletedInWeek &&
workspacesToBeDeletedInWeek.length > 0
if (dormancyScheduleChanged || deletionScheduleChanged) {
setIsScheduleDialogOpen(true)
} else {
submitValues()
}
@ -115,18 +124,44 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
const { t } = useTranslation("templateSettingsPage")
const styles = useStyles()
const workspacesToBeLockedToday = useWorkspacesToBeLocked(
template,
form.values,
)
const workspacesToBeDeletedToday = useWorkspacesToBeDeleted(
const now = new Date()
const weekFromNow = new Date(now)
weekFromNow.setDate(now.getDate() + 7)
const workspacesToDormancyNow = useWorkspacesToBeLocked(
template,
form.values,
now,
)
const [isInactivityDialogOpen, setIsInactivityDialogOpen] =
const workspacesToDormancyInWeek = useWorkspacesToBeLocked(
template,
form.values,
weekFromNow,
)
const workspacesToBeDeletedNow = useWorkspacesToBeDeleted(
template,
form.values,
now,
)
const workspacesToBeDeletedInWeek = useWorkspacesToBeDeleted(
template,
form.values,
weekFromNow,
)
const showScheduleDialog =
workspacesToDormancyNow &&
workspacesToBeDeletedNow &&
workspacesToDormancyInWeek &&
workspacesToBeDeletedInWeek &&
(workspacesToDormancyInWeek.length > 0 ||
workspacesToBeDeletedInWeek.length > 0)
const [isScheduleDialogOpen, setIsScheduleDialogOpen] =
useState<boolean>(false)
const [isLockedDialogOpen, setIsLockedDialogOpen] = useState<boolean>(false)
const submitValues = () => {
// on submit, convert from hours => ms
@ -149,6 +184,8 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
allow_user_autostart: form.values.allow_user_autostart,
allow_user_autostop: form.values.allow_user_autostop,
update_workspace_last_used_at: form.values.update_workspace_last_used_at,
update_workspace_locked_at: form.values.update_workspace_locked_at,
})
}
@ -345,25 +382,25 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
</FormFields>
</FormSection>
<FormSection
title="Inactivity TTL"
description="When enabled, Coder will lock workspaces that have not been accessed after a specified number of days."
title="Dormancy Threshold"
description="When enabled, Coder will mark workspaces as dormant after a period of time with no connections. Dormant workspaces can be auto-deleted (see below) or manually reviewed by the workspace owner or admins."
>
<FormFields>
<FormControlLabel
control={
<Switch
name="inactivityCleanupEnabled"
name="dormancyThreshold"
checked={form.values.inactivity_cleanup_enabled}
onChange={handleToggleInactivityCleanup}
/>
}
label="Enable Inactivity TTL"
label="Enable Dormancy Threshold"
/>
<TextField
{...getFieldHelpers(
"inactivity_ttl_ms",
<TTLHelperText
translationName="inactivityTTLHelperText"
translationName="dormancyThresholdHelperText"
ttl={form.values.inactivity_ttl_ms}
/>,
)}
@ -372,58 +409,88 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
}
fullWidth
inputProps={{ min: 0, step: "any" }}
label="Time until cleanup (days)"
label="Time until dormant (days)"
type="number"
/>
</FormFields>
</FormSection>
<FormSection
title="Deletion Grace Period"
description="When enabled, Coder will permanently delete workspaces that have been locked for a specified number of days."
title="Dormancy Auto-Deletion"
description="When enabled, Coder will permanently delete dormant workspaces after a period of time. Once a workspace is deleted it cannot be recovered."
>
<FormFields>
<FormControlLabel
control={
<Switch
name="lockedCleanupEnabled"
name="dormancyAutoDeletion"
checked={form.values.locked_cleanup_enabled}
onChange={handleToggleLockedCleanup}
/>
}
label="Enable Locked TTL"
label="Enable Dormancy Auto-Deletion"
/>
<TextField
{...getFieldHelpers(
"locked_ttl_ms",
<TTLHelperText
translationName="lockedTTLHelperText"
translationName="dormancyAutoDeletionHelperText"
ttl={form.values.locked_ttl_ms}
/>,
)}
disabled={isSubmitting || !form.values.locked_cleanup_enabled}
fullWidth
inputProps={{ min: 0, step: "any" }}
label="Time until cleanup (days)"
label="Time until deletion (days)"
type="number"
/>
</FormFields>
</FormSection>
</>
)}
{workspacesToBeLockedToday && workspacesToBeLockedToday.length > 0 && (
<InactivityDialog
submitValues={submitValues}
isInactivityDialogOpen={isInactivityDialogOpen}
setIsInactivityDialogOpen={setIsInactivityDialogOpen}
workspacesToBeLockedToday={workspacesToBeLockedToday?.length ?? 0}
/>
)}
{workspacesToBeDeletedToday && workspacesToBeDeletedToday.length > 0 && (
<DeleteLockedDialog
submitValues={submitValues}
isLockedDialogOpen={isLockedDialogOpen}
setIsLockedDialogOpen={setIsLockedDialogOpen}
workspacesToBeDeletedToday={workspacesToBeDeletedToday?.length ?? 0}
{showScheduleDialog && (
<ScheduleDialog
onConfirm={() => {
submitValues()
setIsScheduleDialogOpen(false)
// These fields are request-scoped so they should be reset
// after every submission.
form
.setFieldValue("update_workspace_locked_at", false)
.catch((error) => {
throw error
})
form
.setFieldValue("update_workspace_last_used_at", false)
.catch((error) => {
throw error
})
}}
inactiveWorkspacesToGoDormant={workspacesToDormancyNow.length}
inactiveWorkspacesToGoDormantInWeek={
workspacesToDormancyInWeek.length - workspacesToDormancyNow.length
}
dormantWorkspacesToBeDeleted={workspacesToBeDeletedNow.length}
dormantWorkspacesToBeDeletedInWeek={
workspacesToBeDeletedInWeek.length - workspacesToBeDeletedNow.length
}
open={isScheduleDialogOpen}
onClose={() => {
setIsScheduleDialogOpen(false)
}}
title="Workspace Scheduling"
updateLockedWorkspaces={(update: boolean) =>
form.setFieldValue("update_workspace_locked_at", update)
}
updateInactiveWorkspaces={(update: boolean) =>
form.setFieldValue("update_workspace_last_used_at", update)
}
dormantValueChanged={
form.initialValues.inactivity_ttl_ms !==
form.values.inactivity_ttl_ms
}
deletionValueChanged={
form.initialValues.locked_ttl_ms !== form.values.locked_ttl_ms
}
/>
)}

View File

@ -51,10 +51,10 @@ export const getValidationSchema = (): Yup.AnyObjectSchema =>
},
),
inactivity_ttl_ms: Yup.number()
.min(0, "Inactivity cleanup days must not be less than 0.")
.min(0, "Dormancy threshold days must not be less than 0.")
.test(
"positive-if-enabled",
"Inactivity cleanup days must be greater than zero when enabled.",
"Dormancy threshold days must be greater than zero when enabled.",
function (value) {
const parent = this.parent as TemplateScheduleFormValues
if (parent.inactivity_cleanup_enabled) {
@ -65,10 +65,10 @@ export const getValidationSchema = (): Yup.AnyObjectSchema =>
},
),
locked_ttl_ms: Yup.number()
.min(0, "Locked cleanup days must not be less than 0.")
.min(0, "Dormancy auto-deletion days must not be less than 0.")
.test(
"positive-if-enabled",
"Locked cleanup days must be greater than zero when enabled.",
"Dormancy auto-deletion days must be greater than zero when enabled.",
function (value) {
const parent = this.parent as TemplateScheduleFormValues
if (parent.locked_cleanup_enabled) {

View File

@ -1,23 +1,20 @@
import { useQuery } from "@tanstack/react-query"
import { getWorkspaces } from "api/api"
import { compareAsc } from "date-fns"
import { Workspace, Template } from "api/typesGenerated"
import { TemplateScheduleFormValues } from "./formHelpers"
import { useWorkspacesData } from "pages/WorkspacesPage/data"
export const useWorkspacesToBeLocked = (
template: Template,
formValues: TemplateScheduleFormValues,
fromDate: Date,
) => {
const { data: workspacesData } = useQuery({
queryKey: ["workspaces"],
queryFn: () =>
getWorkspaces({
q: "template:" + template.name,
}),
enabled: formValues.inactivity_cleanup_enabled,
const { data } = useWorkspacesData({
page: 0,
limit: 0,
query: "template:" + template.name,
})
return workspacesData?.workspaces?.filter((workspace: Workspace) => {
return data?.workspaces?.filter((workspace: Workspace) => {
if (!formValues.inactivity_ttl_ms) {
return
}
@ -28,38 +25,38 @@ export const useWorkspacesToBeLocked = (
const proposedLocking = new Date(
new Date(workspace.last_used_at).getTime() +
formValues.inactivity_ttl_ms * 86400000,
formValues.inactivity_ttl_ms * DayInMS,
)
if (compareAsc(proposedLocking, new Date()) < 1) {
if (compareAsc(proposedLocking, fromDate) < 1) {
return workspace
}
})
}
const DayInMS = 86400000
export const useWorkspacesToBeDeleted = (
template: Template,
formValues: TemplateScheduleFormValues,
fromDate: Date,
) => {
const { data: workspacesData } = useQuery({
queryKey: ["workspaces"],
queryFn: () =>
getWorkspaces({
q: "template:" + template.name,
}),
enabled: formValues.locked_cleanup_enabled,
const { data } = useWorkspacesData({
page: 0,
limit: 0,
query: "template:" + template.name + " locked_at:1970-01-01",
})
return workspacesData?.workspaces?.filter((workspace: Workspace) => {
return data?.workspaces?.filter((workspace: Workspace) => {
if (!workspace.locked_at || !formValues.locked_ttl_ms) {
return false
}
const proposedLocking = new Date(
new Date(workspace.locked_at).getTime() +
formValues.locked_ttl_ms * 86400000,
formValues.locked_ttl_ms * DayInMS,
)
if (compareAsc(proposedLocking, new Date()) < 1) {
if (compareAsc(proposedLocking, fromDate) < 1) {
return workspace
}
})

View File

@ -23,6 +23,8 @@ const validFormValues = {
failure_ttl_ms: 7,
inactivity_ttl_ms: 180,
locked_ttl_ms: 30,
update_workspace_last_used_at: false,
update_workspace_locked_at: false,
}
const renderTemplateSchedulePage = async () => {
@ -63,12 +65,12 @@ const fillAndSubmitForm = async ({
await user.type(failureTtlField, failure_ttl_ms.toString())
const inactivityTtlField = screen.getByRole("checkbox", {
name: /Inactivity TTL/i,
name: /Dormancy Threshold/i,
})
await user.type(inactivityTtlField, inactivity_ttl_ms.toString())
const lockedTtlField = screen.getByRole("checkbox", {
name: /Locked TTL/i,
name: /Dormancy Auto-Deletion/i,
})
await user.type(lockedTtlField, locked_ttl_ms.toString())
@ -123,7 +125,7 @@ describe("TemplateSchedulePage", () => {
)
})
test("failure, inactivity, and locked ttl converted to and from days", async () => {
test("failure, dormancy, and dormancy auto-deletion converted to and from days", async () => {
await renderTemplateSchedulePage()
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
@ -237,11 +239,11 @@ describe("TemplateSchedulePage", () => {
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).toThrowError(
"Inactivity cleanup days must not be less than 0.",
"Dormancy threshold days must not be less than 0.",
)
})
it("allows a locked ttl of 7 days", () => {
it("allows a dormancy ttl of 7 days", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
locked_ttl_ms: 86400000 * 7,
@ -250,7 +252,7 @@ describe("TemplateSchedulePage", () => {
expect(validate).not.toThrowError()
})
it("allows a locked ttl of 0", () => {
it("allows a dormancy ttl of 0", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
locked_ttl_ms: 0,
@ -266,7 +268,7 @@ describe("TemplateSchedulePage", () => {
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).toThrowError(
"Locked cleanup days must not be less than 0.",
"Dormancy auto-deletion days must not be less than 0.",
)
})
})

View File

@ -176,7 +176,7 @@ export const WorkspaceReadyPage = ({
handleChangeVersion={() => {
setChangeVersionDialogOpen(true)
}}
handleUnlock={() => workspaceSend({ type: "UNLOCK" })}
handleDormantActivate={() => workspaceSend({ type: "ACTIVATE" })}
resources={workspace.latest_build.resources}
builds={builds}
canUpdateWorkspace={canUpdateWorkspace}

View File

@ -14,7 +14,7 @@ import { Stack } from "components/Stack/Stack"
import { WorkspaceHelpTooltip } from "components/Tooltips"
import { WorkspacesTable } from "pages/WorkspacesPage/WorkspacesTable"
import { useLocalStorage } from "hooks"
import { LockedWorkspaceBanner, Count } from "components/WorkspaceDeletion"
import { DormantWorkspaceBanner, Count } from "components/WorkspaceDeletion"
import { ErrorAlert } from "components/Alert/ErrorAlert"
import { WorkspacesFilter } from "./filter/filter"
import { hasError, isApiValidationError } from "api/errors"
@ -101,7 +101,7 @@ export const WorkspacesPageView: FC<
<ErrorAlert error={error} />
</Maybe>
{/* <ImpendingDeletionBanner/> determines its own visibility */}
<LockedWorkspaceBanner
<DormantWorkspaceBanner
workspaces={lockedWorkspaces}
shouldRedisplayBanner={hasLockedWorkspace}
onDismiss={() =>

View File

@ -96,7 +96,7 @@ export type WorkspaceEvent =
| { type: "INCREASE_DEADLINE"; hours: number }
| { type: "DECREASE_DEADLINE"; hours: number }
| { type: "RETRY_BUILD" }
| { type: "UNLOCK" }
| { type: "ACTIVATE" }
export const checks = {
readWorkspace: "readWorkspace",
@ -171,7 +171,7 @@ export const workspaceMachine = createMachine(
cancelWorkspace: {
data: Types.Message
}
unlockWorkspace: {
activateWorkspace: {
data: Types.Message
}
listening: {
@ -264,7 +264,7 @@ export const workspaceMachine = createMachine(
actions: ["enableDebugMode"],
},
],
UNLOCK: "requestingUnlock",
ACTIVATE: "requestingActivate",
},
},
askingDelete: {
@ -410,15 +410,15 @@ export const workspaceMachine = createMachine(
],
},
},
requestingUnlock: {
requestingActivate: {
entry: ["clearBuildError"],
invoke: {
src: "unlockWorkspace",
id: "unlockWorkspace",
src: "activateWorkspace",
id: "activateWorkspace",
onDone: "idle",
onError: {
target: "idle",
actions: ["displayUnlockError"],
actions: ["displayActivateError"],
},
},
},
@ -576,8 +576,8 @@ export const workspaceMachine = createMachine(
)
displayError(message)
},
displayUnlockError: (_, { data }) => {
const message = getErrorMessage(data, "Error unlocking workspace.")
displayActivateError: (_, { data }) => {
const message = getErrorMessage(data, "Error activate workspace.")
displayError(message)
},
assignMissedParameters: assign({
@ -695,16 +695,16 @@ export const workspaceMachine = createMachine(
throw Error("Cannot cancel workspace without build id")
}
},
unlockWorkspace: (context) => async (send) => {
activateWorkspace: (context) => async (send) => {
if (context.workspace) {
const unlockWorkspacePromise = await API.updateWorkspaceLock(
const activateWorkspacePromise = await API.updateWorkspaceLock(
context.workspace.id,
false,
)
send({ type: "REFRESH_WORKSPACE", data: unlockWorkspacePromise })
return unlockWorkspacePromise
send({ type: "REFRESH_WORKSPACE", data: activateWorkspacePromise })
return activateWorkspacePromise
} else {
throw Error("Cannot unlock workspace without workspace id")
throw Error("Cannot activate workspace without workspace id")
}
},
listening: (context) => (send) => {