diff --git a/Makefile b/Makefile index 72e44308c6f03..ba4c616e3e2f6 100644 --- a/Makefile +++ b/Makefile @@ -717,6 +717,7 @@ test-postgres-docker: --name test-postgres-docker \ --restart no \ --detach \ + -v ~/.coder/data:/var/lib/postgresql/data \ gcr.io/coder-dev-1/postgres:13 \ -c shared_buffers=1GB \ -c work_mem=1GB \ diff --git a/cli/create.go b/cli/create.go index 1a2492374a186..dfab3f22662c4 100644 --- a/cli/create.go +++ b/cli/create.go @@ -21,10 +21,11 @@ import ( func (r *RootCmd) create() *clibase.Cmd { var ( - templateName string - startAt string - stopAfter time.Duration - workspaceName string + templateName string + startAt string + stopAfter time.Duration + defaultTTLBump time.Duration + workspaceName string parameterFlags workspaceParameterFlags autoUpdates string @@ -199,12 +200,17 @@ func (r *RootCmd) create() *clibase.Cmd { if stopAfter > 0 { ttlMillis = ptr.Ref(stopAfter.Milliseconds()) } + var ttlBumpMillis *int64 + if defaultTTLBump.Milliseconds() > 0 { + ttlBumpMillis = ptr.Ref(defaultTTLBump.Milliseconds()) + } workspace, err := client.CreateWorkspace(inv.Context(), organization.ID, workspaceOwner, codersdk.CreateWorkspaceRequest{ TemplateVersionID: templateVersionID, Name: workspaceName, AutostartSchedule: schedSpec, TTLMillis: ttlMillis, + TTLBumpMillis: ttlBumpMillis, RichParameterValues: richParameters, AutomaticUpdates: codersdk.AutomaticUpdates(autoUpdates), }) @@ -246,6 +252,12 @@ func (r *RootCmd) create() *clibase.Cmd { Description: "Specify a duration after which the workspace should shut down (e.g. 8h).", Value: clibase.DurationOf(&stopAfter), }, + clibase.Option{ + Flag: "activity-bump", + Description: "Specify a default amount of time to bump the deadline for the workspaces based on workspace activity. By default, activity will extend the deadline for a workspace by the 'stop-after' amount.", + Default: "0", + Value: clibase.DurationOf(&defaultTTLBump), + }, clibase.Option{ Flag: "automatic-updates", Env: "CODER_WORKSPACE_AUTOMATIC_UPDATES", diff --git a/cli/create_test.go b/cli/create_test.go index 42b526d404cfc..84b35ed948410 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -39,6 +39,7 @@ func TestCreate(t *testing.T) { "--template", template.Name, "--start-at", "9:30AM Mon-Fri US/Central", "--stop-after", "8h", + "--activity-bump", "1h", "--automatic-updates", "always", } inv, root := clitest.New(t, args...) @@ -75,6 +76,7 @@ func TestCreate(t *testing.T) { if assert.NotNil(t, ws.TTLMillis) { assert.Equal(t, *ws.TTLMillis, 8*time.Hour.Milliseconds()) } + assert.Equal(t, ws.TTLBumpMillis, 1*time.Hour.Milliseconds()) assert.Equal(t, codersdk.AutomaticUpdatesAlways, ws.AutomaticUpdates) } }) diff --git a/cli/templatecreate.go b/cli/templatecreate.go index da0793121d949..7aef2b729ce00 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -31,10 +31,11 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { disableEveryone bool requireActiveVersion bool - defaultTTL time.Duration - failureTTL time.Duration - inactivityTTL time.Duration - maxTTL time.Duration + defaultTTL time.Duration + defaultTTLBump time.Duration + failureTTL time.Duration + inactivityTTL time.Duration + maxTTL time.Duration uploadFlags templateUploadFlags ) @@ -156,6 +157,7 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { Name: templateName, VersionID: job.ID, DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()), + DefaultTTLBumpMillis: ptr.Ref(defaultTTLBump.Milliseconds()), FailureTTLMillis: ptr.Ref(failureTTL.Milliseconds()), MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), TimeTilDormantMillis: ptr.Ref(inactivityTTL.Milliseconds()), @@ -213,6 +215,12 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { Default: "24h", Value: clibase.DurationOf(&defaultTTL), }, + { + Flag: "default-activity-bump", + Description: "Specify a default amount of time to bump the deadline for the workspaces based on workspace activity. By default, activity will extend the deadline for a workspace by the 'default-ttl' amount.", + Default: "0", + Value: clibase.DurationOf(&defaultTTLBump), + }, { Flag: "failure-ttl", Description: "Specify a failure TTL for workspaces created from this template. It is the amount of time after a failed \"start\" build before coder automatically schedules a \"stop\" build to cleanup.This licensed feature's default is 0h (off). Maps to \"Failure cleanup\"in the UI.", diff --git a/cli/templatecreate_test.go b/cli/templatecreate_test.go index ec1720ba2a6a4..dc32d11f2193c 100644 --- a/cli/templatecreate_test.go +++ b/cli/templatecreate_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,6 +14,7 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" @@ -72,8 +74,9 @@ func TestTemplateCreate(t *testing.T) { t.Parallel() t.Run("Create", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - coderdtest.CreateFirstUser(t, client) + rootClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, rootClient) + client, _ := coderdtest.CreateAnotherUser(t, rootClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) args := []string{ "templates", @@ -82,12 +85,13 @@ func TestTemplateCreate(t *testing.T) { "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--default-ttl", "24h", + "--default-activity-bump", "1h", } inv, root := clitest.New(t, args...) clitest.SetupConfig(t, client, root) pty := ptytest.New(t).Attach(inv) - clitest.Start(t, inv) + waitor := clitest.StartWithWaiter(t, inv) matches := []struct { match string @@ -104,6 +108,14 @@ func TestTemplateCreate(t *testing.T) { pty.WriteLine(m.write) } } + waitor.RequireSuccess() + + ctx := testutil.Context(t, testutil.WaitShort) + tpl, err := client.TemplateByName(ctx, owner.OrganizationID, "my-template") + require.NoError(t, err) + + require.EqualValues(t, (24 * time.Hour).Milliseconds(), tpl.DefaultTTLMillis) + require.EqualValues(t, time.Hour.Milliseconds(), tpl.DefaultTTLBumpMillis) }) t.Run("CreateNoLockfile", func(t *testing.T) { t.Parallel() diff --git a/cli/templateedit.go b/cli/templateedit.go index 1c17ec52bcab3..b71e28addee65 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -22,6 +22,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { description string icon string defaultTTL time.Duration + defaultTTLBump time.Duration maxTTL time.Duration autostopRequirementDaysOfWeek []string autostopRequirementWeeks int64 @@ -120,12 +121,13 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { // NOTE: coderd will ignore empty fields. req := codersdk.UpdateTemplateMeta{ - Name: name, - DisplayName: displayName, - Description: description, - Icon: icon, - DefaultTTLMillis: defaultTTL.Milliseconds(), - MaxTTLMillis: maxTTL.Milliseconds(), + Name: name, + DisplayName: displayName, + Description: description, + Icon: icon, + DefaultTTLMillis: defaultTTL.Milliseconds(), + DefaultTTLBumpMillis: defaultTTLBump.Milliseconds(), + MaxTTLMillis: maxTTL.Milliseconds(), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ DaysOfWeek: autostopRequirementDaysOfWeek, Weeks: autostopRequirementWeeks, @@ -176,6 +178,12 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Description: "Edit the template default time before shutdown - workspaces created from this template default to this value. Maps to \"Default autostop\" in the UI.", Value: clibase.DurationOf(&defaultTTL), }, + { + Flag: "default-activity-bump", + Description: "Specify a default amount of time to bump the deadline for the workspaces based on workspace activity. By default, activity will extend the deadline for a workspace by the 'default-ttl' amount.", + Default: "0", + Value: clibase.DurationOf(&defaultTTLBump), + }, { Flag: "max-ttl", Description: "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting, regardless of user activity. This is an enterprise-only feature. Maps to \"Max lifetime\" in the UI.", diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index de2e52894a444..28526eea3a42f 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -44,6 +44,7 @@ func TestTemplateEdit(t *testing.T) { desc := "lorem ipsum dolor sit amet et cetera" icon := "/icon/new-icon.png" defaultTTL := 12 * time.Hour + defaultTTLBump := time.Hour allowUserCancelWorkspaceJobs := false cmdArgs := []string{ @@ -55,6 +56,7 @@ func TestTemplateEdit(t *testing.T) { "--description", desc, "--icon", icon, "--default-ttl", defaultTTL.String(), + "--default-activity-bump", defaultTTLBump.String(), "--allow-user-cancel-workspace-jobs=" + strconv.FormatBool(allowUserCancelWorkspaceJobs), } inv, root := clitest.New(t, cmdArgs...) @@ -73,6 +75,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, defaultTTLBump.Milliseconds(), updated.DefaultTTLBumpMillis) assert.Equal(t, allowUserCancelWorkspaceJobs, updated.AllowUserCancelWorkspaceJobs) }) t.Run("FirstEmptyThenNotModified", func(t *testing.T) { diff --git a/cli/templates.go b/cli/templates.go index 4f5b4f8f36d0b..452e57fbd1294 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -95,6 +95,7 @@ type templateTableRow struct { ActiveVersionID uuid.UUID `json:"-" table:"active version id"` UsedBy string `json:"-" table:"used by"` DefaultTTL time.Duration `json:"-" table:"default ttl"` + DefaultTTLBump time.Duration `json:"-" table:"default ttl bump"` } // templateToRows converts a list of templates to a list of templateTableRow for @@ -111,7 +112,8 @@ func templatesToRows(templates ...codersdk.Template) []templateTableRow { Provisioner: template.Provisioner, ActiveVersionID: template.ActiveVersionID, UsedBy: pretty.Sprint(cliui.DefaultStyles.Fuchsia, formatActiveDevelopers(template.ActiveUserCount)), - DefaultTTL: (time.Duration(template.DefaultTTLMillis) * time.Millisecond), + DefaultTTL: time.Duration(template.DefaultTTLMillis) * time.Millisecond, + DefaultTTLBump: time.Duration(template.DefaultTTLBumpMillis) * time.Millisecond, } } diff --git a/cli/testdata/coder_create_--help.golden b/cli/testdata/coder_create_--help.golden index 7662ecd7ed7ee..5a3d65fc38fb1 100644 --- a/cli/testdata/coder_create_--help.golden +++ b/cli/testdata/coder_create_--help.golden @@ -10,6 +10,11 @@ USAGE: $ coder create / OPTIONS: + --activity-bump duration (default: 0) + Specify a default amount of time to bump the deadline for the + workspaces based on workspace activity. By default, activity will + extend the deadline for a workspace by the 'stop-after' amount. + --automatic-updates string, $CODER_WORKSPACE_AUTOMATIC_UPDATES (default: never) Specify automatic updates setting for the workspace (accepts 'always' or 'never'). diff --git a/cli/testdata/coder_templates_create_--help.golden b/cli/testdata/coder_templates_create_--help.golden index f458d3954dd62..1090874fdff3d 100644 --- a/cli/testdata/coder_templates_create_--help.golden +++ b/cli/testdata/coder_templates_create_--help.golden @@ -6,6 +6,11 @@ USAGE: Create a template from the current directory or as specified by flag OPTIONS: + --default-activity-bump duration (default: 0) + Specify a default amount of time to bump the deadline for the + workspaces based on workspace activity. By default, activity will + extend the deadline for a workspace by the 'default-ttl' amount. + --default-ttl duration (default: 24h) Specify a default TTL for workspaces created from this template. It is the default time before shutdown - workspaces created from this diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index fd5841125e708..5a0a275cf932b 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -23,6 +23,11 @@ OPTIONS: this value for the template (and allow autostart on all days), pass 'all'. + --default-activity-bump duration (default: 0) + Specify a default amount of time to bump the deadline for the + workspaces based on workspace activity. By default, activity will + extend the deadline for a workspace by the 'default-ttl' amount. + --default-ttl duration Edit the template default time before shutdown - workspaces created from this template default to this value. Maps to "Default autostop" diff --git a/cli/testdata/coder_templates_list_--help.golden b/cli/testdata/coder_templates_list_--help.golden index c76905cae27f4..9de7ad43980ea 100644 --- a/cli/testdata/coder_templates_list_--help.golden +++ b/cli/testdata/coder_templates_list_--help.golden @@ -11,7 +11,7 @@ OPTIONS: -c, --column string-array (default: name,last updated,used by) Columns to display in table output. Available columns: name, created at, last updated, organization id, provisioner, active version id, - used by, default ttl. + used by, default ttl, default ttl bump. -o, --output string (default: table) Output format. Available formats: table, json. diff --git a/coderd/activitybump_internal_test.go b/coderd/activitybump_internal_test.go index 3e5f7c1848db3..6144e423a5093 100644 --- a/coderd/activitybump_internal_test.go +++ b/coderd/activitybump_internal_test.go @@ -39,7 +39,9 @@ func Test_ActivityBumpWorkspace(t *testing.T) { buildDeadlineOffset *time.Duration maxDeadlineOffset *time.Duration workspaceTTL time.Duration + workspaceBumpTTL time.Duration templateTTL time.Duration + templateBumpTTL time.Duration templateDisallowsUserAutostop bool expectedBump time.Duration }{ @@ -83,6 +85,16 @@ func Test_ActivityBumpWorkspace(t *testing.T) { workspaceTTL: 8 * time.Hour, expectedBump: 1 * time.Hour, }, + { + name: "MaxDeadlineCustomBump", + transition: database.WorkspaceTransitionStart, + jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-24 * time.Minute)}, + buildDeadlineOffset: ptr.Ref(time.Minute), // last chance to bump! + maxDeadlineOffset: ptr.Ref(4 * time.Hour), + workspaceTTL: 8 * time.Hour, + workspaceBumpTTL: 5 * time.Hour, + expectedBump: 5 * time.Hour, + }, { // A workspace that is still running, has passed its deadline, but has not // yet been auto-stopped should still bump the deadline. @@ -101,6 +113,17 @@ func Test_ActivityBumpWorkspace(t *testing.T) { buildDeadlineOffset: ptr.Ref(-time.Minute), workspaceTTL: 8 * time.Hour, }, + { + // Deadline bump TTL can be less than the time left in the + // deadline. It should be a no-op. + name: "DeadlineNotDecreased", + transition: database.WorkspaceTransitionStart, + jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-24 * time.Hour)}, + buildDeadlineOffset: ptr.Ref(time.Hour * 5), + workspaceTTL: 8 * time.Hour, + workspaceBumpTTL: time.Hour, + expectedBump: 0, + }, { // A workspace built from a template that disallows user autostop should bump // by the template TTL instead. @@ -113,6 +136,18 @@ func Test_ActivityBumpWorkspace(t *testing.T) { templateDisallowsUserAutostop: true, expectedBump: 8 * time.Hour, }, + { + name: "TemplateDisallowsUserAutostopCustomBump", + transition: database.WorkspaceTransitionStart, + templateDisallowsUserAutostop: true, + jobCompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now().Add(-7 * time.Hour)}, + buildDeadlineOffset: ptr.Ref(time.Minute), + templateTTL: time.Hour * 8, + templateBumpTTL: time.Hour, + workspaceTTL: 24 * time.Hour, + workspaceBumpTTL: 10 * time.Hour, + expectedBump: time.Hour, // Expect smaller bump + }, } { tt := tt for _, tz := range timezones { @@ -147,6 +182,7 @@ func Test_ActivityBumpWorkspace(t *testing.T) { OrganizationID: org.ID, TemplateID: template.ID, Ttl: sql.NullInt64{Valid: true, Int64: int64(tt.workspaceTTL)}, + TtlBump: int64(tt.workspaceBumpTTL), }) job = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ OrganizationID: org.ID, @@ -163,6 +199,7 @@ func Test_ActivityBumpWorkspace(t *testing.T) { UpdatedAt: dbtime.Now(), AllowUserAutostop: !tt.templateDisallowsUserAutostop, DefaultTTL: int64(tt.templateTTL), + DefaultTTLBump: int64(tt.templateBumpTTL), }), "unexpected error updating template schedule") var buildNumber int32 = 1 @@ -207,6 +244,7 @@ func Test_ActivityBumpWorkspace(t *testing.T) { require.Equal(t, buildDeadline.UTC(), bld.Deadline.UTC(), "unexpected build deadline before bump") require.Equal(t, maxDeadline.UTC(), bld.MaxDeadline.UTC(), "unexpected max deadline before bump") require.Equal(t, tt.workspaceTTL, time.Duration(ws.Ttl.Int64), "unexpected workspace TTL before bump") + require.Equal(t, tt.workspaceBumpTTL, time.Duration(ws.TtlBump), "unexpected workspace TTL bump") // Wait a bit before bumping as dbtime is rounded to the nearest millisecond. // This should also hopefully be enough for Windows time resolution to register @@ -236,8 +274,8 @@ func Test_ActivityBumpWorkspace(t *testing.T) { // Assert that the bump occurred between start and end. expectedDeadlineStart := start.Add(tt.expectedBump) expectedDeadlineEnd := end.Add(tt.expectedBump) - require.GreaterOrEqual(t, updatedBuild.Deadline, expectedDeadlineStart, "new deadline should be greater than or equal to start") - require.LessOrEqual(t, updatedBuild.Deadline, expectedDeadlineEnd, "new deadline should be lesser than or equal to end") + require.GreaterOrEqualf(t, updatedBuild.Deadline, expectedDeadlineStart, "new deadline should be greater than or equal to start, pre-bump: %s, diff_from_last_deadline: %s", buildDeadline.String(), updatedBuild.Deadline.Sub(buildDeadline)) + require.LessOrEqualf(t, updatedBuild.Deadline, expectedDeadlineEnd, "new deadline should be lesser than or equal to end, pre-bump: %s, diff_from_last_deadline: %s", buildDeadline.String(), updatedBuild.Deadline.Sub(buildDeadline)) }) } } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e8689850eb081..dfcf22095b2a3 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7806,6 +7806,10 @@ const docTemplate = `{ } ] }, + "default_ttl_bump_ms": { + "description": "DefaultTTLBumpMillis allows optionally specifying the default TTL\nbump based on workspace activity. If unset, the default TTL will be\nused as the bump amount.", + "type": "integer" + }, "default_ttl_ms": { "description": "DefaultTTLMillis allows optionally specifying the default TTL\nfor all workspaces created from this template.", "type": "integer" @@ -8156,6 +8160,9 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "ttl_bump_ms": { + "type": "integer" + }, "ttl_ms": { "type": "integer" } @@ -10026,6 +10033,9 @@ const docTemplate = `{ "created_by_name": { "type": "string" }, + "default_ttl_bump_ms": { + "type": "integer" + }, "default_ttl_ms": { "type": "integer" }, @@ -10814,6 +10824,9 @@ const docTemplate = `{ "codersdk.UpdateWorkspaceTTLRequest": { "type": "object", "properties": { + "ttl_bump_ms": { + "type": "integer" + }, "ttl_ms": { "type": "integer" } @@ -11191,6 +11204,9 @@ const docTemplate = `{ "template_require_active_version": { "type": "boolean" }, + "ttl_bump_ms": { + "type": "integer" + }, "ttl_ms": { "type": "integer" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 10a54546fde8f..d2cb4d1787340 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6955,6 +6955,10 @@ } ] }, + "default_ttl_bump_ms": { + "description": "DefaultTTLBumpMillis allows optionally specifying the default TTL\nbump based on workspace activity. If unset, the default TTL will be\nused as the bump amount.", + "type": "integer" + }, "default_ttl_ms": { "description": "DefaultTTLMillis allows optionally specifying the default TTL\nfor all workspaces created from this template.", "type": "integer" @@ -7268,6 +7272,9 @@ "type": "string", "format": "uuid" }, + "ttl_bump_ms": { + "type": "integer" + }, "ttl_ms": { "type": "integer" } @@ -9058,6 +9065,9 @@ "created_by_name": { "type": "string" }, + "default_ttl_bump_ms": { + "type": "integer" + }, "default_ttl_ms": { "type": "integer" }, @@ -9797,6 +9807,9 @@ "codersdk.UpdateWorkspaceTTLRequest": { "type": "object", "properties": { + "ttl_bump_ms": { + "type": "integer" + }, "ttl_ms": { "type": "integer" } @@ -10153,6 +10166,9 @@ "template_require_active_version": { "type": "boolean" }, + "ttl_bump_ms": { + "type": "integer" + }, "ttl_ms": { "type": "integer" }, diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 970709fec9087..ba77a18e4541d 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -178,6 +178,7 @@ func Workspace(t testing.TB, db database.Store, orig database.Workspace) databas Name: takeFirst(orig.Name, namesgenerator.GetRandomName(1)), AutostartSchedule: orig.AutostartSchedule, Ttl: orig.Ttl, + TtlBump: orig.TtlBump, AutomaticUpdates: takeFirst(orig.AutomaticUpdates, database.AutomaticUpdatesNever), }) require.NoError(t, err, "insert workspace") diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 985b2d4b422ee..3876f5171435b 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -350,6 +350,7 @@ func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspac Name: w.Name, AutostartSchedule: w.AutostartSchedule, Ttl: w.Ttl, + TtlBump: w.TtlBump, LastUsedAt: w.LastUsedAt, DormantAt: w.DormantAt, DeletingAt: w.DeletingAt, @@ -824,9 +825,15 @@ func (q *FakeQuerier) ActivityBumpWorkspace(ctx context.Context, workspaceID uui var ttlDur time.Duration if workspace.Ttl.Valid { ttlDur = time.Duration(workspace.Ttl.Int64) + if workspace.TtlBump > 0 { + ttlDur = time.Duration(workspace.TtlBump) + } } if !template.AllowUserAutostop { ttlDur = time.Duration(template.DefaultTTL) + if template.DefaultTTLBump > 0 { + ttlDur = time.Duration(template.DefaultTTLBump) + } } if ttlDur <= 0 { // There's no TTL set anymore, so we don't know the bump duration. @@ -843,6 +850,10 @@ func (q *FakeQuerier) ActivityBumpWorkspace(ctx context.Context, workspaceID uui // Bump. newDeadline := now.Add(ttlDur) q.workspaceBuilds[i].UpdatedAt = now + if newDeadline.Before(q.workspaceBuilds[i].Deadline) { + // Never shorten a deadline + newDeadline = q.workspaceBuilds[i].Deadline + } if !q.workspaceBuilds[i].MaxDeadline.IsZero() { q.workspaceBuilds[i].Deadline = minTime(newDeadline, q.workspaceBuilds[i].MaxDeadline) } else { @@ -5070,6 +5081,7 @@ func (q *FakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWork Name: arg.Name, AutostartSchedule: arg.AutostartSchedule, Ttl: arg.Ttl, + TtlBump: arg.TtlBump, LastUsedAt: arg.LastUsedAt, AutomaticUpdates: arg.AutomaticUpdates, } @@ -5941,6 +5953,7 @@ func (q *FakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database tpl.AllowUserAutostop = arg.AllowUserAutostop tpl.UpdatedAt = dbtime.Now() tpl.DefaultTTL = arg.DefaultTTL + tpl.DefaultTTLBump = arg.DefaultTTLBump tpl.MaxTTL = arg.MaxTTL tpl.AutostopRequirementDaysOfWeek = arg.AutostopRequirementDaysOfWeek tpl.AutostopRequirementWeeks = arg.AutostopRequirementWeeks @@ -6671,6 +6684,7 @@ func (q *FakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW continue } workspace.Ttl = arg.Ttl + workspace.TtlBump = arg.TtlBump q.workspaces[index] = workspace return nil } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index c4e1c26f1b389..7a1aca9971ef1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -805,7 +805,8 @@ CREATE TABLE templates ( autostop_requirement_days_of_week smallint DEFAULT 0 NOT NULL, autostop_requirement_weeks bigint DEFAULT 0 NOT NULL, autostart_block_days_of_week smallint DEFAULT 0 NOT NULL, - require_active_version boolean DEFAULT false NOT NULL + require_active_version boolean DEFAULT false NOT NULL, + default_ttl_bump bigint DEFAULT '0'::bigint NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.'; @@ -824,6 +825,8 @@ COMMENT ON COLUMN templates.autostop_requirement_weeks IS 'The number of weeks b COMMENT ON COLUMN templates.autostart_block_days_of_week IS 'A bitmap of days of week that autostart of a workspace is not allowed. Default allows all days. This is intended as a cost savings measure to prevent auto start on weekends (for example).'; +COMMENT ON COLUMN templates.default_ttl_bump IS 'Amount of time to bump workspace ttl from activity. 0 will default to the "default_ttl" as the bump interval.'; + CREATE VIEW template_with_users AS SELECT templates.id, templates.created_at, @@ -851,6 +854,7 @@ CREATE VIEW template_with_users AS templates.autostop_requirement_weeks, templates.autostart_block_days_of_week, templates.require_active_version, + templates.default_ttl_bump, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, COALESCE(visible_users.username, ''::text) AS created_by_username FROM (public.templates @@ -1192,9 +1196,12 @@ CREATE TABLE workspaces ( last_used_at timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL, dormant_at timestamp with time zone, deleting_at timestamp with time zone, - automatic_updates automatic_updates DEFAULT 'never'::automatic_updates NOT NULL + automatic_updates automatic_updates DEFAULT 'never'::automatic_updates NOT NULL, + ttl_bump bigint DEFAULT '0'::bigint NOT NULL ); +COMMENT ON COLUMN workspaces.ttl_bump IS 'Amount of time to bump workspace ttl from activity. 0 will default to the "ttl" as the bump interval.'; + ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('licenses_id_seq'::regclass); ALTER TABLE ONLY provisioner_job_logs ALTER COLUMN id SET DEFAULT nextval('provisioner_job_logs_id_seq'::regclass); diff --git a/coderd/database/migrations/000169_ttl_bump.down.sql b/coderd/database/migrations/000169_ttl_bump.down.sql new file mode 100644 index 0000000000000..a55ddce28cd98 --- /dev/null +++ b/coderd/database/migrations/000169_ttl_bump.down.sql @@ -0,0 +1,26 @@ +BEGIN; + +-- Update the template_with_users view; +DROP VIEW template_with_users; + +ALTER TABLE templates DROP COLUMN default_ttl_bump; +ALTER TABLE workspaces DROP COLUMN ttl_bump; + +-- If you need to update this view, put 'DROP VIEW template_with_users;' before this. +CREATE VIEW + template_with_users +AS +SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username +FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; + +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/migrations/000169_ttl_bump.up.sql b/coderd/database/migrations/000169_ttl_bump.up.sql new file mode 100644 index 0000000000000..7b61d19547594 --- /dev/null +++ b/coderd/database/migrations/000169_ttl_bump.up.sql @@ -0,0 +1,29 @@ +BEGIN; + +DROP VIEW template_with_users; + +ALTER TABLE templates ADD COLUMN default_ttl_bump bigint DEFAULT '0'::bigint NOT NULL; +COMMENT ON COLUMN templates.default_ttl_bump IS 'Amount of time to bump workspace ttl from activity. 0 will default to the "default_ttl" as the bump interval.'; + + +ALTER TABLE workspaces ADD COLUMN ttl_bump bigint DEFAULT '0'::bigint NOT NULL; +COMMENT ON COLUMN workspaces.ttl_bump IS 'Amount of time to bump workspace ttl from activity. 0 will default to the "ttl" as the bump interval.'; + +CREATE VIEW + template_with_users +AS +SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username +FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; + +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + + +COMMIT; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 5c78600237e1d..4afb788c03d6f 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -87,6 +87,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, &i.RequireActiveVersion, + &i.DefaultTTLBump, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -247,6 +248,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.TtlBump, &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, diff --git a/coderd/database/models.go b/coderd/database/models.go index 84147dc28fcc2..c290cc65d7d20 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1966,6 +1966,7 @@ type Template struct { AutostopRequirementWeeks int64 `db:"autostop_requirement_weeks" json:"autostop_requirement_weeks"` AutostartBlockDaysOfWeek int16 `db:"autostart_block_days_of_week" json:"autostart_block_days_of_week"` RequireActiveVersion bool `db:"require_active_version" json:"require_active_version"` + DefaultTTLBump int64 `db:"default_ttl_bump" json:"default_ttl_bump"` CreatedByAvatarURL sql.NullString `db:"created_by_avatar_url" json:"created_by_avatar_url"` CreatedByUsername string `db:"created_by_username" json:"created_by_username"` } @@ -2005,6 +2006,8 @@ type TemplateTable struct { // A bitmap of days of week that autostart of a workspace is not allowed. Default allows all days. This is intended as a cost savings measure to prevent auto start on weekends (for example). AutostartBlockDaysOfWeek int16 `db:"autostart_block_days_of_week" json:"autostart_block_days_of_week"` RequireActiveVersion bool `db:"require_active_version" json:"require_active_version"` + // Amount of time to bump workspace ttl from activity. 0 will default to the "default_ttl" as the bump interval. + DefaultTTLBump int64 `db:"default_ttl_bump" json:"default_ttl_bump"` } // Joins in the username + avatar url of the created by user. @@ -2148,6 +2151,8 @@ type Workspace struct { DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` + // Amount of time to bump workspace ttl from activity. 0 will default to the "ttl" as the bump interval. + TtlBump int64 `db:"ttl_bump" json:"ttl_bump"` } type WorkspaceAgent struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 03aff0ea801b2..f2744dbacaceb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -26,8 +26,18 @@ WITH latest AS ( ( CASE WHEN templates.allow_user_autostop - THEN (workspaces.ttl / 1000 / 1000 / 1000 || ' seconds')::interval - ELSE (templates.default_ttl / 1000 / 1000 / 1000 || ' seconds')::interval + THEN + CASE + -- If ttl_bump is set, then use that instead of the ttl + -- for the bump amount. + WHEN workspaces.ttl_bump > 0 THEN (workspaces.ttl_bump / 1000 / 1000 / 1000 || ' seconds')::interval + ELSE (workspaces.ttl / 1000 / 1000 / 1000 || ' seconds')::interval + END + ELSE + CASE + WHEN templates.default_ttl_bump > 0 THEN (templates.default_ttl_bump / 1000 / 1000 / 1000 || ' seconds')::interval + ELSE (templates.default_ttl / 1000 / 1000 / 1000 || ' seconds')::interval + END END ) AS ttl_interval FROM workspace_builds @@ -45,11 +55,12 @@ UPDATE workspace_builds wb SET updated_at = NOW(), - deadline = CASE + -- Never shorten a deadline with an activity bump. + deadline = GREATEST(wb.deadline, CASE WHEN l.build_max_deadline = '0001-01-01 00:00:00+00' THEN NOW() + l.ttl_interval ELSE LEAST(NOW() + l.ttl_interval, l.build_max_deadline) - END + END) FROM latest l WHERE wb.id = l.build_id AND l.job_completed_at IS NOT NULL @@ -4887,7 +4898,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, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, created_by_avatar_url, created_by_username + 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, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, default_ttl_bump, created_by_avatar_url, created_by_username FROM template_with_users WHERE @@ -4926,6 +4937,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, &i.RequireActiveVersion, + &i.DefaultTTLBump, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -4934,7 +4946,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat 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, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, created_by_avatar_url, created_by_username + 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, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, default_ttl_bump, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -4981,6 +4993,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, &i.RequireActiveVersion, + &i.DefaultTTLBump, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -4988,7 +5001,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G } 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, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, created_by_avatar_url, created_by_username FROM template_with_users AS 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, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, default_ttl_bump, created_by_avatar_url, created_by_username FROM template_with_users AS templates ORDER BY (name, id) ASC ` @@ -5028,6 +5041,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, &i.RequireActiveVersion, + &i.DefaultTTLBump, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -5046,7 +5060,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, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, created_by_avatar_url, created_by_username + 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, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, default_ttl_bump, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -5123,6 +5137,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, &i.RequireActiveVersion, + &i.DefaultTTLBump, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -5331,7 +5346,8 @@ SET autostart_block_days_of_week = $9, failure_ttl = $10, time_til_dormant = $11, - time_til_dormant_autodelete = $12 + time_til_dormant_autodelete = $12, + default_ttl_bump = $13 WHERE id = $1 ` @@ -5349,6 +5365,7 @@ type UpdateTemplateScheduleByIDParams struct { FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` TimeTilDormant int64 `db:"time_til_dormant" json:"time_til_dormant"` TimeTilDormantAutoDelete int64 `db:"time_til_dormant_autodelete" json:"time_til_dormant_autodelete"` + DefaultTTLBump int64 `db:"default_ttl_bump" json:"default_ttl_bump"` } func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error { @@ -5365,6 +5382,7 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT arg.FailureTTL, arg.TimeTilDormant, arg.TimeTilDormantAutoDelete, + arg.DefaultTTLBump, ) return err } @@ -10035,7 +10053,7 @@ func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploy const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, ttl_bump FROM workspaces WHERE @@ -10081,13 +10099,14 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.TtlBump, ) return i, err } const getWorkspaceByID = `-- name: GetWorkspaceByID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, ttl_bump FROM workspaces WHERE @@ -10114,13 +10133,14 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.TtlBump, ) return i, err } const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, ttl_bump FROM workspaces WHERE @@ -10154,13 +10174,14 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.TtlBump, ) return i, err } const getWorkspaceByWorkspaceAppID = `-- name: GetWorkspaceByWorkspaceAppID :one SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, ttl_bump FROM workspaces WHERE @@ -10213,13 +10234,14 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.TtlBump, ) return i, err } const getWorkspaces = `-- name: GetWorkspaces :many SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.ttl_bump, COALESCE(template_name.template_name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, @@ -10387,7 +10409,7 @@ WHERE -- workspaces since they are considered soft-deleted. AND CASE WHEN $10 :: text != '' THEN - dormant_at IS NOT NULL + dormant_at IS NOT NULL ELSE dormant_at IS NULL END @@ -10452,6 +10474,7 @@ type GetWorkspacesRow struct { DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` + TtlBump int64 `db:"ttl_bump" json:"ttl_bump"` TemplateName string `db:"template_name" json:"template_name"` TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` @@ -10497,6 +10520,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.TtlBump, &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, @@ -10517,7 +10541,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.ttl_bump FROM workspaces LEFT JOIN @@ -10605,6 +10629,7 @@ func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.TtlBump, ); err != nil { return nil, err } @@ -10631,11 +10656,12 @@ INSERT INTO name, autostart_schedule, ttl, + ttl_bump, last_used_at, automatic_updates ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, ttl_bump ` type InsertWorkspaceParams struct { @@ -10648,6 +10674,7 @@ type InsertWorkspaceParams struct { Name string `db:"name" json:"name"` AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + TtlBump int64 `db:"ttl_bump" json:"ttl_bump"` LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` } @@ -10663,6 +10690,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar arg.Name, arg.AutostartSchedule, arg.Ttl, + arg.TtlBump, arg.LastUsedAt, arg.AutomaticUpdates, ) @@ -10682,6 +10710,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.TtlBump, ) return i, err } @@ -10712,7 +10741,7 @@ SET WHERE id = $1 AND deleted = false -RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates +RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, ttl_bump ` type UpdateWorkspaceParams struct { @@ -10738,6 +10767,7 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.TtlBump, ) return i, err } @@ -10814,7 +10844,7 @@ FROM templates WHERE workspaces.id = $1 -RETURNING workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates +RETURNING workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.ttl_bump ` type UpdateWorkspaceDormantDeletingAtParams struct { @@ -10840,6 +10870,7 @@ func (q *sqlQuerier) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg U &i.DormantAt, &i.DeletingAt, &i.AutomaticUpdates, + &i.TtlBump, ) return i, err } @@ -10867,18 +10898,20 @@ const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec UPDATE workspaces SET - ttl = $2 + ttl = $2, + ttl_bump = $3 WHERE id = $1 ` type UpdateWorkspaceTTLParams struct { - ID uuid.UUID `db:"id" json:"id"` - Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + ID uuid.UUID `db:"id" json:"id"` + Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + TtlBump int64 `db:"ttl_bump" json:"ttl_bump"` } func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceTTL, arg.ID, arg.Ttl) + _, err := q.db.ExecContext(ctx, updateWorkspaceTTL, arg.ID, arg.Ttl, arg.TtlBump) return err } diff --git a/coderd/database/queries/activitybump.sql b/coderd/database/queries/activitybump.sql index fb9ae456501e1..589ba071923cd 100644 --- a/coderd/database/queries/activitybump.sql +++ b/coderd/database/queries/activitybump.sql @@ -13,8 +13,18 @@ WITH latest AS ( ( CASE WHEN templates.allow_user_autostop - THEN (workspaces.ttl / 1000 / 1000 / 1000 || ' seconds')::interval - ELSE (templates.default_ttl / 1000 / 1000 / 1000 || ' seconds')::interval + THEN + CASE + -- If ttl_bump is set, then use that instead of the ttl + -- for the bump amount. + WHEN workspaces.ttl_bump > 0 THEN (workspaces.ttl_bump / 1000 / 1000 / 1000 || ' seconds')::interval + ELSE (workspaces.ttl / 1000 / 1000 / 1000 || ' seconds')::interval + END + ELSE + CASE + WHEN templates.default_ttl_bump > 0 THEN (templates.default_ttl_bump / 1000 / 1000 / 1000 || ' seconds')::interval + ELSE (templates.default_ttl / 1000 / 1000 / 1000 || ' seconds')::interval + END END ) AS ttl_interval FROM workspace_builds @@ -32,11 +42,12 @@ UPDATE workspace_builds wb SET updated_at = NOW(), - deadline = CASE + -- Never shorten a deadline with an activity bump. + deadline = GREATEST(wb.deadline, CASE WHEN l.build_max_deadline = '0001-01-01 00:00:00+00' THEN NOW() + l.ttl_interval ELSE LEAST(NOW() + l.ttl_interval, l.build_max_deadline) - END + END) FROM latest l WHERE wb.id = l.build_id AND l.job_completed_at IS NOT NULL diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index c5bc72d7911d6..90493823cd4c2 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -123,7 +123,8 @@ SET autostart_block_days_of_week = $9, failure_ttl = $10, time_til_dormant = $11, - time_til_dormant_autodelete = $12 + time_til_dormant_autodelete = $12, + default_ttl_bump = $13 WHERE id = $1 ; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index f20c9edb88b28..c1bffaf745e6c 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -243,7 +243,7 @@ WHERE -- workspaces since they are considered soft-deleted. AND CASE WHEN @is_dormant :: text != '' THEN - dormant_at IS NOT NULL + dormant_at IS NOT NULL ELSE dormant_at IS NULL END @@ -299,11 +299,12 @@ INSERT INTO name, autostart_schedule, ttl, + ttl_bump, last_used_at, automatic_updates ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *; -- name: UpdateWorkspaceDeletedByID :exec UPDATE @@ -335,7 +336,8 @@ WHERE UPDATE workspaces SET - ttl = $2 + ttl = $2, + ttl_bump = $3 WHERE id = $1; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 956485311e4a1..c159837216127 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -67,6 +67,7 @@ overrides: group_acl: GroupACL troubleshooting_url: TroubleshootingURL default_ttl: DefaultTTL + default_ttl_bump: DefaultTTLBump max_ttl: MaxTTL template_max_ttl: TemplateMaxTTL motd_file: MOTDFile diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index fc274d9a7d8ba..e5548281565ed 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -117,6 +117,7 @@ type TemplateScheduleOptions struct { UserAutostartEnabled bool `json:"user_autostart_enabled"` UserAutostopEnabled bool `json:"user_autostop_enabled"` DefaultTTL time.Duration `json:"default_ttl"` + DefaultTTLBump time.Duration `json:"default_ttl_bump"` // TODO(@dean): remove MaxTTL once autostop_requirement is matured and the // default MaxTTL time.Duration `json:"max_ttl"` @@ -183,6 +184,7 @@ func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, te UserAutostartEnabled: true, UserAutostopEnabled: true, DefaultTTL: time.Duration(tpl.DefaultTTL), + DefaultTTLBump: time.Duration(tpl.DefaultTTLBump), // Disregard the values in the database, since AutostopRequirement, // FailureTTL, TimeTilDormant, and TimeTilDormantAutoDelete are enterprise features. UseAutostopRequirement: false, @@ -207,7 +209,7 @@ func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tp ctx, span := tracing.StartSpan(ctx) defer span.End() - if int64(opts.DefaultTTL) == tpl.DefaultTTL { + if int64(opts.DefaultTTL) == tpl.DefaultTTL && int64(opts.DefaultTTLBump) == tpl.DefaultTTLBump { // Avoid updating the UpdatedAt timestamp if nothing will be changed. return tpl, nil } @@ -215,9 +217,10 @@ func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tp var template database.Template err := db.InTx(func(db database.Store) error { err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ - ID: tpl.ID, - UpdatedAt: dbtime.Now(), - DefaultTTL: int64(opts.DefaultTTL), + ID: tpl.ID, + UpdatedAt: dbtime.Now(), + DefaultTTL: int64(opts.DefaultTTL), + DefaultTTLBump: int64(opts.DefaultTTLBump), // Don't allow changing these settings, but keep the value in the DB (to // avoid clearing settings if the license has an issue). MaxTTL: tpl.MaxTTL, diff --git a/coderd/templates.go b/coderd/templates.go index c1f3bc97a01c3..d88abf22ce1ef 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -223,7 +223,8 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque } var ( - defaultTTL time.Duration + defaultTTL time.Duration + defaultTTLBump time.Duration // TODO(@dean): remove max_ttl once autostop_requirement is ready maxTTL time.Duration autostopRequirementDaysOfWeek []string @@ -236,6 +237,9 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque if createTemplate.DefaultTTLMillis != nil { defaultTTL = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond } + if createTemplate.DefaultTTLBumpMillis != nil { + defaultTTLBump = time.Duration(*createTemplate.DefaultTTLBumpMillis) * time.Millisecond + } if createTemplate.AutostopRequirement != nil { autostopRequirementDaysOfWeek = createTemplate.AutostopRequirement.DaysOfWeek autostopRequirementWeeks = createTemplate.AutostopRequirement.Weeks @@ -264,12 +268,18 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque if defaultTTL < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) } + if defaultTTLBump < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_bump_ms", Detail: "Must be a positive integer."}) + } if maxTTL < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."}) } if maxTTL != 0 && defaultTTL > maxTTL { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) } + if maxTTL != 0 && defaultTTLBump > maxTTL { + validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_bump_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) + } if len(autostopRequirementDaysOfWeek) > 0 { autostopRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(autostopRequirementDaysOfWeek) if err != nil { @@ -365,6 +375,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque UserAutostartEnabled: allowUserAutostart, UserAutostopEnabled: allowUserAutostop, DefaultTTL: defaultTTL, + DefaultTTLBump: defaultTTLBump, MaxTTL: maxTTL, // Some of these values are enterprise-only, but the // TemplateScheduleStore will handle avoiding setting them if @@ -543,12 +554,18 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if req.DefaultTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) } + if req.DefaultTTLBumpMillis < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_bump_ms", Detail: "Must be a positive integer."}) + } if req.MaxTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "max_ttl_ms", Detail: "Must be a positive integer."}) } if req.MaxTTLMillis != 0 && req.DefaultTTLMillis > req.MaxTTLMillis { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) } + if req.MaxTTLMillis != 0 && req.DefaultTTLBumpMillis > req.MaxTTLMillis { + validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_bump_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) + } if req.AutostopRequirement == nil { req.AutostopRequirement = &codersdk.TemplateAutostopRequirement{ DaysOfWeek: codersdk.BitmapToWeekdays(scheduleOpts.AutostopRequirement.DaysOfWeek), @@ -608,6 +625,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { } var updated database.Template + err = api.Database.InTx(func(tx database.Store) error { if req.Name == template.Name && req.Description == template.Description && @@ -617,6 +635,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.AllowUserAutostop == template.AllowUserAutostop && req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs && req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() && + req.DefaultTTLBumpMillis == time.Duration(template.DefaultTTLBump).Milliseconds() && req.MaxTTLMillis == time.Duration(template.MaxTTL).Milliseconds() && autostopRequirementDaysOfWeekParsed == scheduleOpts.AutostopRequirement.DaysOfWeek && autostartRequirementDaysOfWeekParsed == scheduleOpts.AutostartRequirement.DaysOfWeek && @@ -663,12 +682,14 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { } defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond + defaultTTLBump := time.Duration(req.DefaultTTLBumpMillis) * time.Millisecond maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond failureTTL := time.Duration(req.FailureTTLMillis) * time.Millisecond inactivityTTL := time.Duration(req.TimeTilDormantMillis) * time.Millisecond timeTilDormantAutoDelete := time.Duration(req.TimeTilDormantAutoDeleteMillis) * time.Millisecond if defaultTTL != time.Duration(template.DefaultTTL) || + defaultTTLBump != time.Duration(template.DefaultTTLBump) || maxTTL != time.Duration(template.MaxTTL) || autostopRequirementDaysOfWeekParsed != scheduleOpts.AutostopRequirement.DaysOfWeek || autostartRequirementDaysOfWeekParsed != scheduleOpts.AutostartRequirement.DaysOfWeek || @@ -685,6 +706,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { UserAutostartEnabled: req.AllowUserAutostart, UserAutostopEnabled: req.AllowUserAutostop, DefaultTTL: defaultTTL, + DefaultTTLBump: defaultTTLBump, MaxTTL: maxTTL, AutostopRequirement: schedule.TemplateAutostopRequirement{ DaysOfWeek: autostopRequirementDaysOfWeekParsed, @@ -827,6 +849,7 @@ func (api *API) convertTemplate( Description: template.Description, Icon: template.Icon, DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(), + DefaultTTLBumpMillis: time.Duration(template.DefaultTTLBump).Milliseconds(), MaxTTLMillis: time.Duration(template.MaxTTL).Milliseconds(), CreatedByID: template.CreatedBy, CreatedByName: template.CreatedByUsername, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index a218119e266e4..17aa118849fc3 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -111,6 +111,26 @@ func TestPostTemplateByOrganization(t *testing.T) { require.Contains(t, err.Error(), "default_ttl_ms: Must be a positive integer") }) + t.Run("DefaultTTLBumpTooLow", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "testing", + VersionID: version.ID, + DefaultTTLBumpMillis: ptr.Ref(int64(-1)), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, err.Error(), "default_ttl_bump_ms: Must be a positive integer") + }) + t.Run("NoDefaultTTL", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) @@ -121,9 +141,10 @@ func TestPostTemplateByOrganization(t *testing.T) { defer cancel() got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ - Name: "testing", - VersionID: version.ID, - DefaultTTLMillis: ptr.Ref(int64(0)), + Name: "testing", + VersionID: version.ID, + DefaultTTLMillis: ptr.Ref(int64(0)), + DefaultTTLBumpMillis: ptr.Ref(int64(0)), }) require.NoError(t, err) require.Zero(t, got.DefaultTTLMillis) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 6a86e9e735501..9a4712f4d5d5b 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -434,6 +434,26 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } + // The default ttl bump should come from the request only if the workspace + // ttl or ttl_bump is explicitly set. By default, just use the template + // defaults. + defaultBump := templateSchedule.DefaultTTLBump + if !ptr.NilOrZero(createWorkspace.TTLMillis) { + // By setting the default bump to 0, that means to defer to TTL + // for the bump duration. So this defaults to the explicitly set TTL + // in the workspace by making this field a noop. + defaultBump = 0 + } + + dbTTLBump, err := validWorkspaceTTLBumpMillis(createWorkspace.TTLBumpMillis, defaultBump) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid Workspace Time to Shutdown.", + Validations: []codersdk.ValidationError{{Field: "ttl_ms", Detail: err.Error()}}, + }) + return + } + // back-compatibility: default to "never" if not included. dbAU := database.AutomaticUpdatesNever if createWorkspace.AutomaticUpdates != "" { @@ -490,6 +510,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req Name: createWorkspace.Name, AutostartSchedule: dbAutostartSchedule, Ttl: dbTTL, + TtlBump: int64(dbTTLBump), // The workspaces page will sort by last used at, and it's useful to // have the newly created workspace at the top of the list! LastUsedAt: dbtime.Now(), @@ -765,6 +786,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { } var dbTTL sql.NullInt64 + var dbTTLBump time.Duration err := api.Database.InTx(func(s database.Store) error { templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, s, workspace.TemplateID) @@ -788,9 +810,18 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { if validityErr != nil { return codersdk.ValidationError{Field: "ttl_ms", Detail: validityErr.Error()} } + + // The TTL bump defaults to 0, which means the bump amount is the + // TTL. + dbTTLBump, validityErr = validWorkspaceTTLBumpMillis(req.TTLBumpMillis, 0) + if validityErr != nil { + return codersdk.ValidationError{Field: "ttl_bump_ms", Detail: validityErr.Error()} + } + if err := s.UpdateWorkspaceTTL(ctx, database.UpdateWorkspaceTTLParams{ - ID: workspace.ID, - Ttl: dbTTL, + ID: workspace.ID, + Ttl: dbTTL, + TtlBump: int64(dbTTLBump), }); err != nil { return xerrors.Errorf("update workspace time until shutdown: %w", err) } @@ -815,6 +846,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { newWorkspace := workspace newWorkspace.Ttl = dbTTL + newWorkspace.TtlBump = int64(dbTTLBump) aReq.New = newWorkspace rw.WriteHeader(http.StatusNoContent) @@ -1438,6 +1470,7 @@ func convertWorkspace( Name: workspace.Name, AutostartSchedule: autostartSchedule, TTLMillis: ttlMillis, + TTLBumpMillis: time.Duration(workspace.TtlBump).Milliseconds(), LastUsedAt: workspace.LastUsedAt, DeletingAt: deletingAt, DormantAt: dormantAt, @@ -1501,6 +1534,28 @@ func validWorkspaceTTLMillis(millis *int64, templateDefault, templateMax time.Du }, nil } +// validWorkspaceTTLBumpMillis returns the workspace ttl bump. +// If the bump is set to <= 0, it is ignored and ttl is used as the +// bump duration. +// So this function should only return explicitly set bump durations. +func validWorkspaceTTLBumpMillis(millis *int64, defaultBump time.Duration) (time.Duration, error) { + if ptr.NilOrZero(millis) { + return defaultBump, nil + } + + dur := time.Duration(*millis) * time.Millisecond + truncated := dur.Truncate(time.Minute) + if truncated < ttlMin { + return -1, errTTLMin + } + + if truncated > ttlMax { + return -1, errTTLMax + } + + return truncated, nil +} + func validWorkspaceAutomaticUpdates(updates codersdk.AutomaticUpdates) (database.AutomaticUpdates, error) { if updates == "" { return database.AutomaticUpdatesNever, nil diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 002c8a7c00d8c..c4ed7392e93f3 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -707,6 +707,7 @@ func TestPostWorkspacesByOrganization(t *testing.T) { // Then: No TTL should be set by the template require.Nil(t, workspace.TTLMillis) + require.Zero(t, workspace.TTLMillis) }) t.Run("TemplateCustomTTL", func(t *testing.T) { @@ -715,8 +716,10 @@ func TestPostWorkspacesByOrganization(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) templateTTL := 24 * time.Hour.Milliseconds() + templateTTLBump := time.Hour.Milliseconds() template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = ptr.Ref(templateTTL) + ctr.DefaultTTLBumpMillis = ptr.Ref(templateTTLBump) }) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { @@ -727,6 +730,34 @@ func TestPostWorkspacesByOrganization(t *testing.T) { // TTL should be set by the template require.Equal(t, template.DefaultTTLMillis, templateTTL) require.Equal(t, template.DefaultTTLMillis, *workspace.TTLMillis) + require.Equal(t, template.DefaultTTLBumpMillis, templateTTLBump) + // Workspace inherits template TTL bump + require.Equal(t, template.DefaultTTLBumpMillis, workspace.TTLBumpMillis) + }) + + t.Run("TemplateCustomTTL", 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, nil) + templateTTL := 24 * time.Hour.Milliseconds() + templateTTLBump := templateTTL / 24 + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = ptr.Ref(templateTTL) + ctr.DefaultTTLBumpMillis = ptr.Ref(templateTTLBump) + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TTLMillis = nil // ensure that no default TTL is set + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // TTL should be set by the template + require.Equal(t, template.DefaultTTLMillis, templateTTL) + require.Equal(t, template.DefaultTTLMillis, *workspace.TTLMillis) + require.Equal(t, template.DefaultTTLBumpMillis, templateTTLBump) + // Workspace inherits template TTL bump + require.Equal(t, template.DefaultTTLBumpMillis, workspace.TTLBumpMillis) }) t.Run("InvalidTTL", func(t *testing.T) { @@ -1871,8 +1902,12 @@ func TestWorkspaceUpdateTTL(t *testing.T) { testCases := []struct { name string ttlMillis *int64 + ttlBumpMillis *int64 expectedError string modifyTemplate func(*codersdk.CreateTemplateRequest) + + expectedTTLMillis *int64 + expectedTTLBumpMillis int64 }{ { name: "disable ttl", @@ -1880,12 +1915,16 @@ func TestWorkspaceUpdateTTL(t *testing.T) { expectedError: "", modifyTemplate: func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = ptr.Ref((8 * time.Hour).Milliseconds()) + ctr.DefaultTTLBumpMillis = ptr.Ref((5 * time.Minute).Milliseconds()) }, + expectedTTLMillis: nil, + expectedTTLBumpMillis: 0, }, { - name: "update ttl", - ttlMillis: ptr.Ref(12 * time.Hour.Milliseconds()), - expectedError: "", + name: "update ttl", + ttlMillis: ptr.Ref(12 * time.Hour.Milliseconds()), + expectedTTLMillis: ptr.Ref(12 * time.Hour.Milliseconds()), + expectedError: "", modifyTemplate: func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = ptr.Ref((8 * time.Hour).Milliseconds()) }, @@ -1896,20 +1935,34 @@ func TestWorkspaceUpdateTTL(t *testing.T) { expectedError: "time until shutdown must be at least one minute", }, { - name: "minimum ttl", - ttlMillis: ptr.Ref(time.Minute.Milliseconds()), - expectedError: "", + name: "minimum ttl", + ttlMillis: ptr.Ref(time.Minute.Milliseconds()), + expectedTTLMillis: ptr.Ref(time.Minute.Milliseconds()), + expectedError: "", }, { - name: "maximum ttl", - ttlMillis: ptr.Ref((24 * 30 * time.Hour).Milliseconds()), - expectedError: "", + name: "maximum ttl", + ttlMillis: ptr.Ref((24 * 30 * time.Hour).Milliseconds()), + expectedTTLMillis: ptr.Ref((24 * 30 * time.Hour).Milliseconds()), + expectedError: "", }, { name: "above maximum ttl", ttlMillis: ptr.Ref((24*30*time.Hour + time.Minute).Milliseconds()), expectedError: "time until shutdown must be less than 30 days", }, + { + name: "update ttl bump", + ttlMillis: ptr.Ref(12 * time.Hour.Milliseconds()), + ttlBumpMillis: ptr.Ref(12 * time.Minute.Milliseconds()), + expectedError: "", + modifyTemplate: func(request *codersdk.CreateTemplateRequest) { + request.DefaultTTLMillis = ptr.Ref((8 * time.Hour).Milliseconds()) + request.DefaultTTLBumpMillis = ptr.Ref((5 * time.Minute).Milliseconds()) + }, + expectedTTLMillis: ptr.Ref(12 * time.Hour.Milliseconds()), + expectedTTLBumpMillis: 12 * time.Minute.Milliseconds(), + }, } for _, testCase := range testCases { @@ -1939,7 +1992,8 @@ func TestWorkspaceUpdateTTL(t *testing.T) { defer cancel() err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ - TTLMillis: testCase.ttlMillis, + TTLMillis: testCase.ttlMillis, + TTLBumpMillis: testCase.ttlBumpMillis, }) if testCase.expectedError != "" { @@ -1952,7 +2006,8 @@ func TestWorkspaceUpdateTTL(t *testing.T) { updated, err := client.Workspace(ctx, workspace.ID) require.NoError(t, err, "fetch updated workspace") - require.Equal(t, testCase.ttlMillis, updated.TTLMillis, "expected autostop ttl to equal requested") + require.EqualValues(t, testCase.expectedTTLMillis, updated.TTLMillis, "expected autostop ttl to equal requested") + require.Equal(t, testCase.expectedTTLBumpMillis, updated.TTLBumpMillis, "expected autostop ttl bump to equal requested") require.Eventually(t, func() bool { if len(auditor.AuditLogs()) != 7 { diff --git a/codersdk/organizations.go b/codersdk/organizations.go index cc206180f81ae..c47dababbce7c 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -84,6 +84,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"` + // DefaultTTLBumpMillis allows optionally specifying the default TTL + // bump based on workspace activity. If unset, the default TTL will be + // used as the bump amount. + DefaultTTLBumpMillis *int64 `json:"default_ttl_bump_ms,omitempty"` // TODO(@dean): remove max_ttl once autostop_requirement is matured MaxTTLMillis *int64 `json:"max_ttl_ms,omitempty"` // AutostopRequirement allows optionally specifying the autostop requirement @@ -140,6 +144,7 @@ type CreateWorkspaceRequest struct { Name string `json:"name" validate:"workspace_name,required"` AutostartSchedule *string `json:"autostart_schedule"` TTLMillis *int64 `json:"ttl_ms,omitempty"` + TTLBumpMillis *int64 `json:"ttl_bump_ms,omitempty"` // RichParameterValues allows for additional parameters to be provided // during the initial provision. RichParameterValues []WorkspaceBuildParameter `json:"rich_parameter_values,omitempty"` diff --git a/codersdk/templates.go b/codersdk/templates.go index 3a3240ca711b2..0f028ce313d41 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -24,11 +24,12 @@ type Template struct { Provisioner ProvisionerType `json:"provisioner" enums:"terraform"` ActiveVersionID uuid.UUID `json:"active_version_id" format:"uuid"` // ActiveUserCount is set to -1 when loading. - ActiveUserCount int `json:"active_user_count"` - BuildTimeStats TemplateBuildTimeStats `json:"build_time_stats"` - Description string `json:"description"` - Icon string `json:"icon"` - DefaultTTLMillis int64 `json:"default_ttl_ms"` + ActiveUserCount int `json:"active_user_count"` + BuildTimeStats TemplateBuildTimeStats `json:"build_time_stats"` + Description string `json:"description"` + Icon string `json:"icon"` + DefaultTTLMillis int64 `json:"default_ttl_ms"` + DefaultTTLBumpMillis int64 `json:"default_ttl_bump_ms"` // TODO(@dean): remove max_ttl once autostop_requirement is matured MaxTTLMillis int64 `json:"max_ttl_ms"` // AutostopRequirement and AutostartRequirement are enterprise features. Its @@ -198,11 +199,12 @@ type ACLAvailable 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"` + DefaultTTLBumpMillis int64 `json:"default_ttl_bump_ms,omitempty"` // TODO(@dean): remove max_ttl once autostop_requirement is matured MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"` // AutostopRequirement and AutostartRequirement can only be set if your license diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 307bbdb0d3b93..e6e4cdce3776f 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -42,6 +42,7 @@ type Workspace struct { Name string `json:"name"` AutostartSchedule *string `json:"autostart_schedule,omitempty"` TTLMillis *int64 `json:"ttl_ms,omitempty"` + TTLBumpMillis int64 `json:"ttl_bump_ms,omitempty"` LastUsedAt time.Time `json:"last_used_at" format:"date-time"` // DeletingAt indicates the time at which the workspace will be permanently deleted. @@ -266,7 +267,8 @@ func (c *Client) UpdateWorkspaceAutostart(ctx context.Context, id uuid.UUID, req // UpdateWorkspaceTTLRequest is a request to update a workspace's TTL. type UpdateWorkspaceTTLRequest struct { - TTLMillis *int64 `json:"ttl_ms"` + TTLMillis *int64 `json:"ttl_ms"` + TTLBumpMillis *int64 `json:"ttl_bump_ms"` } // UpdateWorkspaceTTL sets the ttl for workspace by id. diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index af7a5724458d7..8f027c1ddaaaa 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -8,19 +8,19 @@ We track the following resources: -| Resource | | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| +| Resource | | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
default_ttl_bumptrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
ttl_bumptrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index d3a61585d096c..22326bb5df9e1 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1574,6 +1574,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "days_of_week": ["monday"], "weeks": 0 }, + "default_ttl_bump_ms": 0, "default_ttl_ms": 0, "delete_ttl_ms": 0, "description": "string", @@ -1598,6 +1599,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `allow_user_cancel_workspace_jobs` | boolean | false | | Allow users to cancel in-progress workspace jobs. \*bool as the default value is "true". | | `autostart_requirement` | [codersdk.TemplateAutostartRequirement](#codersdktemplateautostartrequirement) | false | | Autostart requirement allows optionally specifying the autostart allowed days for workspaces created from this template. This is an enterprise feature. | | `autostop_requirement` | [codersdk.TemplateAutostopRequirement](#codersdktemplateautostoprequirement) | false | | Autostop requirement allows optionally specifying the autostop requirement for workspaces created from this template. This is an enterprise feature. | +| `default_ttl_bump_ms` | integer | false | | Default ttl bump ms allows optionally specifying the default TTL bump based on workspace activity. If unset, the default TTL will be used as the bump amount. | | `default_ttl_ms` | integer | false | | Default ttl ms allows optionally specifying the default TTL for all workspaces created from this template. | | `delete_ttl_ms` | integer | false | | Delete ttl ms allows optionally specifying the max lifetime before Coder permanently deletes dormant workspaces created from this template. | | `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. | @@ -1854,6 +1856,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ], "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", + "ttl_bump_ms": 0, "ttl_ms": 0 } ``` @@ -1868,6 +1871,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `rich_parameter_values` | array of [codersdk.WorkspaceBuildParameter](#codersdkworkspacebuildparameter) | false | | Rich parameter values allows for additional parameters to be provided during the initial provision. | | `template_id` | string | false | | Template ID specifies which template should be used for creating the workspace. | | `template_version_id` | string | false | | Template version ID can be used to specify a specific version of a template for creating the workspace. | +| `ttl_bump_ms` | integer | false | | | | `ttl_ms` | integer | false | | | ## codersdk.DAUEntry @@ -4411,6 +4415,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "created_at": "2019-08-24T14:15:22Z", "created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f", "created_by_name": "string", + "default_ttl_bump_ms": 0, "default_ttl_ms": 0, "description": "string", "display_name": "string", @@ -4443,6 +4448,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `created_at` | string | false | | | | `created_by_id` | string | false | | | | `created_by_name` | string | false | | | +| `default_ttl_bump_ms` | integer | false | | | | `default_ttl_ms` | integer | false | | | | `description` | string | false | | | | `display_name` | string | false | | | @@ -5298,15 +5304,17 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { + "ttl_bump_ms": 0, "ttl_ms": 0 } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| -------- | ------- | -------- | ------------ | ----------- | -| `ttl_ms` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------- | ------- | -------- | ------------ | ----------- | +| `ttl_bump_ms` | integer | false | | | +| `ttl_ms` | integer | false | | | ## codersdk.UploadResponse @@ -5816,6 +5824,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", "template_require_active_version": true, + "ttl_bump_ms": 0, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -5846,6 +5855,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `template_id` | string | false | | | | `template_name` | string | false | | | | `template_require_active_version` | boolean | false | | | +| `ttl_bump_ms` | integer | false | | | | `ttl_ms` | integer | false | | | | `updated_at` | string | false | | | @@ -7071,6 +7081,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", "template_require_active_version": true, + "ttl_bump_ms": 0, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } diff --git a/docs/api/templates.md b/docs/api/templates.md index 279ab1ff5cfb7..17a7607fbd05d 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -51,6 +51,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "created_at": "2019-08-24T14:15:22Z", "created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f", "created_by_name": "string", + "default_ttl_bump_ms": 0, "default_ttl_ms": 0, "description": "string", "display_name": "string", @@ -100,6 +101,7 @@ Status Code **200** | `» created_at` | string(date-time) | false | | | | `» created_by_id` | string(uuid) | false | | | | `» created_by_name` | string | false | | | +| `» default_ttl_bump_ms` | integer | false | | | | `» default_ttl_ms` | integer | false | | | | `» description` | string | false | | | | `» display_name` | string | false | | | @@ -151,6 +153,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "days_of_week": ["monday"], "weeks": 0 }, + "default_ttl_bump_ms": 0, "default_ttl_ms": 0, "delete_ttl_ms": 0, "description": "string", @@ -204,6 +207,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "created_at": "2019-08-24T14:15:22Z", "created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f", "created_by_name": "string", + "default_ttl_bump_ms": 0, "default_ttl_ms": 0, "description": "string", "display_name": "string", @@ -340,6 +344,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "created_at": "2019-08-24T14:15:22Z", "created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f", "created_by_name": "string", + "default_ttl_bump_ms": 0, "default_ttl_ms": 0, "description": "string", "display_name": "string", @@ -652,6 +657,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "created_at": "2019-08-24T14:15:22Z", "created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f", "created_by_name": "string", + "default_ttl_bump_ms": 0, "default_ttl_ms": 0, "description": "string", "display_name": "string", @@ -771,6 +777,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "created_at": "2019-08-24T14:15:22Z", "created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f", "created_by_name": "string", + "default_ttl_bump_ms": 0, "default_ttl_ms": 0, "description": "string", "display_name": "string", diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 209d7f34d2bac..ecac4f1b867e2 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -29,6 +29,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member ], "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", + "ttl_bump_ms": 0, "ttl_ms": 0 } ``` @@ -217,6 +218,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", "template_require_active_version": true, + "ttl_bump_ms": 0, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -427,6 +429,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", "template_require_active_version": true, + "ttl_bump_ms": 0, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -636,6 +639,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", "template_require_active_version": true, + "ttl_bump_ms": 0, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -847,6 +851,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", "template_require_active_version": true, + "ttl_bump_ms": 0, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -1173,6 +1178,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", "template_require_active_version": true, + "ttl_bump_ms": 0, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -1294,6 +1300,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/ttl \ ```json { + "ttl_bump_ms": 0, "ttl_ms": 0 } ``` diff --git a/docs/cli/create.md b/docs/cli/create.md index 0b3f63b32b3fd..947df7ea467e6 100644 --- a/docs/cli/create.md +++ b/docs/cli/create.md @@ -20,6 +20,15 @@ coder create [flags] [name] ## Options +### --activity-bump + +| | | +| ------- | --------------------- | +| Type | duration | +| Default | 0 | + +Specify a default amount of time to bump the deadline for the workspaces based on workspace activity. By default, activity will extend the deadline for a workspace by the 'stop-after' amount. + ### --automatic-updates | | | diff --git a/docs/cli/templates_create.md b/docs/cli/templates_create.md index 83c3b3c5b9aff..0567fd56da887 100644 --- a/docs/cli/templates_create.md +++ b/docs/cli/templates_create.md @@ -12,6 +12,15 @@ coder templates create [flags] [name] ## Options +### --default-activity-bump + +| | | +| ------- | --------------------- | +| Type | duration | +| Default | 0 | + +Specify a default amount of time to bump the deadline for the workspaces based on workspace activity. By default, activity will extend the deadline for a workspace by the 'default-ttl' amount. + ### --default-ttl | | | diff --git a/docs/cli/templates_edit.md b/docs/cli/templates_edit.md index cd65ac99ef9d0..1bea5a1012f55 100644 --- a/docs/cli/templates_edit.md +++ b/docs/cli/templates_edit.md @@ -47,6 +47,15 @@ Allow users to cancel in-progress workspace jobs. Edit the template autostart requirement weekdays - workspaces created from this template can only autostart on the given weekdays. To unset this value for the template (and allow autostart on all days), pass 'all'. +### --default-activity-bump + +| | | +| ------- | --------------------- | +| Type | duration | +| Default | 0 | + +Specify a default amount of time to bump the deadline for the workspaces based on workspace activity. By default, activity will extend the deadline for a workspace by the 'default-ttl' amount. + ### --default-ttl | | | diff --git a/docs/cli/templates_list.md b/docs/cli/templates_list.md index 7e418e32c35c2..55b231c6d636a 100644 --- a/docs/cli/templates_list.md +++ b/docs/cli/templates_list.md @@ -23,7 +23,7 @@ coder templates list [flags] | Type | string-array | | Default | name,last updated,used by | -Columns to display in table output. Available columns: name, created at, last updated, organization id, provisioner, active version id, used by, default ttl. +Columns to display in table output. Available columns: name, created at, last updated, organization id, provisioner, active version id, used by, default ttl, default ttl bump. ### -o, --output diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index f272354d649ac..0e72952fd4cb0 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -70,6 +70,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "description": ActionTrack, "icon": ActionTrack, "default_ttl": ActionTrack, + "default_ttl_bump": ActionTrack, "max_ttl": ActionTrack, "autostart_block_days_of_week": ActionTrack, "autostop_requirement_days_of_week": ActionTrack, @@ -129,6 +130,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "name": ActionTrack, "autostart_schedule": ActionTrack, "ttl": ActionTrack, + "ttl_bump": ActionTrack, "last_used_at": ActionIgnore, "dormant_at": ActionTrack, "deleting_at": ActionTrack, diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 26b0c78c6bfc9..68baf45dd5aef 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -80,6 +80,7 @@ func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.S UserAutostartEnabled: tpl.AllowUserAutostart, UserAutostopEnabled: tpl.AllowUserAutostop, DefaultTTL: time.Duration(tpl.DefaultTTL), + DefaultTTLBump: time.Duration(tpl.DefaultTTLBump), MaxTTL: time.Duration(tpl.MaxTTL), UseAutostopRequirement: s.UseAutostopRequirement.Load(), AutostopRequirement: agpl.TemplateAutostopRequirement{ @@ -107,7 +108,8 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S tpl.AutostopRequirementWeeks = 1 } - if int64(opts.DefaultTTL) == tpl.DefaultTTL && + if int64(opts.DefaultTTLBump) == tpl.DefaultTTLBump && + int64(opts.DefaultTTL) == tpl.DefaultTTL && int64(opts.MaxTTL) == tpl.MaxTTL && int16(opts.AutostopRequirement.DaysOfWeek) == tpl.AutostopRequirementDaysOfWeek && opts.AutostartRequirement.DaysOfWeek == tpl.AutostartAllowedDays() && @@ -142,6 +144,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S AllowUserAutostart: opts.UserAutostartEnabled, AllowUserAutostop: opts.UserAutostopEnabled, DefaultTTL: int64(opts.DefaultTTL), + DefaultTTLBump: int64(opts.DefaultTTLBump), MaxTTL: int64(opts.MaxTTL), AutostopRequirementDaysOfWeek: int16(opts.AutostopRequirement.DaysOfWeek), AutostopRequirementWeeks: opts.AutostopRequirement.Weeks, diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 9ff7799553d03..40ad33d132c66 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -14,6 +14,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" @@ -89,6 +90,33 @@ func TestTemplates(t *testing.T) { require.Contains(t, apiErr.Validations[0].Detail, "time until shutdown must be less than or equal to the template's maximum TTL") }) + t.Run("PatchTTLBump", func(t *testing.T) { + t.Parallel() + rootClient, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }, + }) + client, _ := coderdtest.CreateAnotherUser(t, rootClient, user.OrganizationID, rbac.RoleTemplateAdmin()) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = ptr.Ref(5 * time.Hour.Milliseconds()) + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + exp := 10 * time.Hour.Milliseconds() + found, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + DefaultTTLBumpMillis: exp, + }) + require.NoError(t, err) + require.Equal(t, exp, found.DefaultTTLBumpMillis) + }) + t.Run("BlockDisablingAutoOffWithMaxTTL", func(t *testing.T) { t.Parallel() client, user := coderdenttest.New(t, &coderdenttest.Options{ diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 032c3854138dd..aba25fd924c21 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -211,6 +211,7 @@ export interface CreateTemplateRequest { readonly icon?: string; readonly template_version_id: string; readonly default_ttl_ms?: number; + readonly default_ttl_bump_ms?: number; readonly max_ttl_ms?: number; readonly autostop_requirement?: TemplateAutostopRequirement; readonly autostart_requirement?: TemplateAutostartRequirement; @@ -296,6 +297,7 @@ export interface CreateWorkspaceRequest { readonly name: string; readonly autostart_schedule?: string; readonly ttl_ms?: number; + readonly ttl_bump_ms?: number; readonly rich_parameter_values?: WorkspaceBuildParameter[]; readonly automatic_updates?: AutomaticUpdates; } @@ -920,6 +922,7 @@ export interface Template { readonly description: string; readonly icon: string; readonly default_ttl_ms: number; + readonly default_ttl_bump_ms: number; readonly max_ttl_ms: number; readonly autostop_requirement: TemplateAutostopRequirement; readonly autostart_requirement: TemplateAutostartRequirement; @@ -1171,6 +1174,7 @@ export interface UpdateTemplateMeta { readonly description?: string; readonly icon?: string; readonly default_ttl_ms?: number; + readonly default_ttl_bump_ms?: number; readonly max_ttl_ms?: number; readonly autostop_requirement?: TemplateAutostopRequirement; readonly autostart_requirement?: TemplateAutostartRequirement; @@ -1230,6 +1234,7 @@ export interface UpdateWorkspaceRequest { // From codersdk/workspaces.go export interface UpdateWorkspaceTTLRequest { readonly ttl_ms?: number; + readonly ttl_bump_ms?: number; } // From codersdk/files.go @@ -1371,6 +1376,7 @@ export interface Workspace { readonly name: string; readonly autostart_schedule?: string; readonly ttl_ms?: number; + readonly ttl_bump_ms?: number; readonly last_used_at: string; readonly deleting_at?: string; readonly dormant_at?: string; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 1f94711d23ebe..4dbf4c59384b4 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -59,6 +59,7 @@ export interface CreateTemplateData { display_name: string; description: string; icon: string; + default_ttl_bump_hours: number; default_ttl_hours: number; max_ttl_hours: number; autostart_requirement_days_of_week: TemplateAutostartRequirementDaysValue[]; @@ -105,6 +106,7 @@ const defaultInitialValues: CreateTemplateData = { description: "", icon: "", default_ttl_hours: 24, + default_ttl_bump_hours: 0, // max_ttl is an enterprise-only feature, and the server ignores the value if // you are not licensed. We hide the form value based on entitlements. // diff --git a/site/src/pages/CreateTemplatePage/utils.ts b/site/src/pages/CreateTemplatePage/utils.ts index fdeb1046d3869..a8bcc5bea1af3 100644 --- a/site/src/pages/CreateTemplatePage/utils.ts +++ b/site/src/pages/CreateTemplatePage/utils.ts @@ -14,6 +14,7 @@ const provisioner: ProvisionerType = export const newTemplate = (formData: CreateTemplateData) => { const { default_ttl_hours, + default_ttl_bump_hours, max_ttl_hours, parameter_values_by_name, allow_everyone_group_access, @@ -27,6 +28,7 @@ export const newTemplate = (formData: CreateTemplateData) => { ...safeTemplateData, disable_everyone_group_access: !formData.allow_everyone_group_access, default_ttl_ms: formData.default_ttl_hours * 60 * 60 * 1000, // Convert hours to ms + default_ttl_bump_ms: formData.default_ttl_bump_hours * 60 * 60 * 1000, // Convert hours to ms max_ttl_ms: formData.max_ttl_hours * 60 * 60 * 1000, // Convert hours to ms autostop_requirement: { days_of_week: calculateAutostopRequirementDaysValue( diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index c9f2059672fce..b2a851190bda8 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -12,7 +12,10 @@ import { getValidationSchema } from "./TemplateSettingsForm"; import { TemplateSettingsPage } from "./TemplateSettingsPage"; type FormValues = Required< - Omit + Omit< + UpdateTemplateMeta, + "default_ttl_ms" | "max_ttl_ms" | "default_ttl_bump_ms" + > >; const validFormValues: FormValues = { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx index caa038cd47a8f..b3d2d01ecff55 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TTLHelperText.tsx @@ -21,6 +21,35 @@ export const DefaultTTLHelperText = (props: { ttl?: number }) => { ); }; +export const DefaultTTLBumpHelperText = (props: { + ttl?: number; + ttl_bump?: number; +}) => { + const { ttl_bump = 0 } = props; + const { ttl = 0 } = props; + + // Error will show once field is considered touched + if (ttl_bump < 0) { + return null; + } + + if (ttl_bump === 0) { + return ( + + Workspace lifetime will default to being extended by the default + autostop duration of {ttl} {hours(ttl)} from workspace activity. + + ); + } + + return ( + + Workspace lifetime will be extended by {ttl_bump} {hours(ttl_bump)} from + workspace activity. + + ); +}; + export const MaxTTLHelperText = (props: { ttl?: number }) => { const { ttl = 0 } = props; diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 912cc070ccb79..95403a2a2866f 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -27,6 +27,7 @@ import { import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers"; import { DefaultTTLHelperText, + DefaultTTLBumpHelperText, DormancyAutoDeletionTTLHelperText, DormancyTTLHelperText, FailureTTLHelperText, @@ -76,6 +77,7 @@ export const TemplateScheduleForm: FC = ({ initialValues: { // on display, convert from ms => hours default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION, + default_ttl_bump_ms: template.default_ttl_bump_ms / MS_HOUR_CONVERSION, // the API ignores these values, but to avoid tripping up validation set // it to zero if the user can't set the field. max_ttl_ms: allowAdvancedScheduling @@ -205,6 +207,9 @@ export const TemplateScheduleForm: FC = ({ default_ttl_ms: form.values.default_ttl_ms ? form.values.default_ttl_ms * MS_HOUR_CONVERSION : undefined, + default_ttl_bump_ms: form.values.default_ttl_bump_ms + ? form.values.default_ttl_bump_ms * MS_HOUR_CONVERSION + : undefined, max_ttl_ms: form.values.max_ttl_ms ? form.values.max_ttl_ms * MS_HOUR_CONVERSION : undefined, @@ -327,17 +332,34 @@ export const TemplateScheduleForm: FC = ({ description="Define when workspaces created from this template are stopped." > - , - )} - disabled={isSubmitting} - fullWidth - inputProps={{ min: 0, step: 1 }} - label="Default autostop (hours)" - type="number" - /> + + , + )} + disabled={isSubmitting} + fullWidth + inputProps={{ min: 0, step: 1 }} + label="Default autostop (hours)" + type="number" + /> + + , + )} + disabled={isSubmitting} + fullWidth + inputProps={{ min: 0, step: 1 }} + label="Default activity bump (hours)" + type="number" + /> + {!allowAutostopRequirement && ( { ); }); + it("allows a default ttl bump of 1 hour", () => { + const values: TemplateScheduleFormValues = { + ...validFormValues, + default_ttl_ms: 24 * 7, + default_ttl_bump_ms: 1, + }; + const validate = () => getValidationSchema().validateSync(values); + expect(validate).not.toThrowError(); + }); + it("allows a failure ttl of 7 days", () => { const values: TemplateScheduleFormValues = { ...validFormValues, diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/formHelpers.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/formHelpers.tsx index 9fc299fba1fe1..23a171b9071fa 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/formHelpers.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/formHelpers.tsx @@ -29,6 +29,13 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => 24 * MAX_TTL_DAYS /* 30 days in hours */, "Please enter a limit that is less than or equal to 720 hours (30 days).", ), + default_ttl_bump_ms: Yup.number() + .integer() + .min(0, "Default activity bump duration must not be less than 0.") + .max( + 24 * MAX_TTL_DAYS /* 30 days in hours */, + "Please enter a limit that is less than or equal to 720 hours (30 days).", + ), max_ttl_ms: Yup.number() .integer() .min(0, "Maximum time until autostop must not be less than 0.") diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.stories.tsx index 6b643fd911bff..ccd0e3e3b0c58 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.stories.tsx @@ -32,6 +32,7 @@ const defaultInitialValues = { ...defaultSchedule(), autostopEnabled: true, ttl: 24, + ttl_bump: emptyTTL, }; export const AllDisabled: Story = { @@ -41,6 +42,7 @@ export const AllDisabled: Story = { ...emptySchedule, autostopEnabled: false, ttl: emptyTTL, + ttl_bump: emptyTTL, }, enableAutoStart: false, enableAutoStop: false, @@ -54,6 +56,7 @@ export const Autostart: Story = { ...defaultSchedule(), autostopEnabled: false, ttl: emptyTTL, + ttl_bump: emptyTTL, }, enableAutoStop: false, }, @@ -61,25 +64,31 @@ export const Autostart: Story = { export const WorkspaceWillShutdownInTwoHours: Story = { args: { - initialValues: { ...defaultInitialValues, ttl: 2 }, + initialValues: { ...defaultInitialValues, ttl: 2, ttl_bump: emptyTTL }, }, }; export const WorkspaceWillShutdownInADay: Story = { args: { - initialValues: { ...defaultInitialValues, ttl: 24 }, + initialValues: { ...defaultInitialValues, ttl: 24, ttl_bump: emptyTTL }, + }, +}; + +export const WorkspaceWillShutdownInADayBump2Hours: Story = { + args: { + initialValues: { ...defaultInitialValues, ttl: 24, ttl_bump: 2 }, }, }; export const WorkspaceWillShutdownInTwoDays: Story = { args: { - initialValues: { ...defaultInitialValues, ttl: 48 }, + initialValues: { ...defaultInitialValues, ttl: 48, ttl_bump: emptyTTL }, }, }; export const WithError: Story = { args: { - initialValues: { ...defaultInitialValues, ttl: 100 }, + initialValues: { ...defaultInitialValues, ttl: 100, ttl_bump: emptyTTL }, initialTouched: { ttl: true }, submitScheduleError: mockApiError({ message: "Something went wrong.", diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.test.ts b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.test.ts index 57c5849180af1..27f9e1de7496f 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.test.ts +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.test.ts @@ -20,6 +20,7 @@ const valid: WorkspaceScheduleFormValues = { autostopEnabled: true, ttl: 120, + ttl_bump: 0, }; describe("validationSchema", () => { @@ -38,6 +39,7 @@ describe("validationSchema", () => { autostopEnabled: false, ttl: 0, + ttl_bump: 0, }; const validate = () => validationSchema.validateSync(values); expect(validate).not.toThrow(); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.tsx index 3f3b7baca1621..827da26a30aaa 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.tsx @@ -61,6 +61,7 @@ export const Language = { startTimeLabel: "Start time", timezoneLabel: "Timezone", ttlLabel: "Time until shutdown (hours)", + ttlBumpLabel: "Lifetime extended by activity (hours)", ttlCausesShutdownHelperText: "Your workspace will shut down", ttlCausesShutdownAfterStart: "after its next start. We delay shutdown by this time whenever we detect activity", @@ -84,6 +85,7 @@ export interface WorkspaceScheduleFormProps { // for storybook initialTouched?: FormikTouched; defaultTTL: number; + defaultTTLBump: number; } export interface WorkspaceScheduleFormValues { @@ -100,6 +102,7 @@ export interface WorkspaceScheduleFormValues { autostopEnabled: boolean; ttl: number; + ttl_bump: number; } export const validationSchema = Yup.object({ @@ -196,6 +199,7 @@ export const WorkspaceScheduleForm: FC< onSubmit, initialTouched, defaultTTL, + defaultTTLBump, enableAutoStop, enableAutoStart, }) => { @@ -279,6 +283,7 @@ export const WorkspaceScheduleForm: FC< ...form.values, autostopEnabled: true, ttl: defaultTTL, + ttl_bump: defaultTTLBump, }); } }; @@ -409,6 +414,18 @@ export const WorkspaceScheduleForm: FC< type="number" fullWidth /> + @@ -426,3 +443,18 @@ export const ttlShutdownAt = (formTTL: number): string => { .humanize()} ${Language.ttlCausesShutdownAfterStart}.`; } }; + +export const ttlBumpShutdownAt = ( + formTTL: number, + formTTLBump: number, +): string => { + const ttlHuman = dayjs.duration(formTTL, "hours").humanize(); + const ttlBumpHuman = dayjs.duration(formTTLBump, "hours").humanize(); + + if (formTTLBump < 1) { + // Passing an empty value for TTL in the form results in a number that is not zero but less than 1. + return `Defaults to the "Time until shutdown". Workspace activity will extend the lifetime of the workspace by ${ttlHuman}.`; + } else { + return `Workspace activity will extend the lifetime of the workspace by ${ttlBumpHuman}.`; + } +}; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index cf3f94cef47e9..80ed628005555 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -29,6 +29,7 @@ const validValues: WorkspaceScheduleFormValues = { timezone: "Canada/Eastern", autostopEnabled: true, ttl: 120, + ttl_bump: 0, }; describe("WorkspaceSchedulePage", () => { @@ -49,6 +50,7 @@ describe("WorkspaceSchedulePage", () => { timezone: "", autostopEnabled: false, ttl: 0, + ttl_bump: 0, }, { schedule: "", @@ -69,6 +71,7 @@ describe("WorkspaceSchedulePage", () => { timezone: "Canada/Eastern", autostopEnabled: true, ttl: 120, + ttl_bump: 0, }, { schedule: "CRON_TZ=Canada/Eastern 20 16 * * 0", @@ -89,6 +92,7 @@ describe("WorkspaceSchedulePage", () => { timezone: "America/Central", autostopEnabled: true, ttl: 120, + ttl_bump: 0, }, { schedule: "CRON_TZ=America/Central 30 09 * * 1-5", @@ -109,6 +113,7 @@ describe("WorkspaceSchedulePage", () => { timezone: "", autostopEnabled: true, ttl: 60 * 8, + ttl_bump: 0, }, { schedule: "00 09 * * *", @@ -129,6 +134,7 @@ describe("WorkspaceSchedulePage", () => { timezone: "", autostopEnabled: true, ttl: 60 * 3, + ttl_bump: 0, }, { schedule: "20 16 * * 1,3,5", @@ -239,14 +245,17 @@ describe("WorkspaceSchedulePage", () => { describe("ttlMsToAutostop", () => { it.each([ // empty case - [undefined, { autostopEnabled: false, ttl: 0 }], + [undefined, undefined, { autostopEnabled: false, ttl: 0, ttl_bump: 0 }], // zero - [0, { autostopEnabled: false, ttl: 0 }], + [0, 0, { autostopEnabled: false, ttl: 0, ttl_bump: 0 }], // basic case - [28_800_000, { autostopEnabled: true, ttl: 8 }], - ] as const)(`ttlMsToAutostop(%p) returns %p`, (ttlMs, autostop) => { - expect(ttlMsToAutostop(ttlMs)).toEqual(autostop); - }); + [28_800_000, 3600, { autostopEnabled: true, ttl: 8, ttl_bump: 0 }], + ] as const)( + `ttlMsToAutostop(%p) returns %p`, + (ttlMs, ttlBumpMs, autostop) => { + expect(ttlMsToAutostop(ttlMs, ttlBumpMs)).toEqual(autostop); + }, + ); }); describe("autostop", () => { diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index b54edcf437e61..77fb267a19237 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -107,6 +107,9 @@ export const WorkspaceSchedulePage: FC = () => { }} isLoading={submitScheduleMutation.isLoading} defaultTTL={dayjs.duration(template.default_ttl_ms, "ms").asHours()} + defaultTTLBump={dayjs + .duration(template.default_ttl_bump_ms, "ms") + .asHours()} onCancel={() => { navigate(`/@${username}/${workspaceName}`); }} @@ -154,7 +157,7 @@ const getAutostart = (workspace: TypesGen.Workspace) => scheduleToAutostart(workspace.autostart_schedule); const getAutostop = (workspace: TypesGen.Workspace) => - ttlMsToAutostop(workspace.ttl_ms); + ttlMsToAutostop(workspace.ttl_ms, workspace.ttl_bump_ms); type SubmitScheduleData = { workspace: TypesGen.Workspace; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/formToRequest.ts b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/formToRequest.ts index 8096512e973db..4c4b50f022239 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/formToRequest.ts +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/formToRequest.ts @@ -73,5 +73,9 @@ export const formValuesToTTLRequest = ( values.autostopEnabled && values.ttl ? values.ttl * 60 * 60 * 1000 : undefined, + ttl_bump_ms: + values.autostopEnabled && values.ttl_bump + ? values.ttl_bump * 60 * 60 * 1000 + : undefined, }; }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule.test.ts b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule.test.ts index 765d6547970c8..a9f6a682ac2d0 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule.test.ts +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule.test.ts @@ -10,6 +10,7 @@ describe("scheduleChanged", () => { ...emptySchedule, autostopEnabled: false, ttl: emptyTTL, + ttl_bump: emptyTTL, }; expect(scheduleChanged(autostart, formValues)).toBe(true); }); @@ -20,6 +21,7 @@ describe("scheduleChanged", () => { ...{ ...emptySchedule, monday: true, startTime: "09:00" }, autostopEnabled: false, ttl: emptyTTL, + ttl_bump: emptyTTL, }; expect(scheduleChanged(autostart, formValues)).toBe(true); }); @@ -30,6 +32,7 @@ describe("scheduleChanged", () => { ...emptySchedule, autostopEnabled: false, ttl: emptyTTL, + ttl_bump: emptyTTL, }; expect(scheduleChanged(autostart, formValues)).toBe(false); }); @@ -37,32 +40,46 @@ describe("scheduleChanged", () => { describe("autostop", () => { it("should be true if toggle values are different", () => { - const autostop = { autostopEnabled: true, ttl: 1000 }; + const autostop = { autostopEnabled: true, ttl: 1000, ttl_bump: emptyTTL }; const formValues = { autostartEnabled: false, ...emptySchedule, autostopEnabled: false, ttl: 1000, + ttl_bump: emptyTTL, }; expect(scheduleChanged(autostop, formValues)).toBe(true); }); it("should be true if ttl values are different", () => { - const autostop = { autostopEnabled: true, ttl: 1000 }; + const autostop = { autostopEnabled: true, ttl: 1000, ttl_bump: emptyTTL }; const formValues = { autostartEnabled: false, ...emptySchedule, autostopEnabled: true, ttl: 2000, + ttl_bump: emptyTTL, + }; + expect(scheduleChanged(autostop, formValues)).toBe(true); + }); + it("should be true if ttl_bump values are different", () => { + const autostop = { autostopEnabled: true, ttl: 1000, ttl_bump: emptyTTL }; + const formValues = { + autostartEnabled: false, + ...emptySchedule, + autostopEnabled: true, + ttl: 1000, + ttl_bump: 500, }; expect(scheduleChanged(autostop, formValues)).toBe(true); }); it("should be false if all autostop values are the same", () => { - const autostop = { autostopEnabled: true, ttl: 1000 }; + const autostop = { autostopEnabled: true, ttl: 1000, ttl_bump: emptyTTL }; const formValues = { autostartEnabled: false, ...emptySchedule, autostopEnabled: true, ttl: 1000, + ttl_bump: emptyTTL, }; expect(scheduleChanged(autostop, formValues)).toBe(false); }); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl.ts b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl.ts index 7118494587662..6f93be96f8399 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl.ts +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl.ts @@ -1,13 +1,21 @@ export interface Autostop { autostopEnabled: boolean; ttl: number; + ttl_bump: number; } export const emptyTTL = 0; const msToHours = (ms: number) => Math.round(ms / (1000 * 60 * 60)); -export const ttlMsToAutostop = (ttl_ms?: number): Autostop => +export const ttlMsToAutostop = ( + ttl_ms?: number, + ttl_bump_ms?: number, +): Autostop => ttl_ms - ? { autostopEnabled: true, ttl: msToHours(ttl_ms) } - : { autostopEnabled: false, ttl: 0 }; + ? { + autostopEnabled: true, + ttl: msToHours(ttl_ms), + ttl_bump: msToHours(ttl_bump_ms || 0), + } + : { autostopEnabled: false, ttl: 0, ttl_bump: msToHours(ttl_bump_ms || 0) }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index ab3de6314098a..dc05c09559b32 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -440,6 +440,7 @@ export const MockTemplate: TypesGen.Template = { }, description: "This is a test description.", default_ttl_ms: 24 * 60 * 60 * 1000, + default_ttl_bump_ms: 2, max_ttl_ms: 2 * 24 * 60 * 60 * 1000, autostop_requirement: { days_of_week: [],