diff --git a/cli/autostart_test.go b/cli/autostart_test.go index accfd5c0d45ff..fd295fa8e49a7 100644 --- a/cli/autostart_test.go +++ b/cli/autostart_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "testing" + "time" "github.com/stretchr/testify/require" @@ -158,4 +159,26 @@ func TestAutostart(t *testing.T) { require.NoError(t, err, "fetch updated workspace") require.Equal(t, expectedSchedule, *updated.AutostartSchedule, "expected default autostart schedule") }) + + t.Run("BelowTemplateConstraint", func(t *testing.T) { + t.Parallel() + + var ( + client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds()) + }) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + cmdArgs = []string{"autostart", "enable", workspace.Name, "--minute", "*", "--hour", "*"} + ) + + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.ErrorContains(t, err, "schedule: Minimum autostart interval 1m0s below template minimum 1h0m0s") + }) } diff --git a/cli/bump_test.go b/cli/bump_test.go index da00bb33b7fd3..041f8996919cf 100644 --- a/cli/bump_test.go +++ b/cli/bump_test.go @@ -10,6 +10,7 @@ import ( "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" ) @@ -152,12 +153,23 @@ func TestBump(t *testing.T) { cmdArgs = []string{"bump", workspace.Name} stdoutBuf = &bytes.Buffer{} ) + // Unset the workspace TTL + err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}) + require.NoError(t, err) + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Nil(t, workspace.TTLMillis) // Given: we wait for the workspace to build coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) workspace, err = client.Workspace(ctx, workspace.ID) require.NoError(t, err) + // TODO(cian): need to stop and start the workspace as we do not update the deadline yet + // see: https://github.com/coder/coder/issues/1783 + coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart) + // Assert test invariant: workspace has no TTL set require.Zero(t, workspace.LatestBuild.Deadline) require.NoError(t, err) diff --git a/cli/create.go b/cli/create.go index 6852ec4f90449..4a178bf1f5a2c 100644 --- a/cli/create.go +++ b/cli/create.go @@ -61,20 +61,6 @@ func create() *cobra.Command { } } - tz, err := time.LoadLocation(tzName) - if err != nil { - return xerrors.Errorf("Invalid workspace autostart timezone: %w", err) - } - schedSpec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", tz.String(), autostartMinute, autostartHour, autostartDow) - _, err = schedule.Weekly(schedSpec) - if err != nil { - return xerrors.Errorf("invalid workspace autostart schedule: %w", err) - } - - if ttl == 0 { - return xerrors.Errorf("TTL must be at least 1 minute") - } - _, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName) if err == nil { return xerrors.Errorf("A workspace already exists named %q!", workspaceName) @@ -129,6 +115,23 @@ func create() *cobra.Command { } } + schedSpec, err := validSchedule( + autostartMinute, + autostartHour, + autostartDow, + tzName, + time.Duration(template.MinAutostartIntervalMillis)*time.Millisecond, + ) + if err != nil { + return xerrors.Errorf("Invalid autostart schedule: %w", err) + } + if ttl < time.Minute { + return xerrors.Errorf("TTL must be at least 1 minute") + } + if ttlMax := time.Duration(template.MaxTTLMillis) * time.Millisecond; ttl > ttlMax { + return xerrors.Errorf("TTL must be below template maximum %s", ttlMax) + } + templateVersion, err := client.TemplateVersion(cmd.Context(), template.ActiveVersionID) if err != nil { return err @@ -226,7 +229,7 @@ func create() *cobra.Command { workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.CreateWorkspaceRequest{ TemplateID: template.ID, Name: workspaceName, - AutostartSchedule: &schedSpec, + AutostartSchedule: schedSpec, TTLMillis: ptr.Ref(ttl.Milliseconds()), ParameterValues: parameters, }) @@ -262,7 +265,27 @@ func create() *cobra.Command { cliflag.StringVarP(cmd.Flags(), &autostartMinute, "autostart-minute", "", "CODER_WORKSPACE_AUTOSTART_MINUTE", "0", "Specify the minute(s) at which the workspace should autostart (e.g. 0).") cliflag.StringVarP(cmd.Flags(), &autostartHour, "autostart-hour", "", "CODER_WORKSPACE_AUTOSTART_HOUR", "9", "Specify the hour(s) at which the workspace should autostart (e.g. 9).") cliflag.StringVarP(cmd.Flags(), &autostartDow, "autostart-day-of-week", "", "CODER_WORKSPACE_AUTOSTART_DOW", "MON-FRI", "Specify the days(s) on which the workspace should autostart (e.g. MON,TUE,WED,THU,FRI)") - cliflag.StringVarP(cmd.Flags(), &tzName, "tz", "", "TZ", "", "Specify your timezone location for workspace autostart (e.g. US/Central).") + cliflag.StringVarP(cmd.Flags(), &tzName, "tz", "", "TZ", "UTC", "Specify your timezone location for workspace autostart (e.g. US/Central).") cliflag.DurationVarP(cmd.Flags(), &ttl, "ttl", "", "CODER_WORKSPACE_TTL", 8*time.Hour, "Specify a time-to-live (TTL) for the workspace (e.g. 8h).") return cmd } + +func validSchedule(minute, hour, dow, tzName string, min time.Duration) (*string, error) { + _, err := time.LoadLocation(tzName) + if err != nil { + return nil, xerrors.Errorf("Invalid workspace autostart timezone: %w", err) + } + + schedSpec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", tzName, minute, hour, dow) + + sched, err := schedule.Weekly(schedSpec) + if err != nil { + return nil, err + } + + if schedMin := sched.Min(); schedMin < min { + return nil, xerrors.Errorf("minimum autostart interval %s is above template constraint %s", schedMin, min) + } + + return &schedSpec, nil +} diff --git a/cli/create_test.go b/cli/create_test.go index 9675a68fc3139..1352ee18b0291 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -14,6 +14,7 @@ import ( "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" @@ -62,6 +63,57 @@ func TestCreate(t *testing.T) { <-doneChan }) + t.Run("AboveTemplateMaxTTL", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.MaxTTLMillis = ptr.Ref((12 * time.Hour).Milliseconds()) + }) + args := []string{ + "create", + "my-workspace", + "--template", template.Name, + "--ttl", "12h1m", + "-y", // don't bother with waiting + } + cmd, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + err := cmd.Execute() + assert.ErrorContains(t, err, "TTL must be below template maximum 12h0m0s") + }) + + t.Run("BelowTemplateMinAutostartInterval", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds()) + }) + args := []string{ + "create", + "my-workspace", + "--template", template.Name, + "--autostart-minute", "*", // Every minute + "--autostart-hour", "*", // Every hour + "-y", // don't bother with waiting + } + cmd, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + err := cmd.Execute() + assert.ErrorContains(t, err, "minimum autostart interval 1m0s is above template constraint 1h0m0s") + }) + t.Run("CreateErrInvalidTz", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) @@ -74,19 +126,15 @@ func TestCreate(t *testing.T) { "my-workspace", "--template", template.Name, "--tz", "invalid", + "-y", } cmd, root := clitest.New(t, args...) clitest.SetupConfig(t, client, root) - doneChan := make(chan struct{}) pty := ptytest.New(t) cmd.SetIn(pty.Input()) cmd.SetOut(pty.Output()) - go func() { - defer close(doneChan) - err := cmd.Execute() - assert.EqualError(t, err, "Invalid workspace autostart timezone: unknown time zone invalid") - }() - <-doneChan + err := cmd.Execute() + assert.ErrorContains(t, err, "Invalid autostart schedule: Invalid workspace autostart timezone: unknown time zone invalid") }) t.Run("CreateErrInvalidTTL", func(t *testing.T) { @@ -101,19 +149,15 @@ func TestCreate(t *testing.T) { "my-workspace", "--template", template.Name, "--ttl", "0s", + "-y", } cmd, root := clitest.New(t, args...) clitest.SetupConfig(t, client, root) - doneChan := make(chan struct{}) pty := ptytest.New(t) cmd.SetIn(pty.Input()) cmd.SetOut(pty.Output()) - go func() { - defer close(doneChan) - err := cmd.Execute() - assert.EqualError(t, err, "TTL must be at least 1 minute") - }() - <-doneChan + err := cmd.Execute() + assert.EqualError(t, err, "TTL must be at least 1 minute") }) t.Run("CreateFromListWithSkip", func(t *testing.T) { diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 06205aa008eb2..936c574b57396 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -14,6 +14,7 @@ import ( "github.com/coder/coder/cli/cliui" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisionerd" "github.com/coder/coder/provisionersdk" @@ -21,9 +22,11 @@ import ( func templateCreate() *cobra.Command { var ( - directory string - provisioner string - parameterFile string + directory string + provisioner string + parameterFile string + maxTTL time.Duration + minAutostartInterval time.Duration ) cmd := &cobra.Command{ Use: "create [name]", @@ -92,11 +95,15 @@ func templateCreate() *cobra.Command { return err } - _, err = client.CreateTemplate(cmd.Context(), organization.ID, codersdk.CreateTemplateRequest{ - Name: templateName, - VersionID: job.ID, - ParameterValues: parameters, - }) + createReq := codersdk.CreateTemplateRequest{ + Name: templateName, + VersionID: job.ID, + ParameterValues: parameters, + MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), + MinAutostartIntervalMillis: ptr.Ref(minAutostartInterval.Milliseconds()), + } + + _, err = client.CreateTemplate(cmd.Context(), organization.ID, createReq) if err != nil { return err } @@ -115,6 +122,8 @@ func templateCreate() *cobra.Command { cmd.Flags().StringVarP(&directory, "directory", "d", currentDirectory, "Specify the directory to create from") cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend") cmd.Flags().StringVarP(¶meterFile, "parameter-file", "", "", "Specify a file path with parameter values.") + cmd.Flags().DurationVarP(&maxTTL, "max-ttl", "", 168*time.Hour, "Specify a maximum TTL for worksapces created from this template.") + cmd.Flags().DurationVarP(&minAutostartInterval, "min-autostart-interval", "", time.Hour, "Specify a minimum autostart interval for workspaces created from this template.") // This is for testing! err := cmd.Flags().MarkHidden("test.provisioner") if err != nil { diff --git a/cli/templatecreate_test.go b/cli/templatecreate_test.go index 2dead6ee24b69..e3d619ccdf629 100644 --- a/cli/templatecreate_test.go +++ b/cli/templatecreate_test.go @@ -24,7 +24,16 @@ func TestTemplateCreate(t *testing.T) { Parse: echo.ParseComplete, Provision: echo.ProvisionComplete, }) - cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho)) + args := []string{ + "templates", + "create", + "my-template", + "--directory", source, + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--max-ttl", "24h", + "--min-autostart-interval", "2h", + } + cmd, root := clitest.New(t, args...) clitest.SetupConfig(t, client, root) pty := ptytest.New(t) cmd.SetIn(pty.Input()) diff --git a/cli/ttl_test.go b/cli/ttl_test.go index b128078e23bb8..00a0f29fd3811 100644 --- a/cli/ttl_test.go +++ b/cli/ttl_test.go @@ -168,4 +168,37 @@ func TestTTL(t *testing.T) { err := cmd.Execute() require.ErrorContains(t, err, "status code 403: Forbidden", "unexpected error") }) + + t.Run("TemplateMaxTTL", func(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.MaxTTLMillis = ptr.Ref((8 * time.Hour).Milliseconds()) + }) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TTLMillis = ptr.Ref((8 * time.Hour).Milliseconds()) + }) + cmdArgs = []string{"ttl", "set", workspace.Name, "24h"} + stdoutBuf = &bytes.Buffer{} + ) + + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + err := cmd.Execute() + require.ErrorContains(t, err, "ttl_ms: ttl must be below template maximum 8h0m0s") + + // Ensure ttl not updated + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + require.NotNil(t, updated.TTLMillis) + require.Equal(t, (8 * time.Hour).Milliseconds(), *updated.TTLMillis) + }) } diff --git a/coderd/audit/diff_test.go b/coderd/audit/diff_test.go index 50bffa3b0d3a1..53f2110f07c26 100644 --- a/coderd/audit/diff_test.go +++ b/coderd/audit/diff_test.go @@ -78,21 +78,25 @@ func TestDiff(t *testing.T) { name: "Create", left: audit.Empty[database.Template](), right: database.Template{ - ID: uuid.UUID{1}, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - OrganizationID: uuid.UUID{2}, - Deleted: false, - Name: "rust", - Provisioner: database.ProvisionerTypeTerraform, - ActiveVersionID: uuid.UUID{3}, + ID: uuid.UUID{1}, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + OrganizationID: uuid.UUID{2}, + Deleted: false, + Name: "rust", + Provisioner: database.ProvisionerTypeTerraform, + ActiveVersionID: uuid.UUID{3}, + MaxTtl: int64(time.Hour), + MinAutostartInterval: int64(time.Minute), }, exp: audit.Map{ - "id": uuid.UUID{1}.String(), - "organization_id": uuid.UUID{2}.String(), - "name": "rust", - "provisioner": database.ProvisionerTypeTerraform, - "active_version_id": uuid.UUID{3}.String(), + "id": uuid.UUID{1}.String(), + "organization_id": uuid.UUID{2}.String(), + "name": "rust", + "provisioner": database.ProvisionerTypeTerraform, + "active_version_id": uuid.UUID{3}.String(), + "max_ttl": int64(3600000000000), + "min_autostart_interval": int64(60000000000), }, }, }) diff --git a/coderd/audit/table.go b/coderd/audit/table.go index 0a2f9c1795dda..efdf0de1d6431 100644 --- a/coderd/audit/table.go +++ b/coderd/audit/table.go @@ -61,15 +61,17 @@ var AuditableResources = auditMap(map[any]map[string]Action{ "updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff. }, &database.Template{}: { - "id": ActionTrack, - "created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff. - "updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff. - "organization_id": ActionTrack, - "deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired. - "name": ActionTrack, - "provisioner": ActionTrack, - "active_version_id": ActionTrack, - "description": ActionTrack, + "id": ActionTrack, + "created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff. + "updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff. + "organization_id": ActionTrack, + "deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired. + "name": ActionTrack, + "provisioner": ActionTrack, + "active_version_id": ActionTrack, + "description": ActionTrack, + "max_ttl": ActionTrack, + "min_autostart_interval": ActionTrack, }, &database.TemplateVersion{}: { "id": ActionTrack, diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go index a97897e1a0bbc..5b1045aea2cab 100644 --- a/coderd/autobuild/executor/lifecycle_executor_test.go +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -2,9 +2,7 @@ package executor_test import ( "context" - "fmt" "os" - "strings" "testing" "time" @@ -17,7 +15,6 @@ import ( "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" - "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -26,8 +23,7 @@ func TestExecutorAutostartOK(t *testing.T) { t.Parallel() var ( - ctx = context.Background() - err error + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") tickCh = make(chan time.Time) statsCh = make(chan executor.Stats) client = coderdtest.New(t, &coderdtest.Options{ @@ -35,22 +31,17 @@ func TestExecutorAutostartOK(t *testing.T) { IncludeProvisionerD: true, AutobuildStats: statsCh, }) - // Given: we have a user with a workspace - workspace = mustProvisionWorkspace(t, client) + // Given: we have a user with a workspace that has autostart enabled + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) ) // Given: workspace is stopped - workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - - // When: we enable workspace autostart - sched, err := schedule.Weekly("* * * * *") - require.NoError(t, err) - require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ - Schedule: ptr.Ref(sched.String()), - })) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - // When: the autobuild executor ticks + // When: the autobuild executor ticks after the scheduled time go func() { - tickCh <- time.Now().UTC().Add(time.Minute) + tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) close(tickCh) }() @@ -66,6 +57,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { t.Parallel() var ( + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") ctx = context.Background() err error tickCh = make(chan time.Time) @@ -75,11 +67,13 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { IncludeProvisionerD: true, AutobuildStats: statsCh, }) - // Given: we have a user with a workspace - workspace = mustProvisionWorkspace(t, client) + // Given: we have a user with a workspace that has autostart enabled + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) ) // Given: workspace is stopped - workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) // Given: the workspace template has been updated orgs, err := client.OrganizationsByUser(ctx, workspace.OwnerID.String()) @@ -92,16 +86,9 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { ID: newVersion.ID, })) - // When: we enable workspace autostart - sched, err := schedule.Weekly("* * * * *") - require.NoError(t, err) - require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ - Schedule: ptr.Ref(sched.String()), - })) - - // When: the autobuild executor ticks + // When: the autobuild executor ticks after the scheduled time go func() { - tickCh <- time.Now().UTC().Add(time.Minute) + tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) close(tickCh) }() @@ -111,7 +98,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { assert.Len(t, stats.Transitions, 1) assert.Contains(t, stats.Transitions, workspace.ID) assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID]) - ws := mustWorkspace(t, client, workspace.ID) + ws := coderdtest.MustWorkspace(t, client, workspace.ID) assert.Equal(t, workspace.LatestBuild.TemplateVersionID, ws.LatestBuild.TemplateVersionID, "expected workspace build to be using the old template version") } @@ -119,8 +106,7 @@ func TestExecutorAutostartAlreadyRunning(t *testing.T) { t.Parallel() var ( - ctx = context.Background() - err error + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") tickCh = make(chan time.Time) statsCh = make(chan executor.Stats) client = coderdtest.New(t, &coderdtest.Options{ @@ -128,23 +114,18 @@ func TestExecutorAutostartAlreadyRunning(t *testing.T) { IncludeProvisionerD: true, AutobuildStats: statsCh, }) - // Given: we have a user with a workspace - workspace = mustProvisionWorkspace(t, client) + // Given: we have a user with a workspace that has autostart enabled + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) ) // Given: we ensure the workspace is running require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) - // When: we enable workspace autostart - sched, err := schedule.Weekly("* * * * *") - require.NoError(t, err) - require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ - Schedule: ptr.Ref(sched.String()), - })) - // When: the autobuild executor ticks go func() { - tickCh <- time.Now().UTC().Add(time.Minute) + tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) close(tickCh) }() @@ -165,7 +146,7 @@ func TestExecutorAutostartNotEnabled(t *testing.T) { IncludeProvisionerD: true, AutobuildStats: statsCh, }) - // Given: we have a user with a workspace + // Given: we have a user with a workspace that does not have autostart enabled workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.AutostartSchedule = nil }) @@ -175,11 +156,11 @@ func TestExecutorAutostartNotEnabled(t *testing.T) { require.Empty(t, workspace.AutostartSchedule) // Given: workspace is stopped - workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - // When: the autobuild executor ticks + // When: the autobuild executor ticks way into the future go func() { - tickCh <- time.Now().UTC().Add(time.Minute) + tickCh <- workspace.LatestBuild.CreatedAt.Add(24 * time.Hour) close(tickCh) }() @@ -290,7 +271,7 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) { ) // Given: workspace is stopped - workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) // When: the autobuild executor ticks past the TTL go func() { @@ -308,6 +289,7 @@ func TestExecutorAutostopNotEnabled(t *testing.T) { t.Parallel() var ( + ctx = context.Background() tickCh = make(chan time.Time) statsCh = make(chan executor.Stats) client = coderdtest.New(t, &coderdtest.Options{ @@ -315,15 +297,22 @@ func TestExecutorAutostopNotEnabled(t *testing.T) { IncludeProvisionerD: true, AutobuildStats: statsCh, }) - // Given: we have a user with a workspace that has no TTL set - workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.TTLMillis = nil - }) + // Given: we have a user with a workspace + workspace = mustProvisionWorkspace(t, client) ) // Given: workspace has no TTL set + err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}) + require.NoError(t, err) + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) require.Nil(t, workspace.TTLMillis) + // TODO(cian): need to stop and start the workspace as we do not update the deadline yet + // see: https://github.com/coder/coder/issues/1783 + coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart) + // Given: workspace is running require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) @@ -343,8 +332,7 @@ func TestExecutorWorkspaceDeleted(t *testing.T) { t.Parallel() var ( - ctx = context.Background() - err error + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") tickCh = make(chan time.Time) statsCh = make(chan executor.Stats) client = coderdtest.New(t, &coderdtest.Options{ @@ -352,23 +340,18 @@ func TestExecutorWorkspaceDeleted(t *testing.T) { IncludeProvisionerD: true, AutobuildStats: statsCh, }) - // Given: we have a user with a workspace - workspace = mustProvisionWorkspace(t, client) + // Given: we have a user with a workspace that has autostart enabled + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) ) - // When: we enable workspace autostart - sched, err := schedule.Weekly("* * * * *") - require.NoError(t, err) - require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ - Schedule: ptr.Ref(sched.String()), - })) - // Given: workspace is deleted - workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionDelete) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionDelete) // When: the autobuild executor ticks go func() { - tickCh <- time.Now().UTC().Add(time.Minute) + tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) close(tickCh) }() @@ -382,8 +365,7 @@ func TestExecutorWorkspaceAutostartTooEarly(t *testing.T) { t.Parallel() var ( - ctx = context.Background() - err error + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") tickCh = make(chan time.Time) statsCh = make(chan executor.Stats) client = coderdtest.New(t, &coderdtest.Options{ @@ -391,24 +373,17 @@ func TestExecutorWorkspaceAutostartTooEarly(t *testing.T) { IncludeProvisionerD: true, AutobuildStats: statsCh, }) - futureTime = time.Now().Add(time.Hour) - futureTimeCron = fmt.Sprintf("%d %d * * *", futureTime.Minute(), futureTime.Hour()) + // futureTime = time.Now().Add(time.Hour) + // futureTimeCron = fmt.Sprintf("%d %d * * *", futureTime.Minute(), futureTime.Hour()) // Given: we have a user with a workspace configured to autostart some time in the future workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.AutostartSchedule = &futureTimeCron + cwr.AutostartSchedule = ptr.Ref(sched.String()) }) ) - // When: we enable workspace autostart with some time in the future - sched, err := schedule.Weekly(futureTimeCron) - require.NoError(t, err) - require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ - Schedule: ptr.Ref(sched.String()), - })) - - // When: the autobuild executor ticks + // When: the autobuild executor ticks before the next scheduled time go func() { - tickCh <- time.Now().UTC() + tickCh <- sched.Next(workspace.LatestBuild.CreatedAt).Add(-time.Minute) close(tickCh) }() @@ -487,6 +462,7 @@ func TestExecutorAutostartMultipleOK(t *testing.T) { t.Parallel() var ( + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") tickCh = make(chan time.Time) tickCh2 = make(chan time.Time) statsCh1 = make(chan executor.Stats) @@ -502,15 +478,17 @@ func TestExecutorAutostartMultipleOK(t *testing.T) { AutobuildStats: statsCh2, }) // Given: we have a user with a workspace that has autostart enabled (default) - workspace = mustProvisionWorkspace(t, client) + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) ) // Given: workspace is stopped - workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) - // When: the autobuild executor ticks + // When: the autobuild executor ticks past the scheduled time go func() { - tickCh <- time.Now().UTC().Add(time.Minute) - tickCh2 <- time.Now().UTC().Add(time.Minute) + tickCh <- sched.Next(workspace.LatestBuild.CreatedAt) + tickCh2 <- sched.Next(workspace.LatestBuild.CreatedAt) close(tickCh) close(tickCh2) }() @@ -536,41 +514,14 @@ func mustProvisionWorkspace(t *testing.T, client *codersdk.Client, mut ...func(* coderdtest.AwaitTemplateVersionJob(t, client, version.ID) ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, mut...) coderdtest.AwaitWorkspaceBuildJob(t, client, ws.LatestBuild.ID) - return mustWorkspace(t, client, ws.ID) -} - -func mustTransitionWorkspace(t *testing.T, client *codersdk.Client, workspaceID uuid.UUID, from, to database.WorkspaceTransition) codersdk.Workspace { - t.Helper() - ctx := context.Background() - workspace, err := client.Workspace(ctx, workspaceID) - require.NoError(t, err, "unexpected error fetching workspace") - require.Equal(t, workspace.LatestBuild.Transition, codersdk.WorkspaceTransition(from), "expected workspace state: %s got: %s", from, workspace.LatestBuild.Transition) - - template, err := client.Template(ctx, workspace.TemplateID) - require.NoError(t, err, "fetch workspace template") - - build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: template.ActiveVersionID, - Transition: codersdk.WorkspaceTransition(to), - }) - require.NoError(t, err, "unexpected error transitioning workspace to %s", to) - - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID) - - updated := mustWorkspace(t, client, workspace.ID) - require.Equal(t, codersdk.WorkspaceTransition(to), updated.LatestBuild.Transition, "expected workspace to be in state %s but got %s", to, updated.LatestBuild.Transition) - return updated + return coderdtest.MustWorkspace(t, client, ws.ID) } -func mustWorkspace(t *testing.T, client *codersdk.Client, workspaceID uuid.UUID) codersdk.Workspace { +func mustSchedule(t *testing.T, s string) *schedule.Schedule { t.Helper() - ctx := context.Background() - ws, err := client.Workspace(ctx, workspaceID) - if err != nil && strings.Contains(err.Error(), "status code 410") { - ws, err = client.DeletedWorkspace(ctx, workspaceID) - } - require.NoError(t, err, "no workspace found with id %s", workspaceID) - return ws + sched, err := schedule.Weekly(s) + require.NoError(t, err) + return sched } func TestMain(m *testing.M) { diff --git a/coderd/autobuild/schedule/schedule.go b/coderd/autobuild/schedule/schedule.go index 03981acac0489..de729982c7142 100644 --- a/coderd/autobuild/schedule/schedule.go +++ b/coderd/autobuild/schedule/schedule.go @@ -108,6 +108,35 @@ func (s Schedule) Next(t time.Time) time.Time { return s.sched.Next(t) } +var t0 = time.Date(1970, 1, 1, 1, 1, 1, 0, time.UTC) +var tMax = t0.Add(168 * time.Hour) + +// Min returns the minimum duration of the schedule. +// This is calculated as follows: +// - Let t(0) be a given point in time (1970-01-01T01:01:01Z00:00) +// - Let t(max) be 168 hours after t(0). +// - Let t(1) be the next scheduled time after t(0). +// - Let t(n) be the next scheduled time after t(n-1). +// - Then, the minimum duration of s d(min) +// = min( t(n) - t(n-1) ∀ n ∈ N, t(n) < t(max) ) +func (s Schedule) Min() time.Duration { + durMin := tMax.Sub(t0) + tPrev := s.Next(t0) + tCurr := s.Next(tPrev) + for { + dur := tCurr.Sub(tPrev) + if dur < durMin { + durMin = dur + } + tPrev = tCurr + tCurr = s.Next(tCurr) + if tCurr.After(tMax) { + break + } + } + return durMin +} + // validateWeeklySpec ensures that the day-of-month and month options of // spec are both set to * func validateWeeklySpec(spec string) error { diff --git a/coderd/autobuild/schedule/schedule_test.go b/coderd/autobuild/schedule/schedule_test.go index 0c847a28e819c..c666b82f0850f 100644 --- a/coderd/autobuild/schedule/schedule_test.go +++ b/coderd/autobuild/schedule/schedule_test.go @@ -16,6 +16,7 @@ func Test_Weekly(t *testing.T) { spec string at time.Time expectedNext time.Time + expectedMin time.Duration expectedError string expectedCron string expectedTz string @@ -26,6 +27,7 @@ func Test_Weekly(t *testing.T) { spec: "CRON_TZ=US/Central 30 9 * * 1-5", at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC), expectedNext: time.Date(2022, 4, 1, 14, 30, 0, 0, time.UTC), + expectedMin: 24 * time.Hour, expectedError: "", expectedCron: "30 9 * * 1-5", expectedTz: "US/Central", @@ -36,11 +38,34 @@ func Test_Weekly(t *testing.T) { spec: "30 9 * * 1-5", at: time.Date(2022, 4, 1, 9, 29, 0, 0, time.UTC), expectedNext: time.Date(2022, 4, 1, 9, 30, 0, 0, time.UTC), + expectedMin: 24 * time.Hour, expectedError: "", expectedCron: "30 9 * * 1-5", expectedTz: "UTC", expectedString: "CRON_TZ=UTC 30 9 * * 1-5", }, + { + name: "convoluted with timezone", + spec: "CRON_TZ=US/Central */5 12-18 * * 1,3,6", + at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC), + expectedNext: time.Date(2022, 4, 2, 17, 0, 0, 0, time.UTC), // Apr 1 was a Friday in 2022 + expectedMin: 5 * time.Minute, + expectedError: "", + expectedCron: "*/5 12-18 * * 1,3,6", + expectedTz: "US/Central", + expectedString: "CRON_TZ=US/Central */5 12-18 * * 1,3,6", + }, + { + name: "another convoluted example", + spec: "CRON_TZ=US/Central 10,20,40-50 * * * *", + at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC), + expectedNext: time.Date(2022, 4, 1, 14, 40, 0, 0, time.UTC), + expectedMin: time.Minute, + expectedError: "", + expectedCron: "10,20,40-50 * * * *", + expectedTz: "US/Central", + expectedString: "CRON_TZ=US/Central 10,20,40-50 * * * *", + }, { name: "time.Local will bite you", spec: "CRON_TZ=Local 30 9 * * 1-5", @@ -104,6 +129,7 @@ func Test_Weekly(t *testing.T) { require.Equal(t, testCase.expectedCron, actual.Cron()) require.Equal(t, testCase.expectedTz, actual.Timezone()) require.Equal(t, testCase.expectedString, actual.String()) + require.Equal(t, testCase.expectedMin, actual.Min()) } else { require.EqualError(t, err, testCase.expectedError) require.Nil(t, actual) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index e9423d50a3826..ea068927004df 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -322,12 +322,16 @@ func CreateWorkspaceBuild( // CreateTemplate creates a template with the "echo" provisioner for // compatibility with testing. The name assigned is randomly generated. -func CreateTemplate(t *testing.T, client *codersdk.Client, organization uuid.UUID, version uuid.UUID) codersdk.Template { - template, err := client.CreateTemplate(context.Background(), organization, codersdk.CreateTemplateRequest{ +func CreateTemplate(t *testing.T, client *codersdk.Client, organization uuid.UUID, version uuid.UUID, mutators ...func(*codersdk.CreateTemplateRequest)) codersdk.Template { + req := codersdk.CreateTemplateRequest{ Name: randomUsername(), Description: randomUsername(), VersionID: version, - }) + } + for _, mut := range mutators { + mut(&req) + } + template, err := client.CreateTemplate(context.Background(), organization, req) require.NoError(t, err) return template } @@ -400,7 +404,7 @@ func CreateWorkspace(t *testing.T, client *codersdk.Client, organization uuid.UU req := codersdk.CreateWorkspaceRequest{ TemplateID: templateID, Name: randomUsername(), - AutostartSchedule: ptr.Ref("CRON_TZ=US/Central * * * * *"), + AutostartSchedule: ptr.Ref("CRON_TZ=US/Central 30 9 * * 1-5"), TTLMillis: ptr.Ref((8 * time.Hour).Milliseconds()), } for _, mutator := range mutators { @@ -411,6 +415,42 @@ func CreateWorkspace(t *testing.T, client *codersdk.Client, organization uuid.UU return workspace } +// TransitionWorkspace is a convenience method for transitioning a workspace from one state to another. +func MustTransitionWorkspace(t *testing.T, client *codersdk.Client, workspaceID uuid.UUID, from, to database.WorkspaceTransition) codersdk.Workspace { + t.Helper() + ctx := context.Background() + workspace, err := client.Workspace(ctx, workspaceID) + require.NoError(t, err, "unexpected error fetching workspace") + require.Equal(t, workspace.LatestBuild.Transition, codersdk.WorkspaceTransition(from), "expected workspace state: %s got: %s", from, workspace.LatestBuild.Transition) + + template, err := client.Template(ctx, workspace.TemplateID) + require.NoError(t, err, "fetch workspace template") + + build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: template.ActiveVersionID, + Transition: codersdk.WorkspaceTransition(to), + }) + require.NoError(t, err, "unexpected error transitioning workspace to %s", to) + + _ = AwaitWorkspaceBuildJob(t, client, build.ID) + + updated := MustWorkspace(t, client, workspace.ID) + require.Equal(t, codersdk.WorkspaceTransition(to), updated.LatestBuild.Transition, "expected workspace to be in state %s but got %s", to, updated.LatestBuild.Transition) + return updated +} + +// MustWorkspace is a convenience method for fetching a workspace that should exist. +func MustWorkspace(t *testing.T, client *codersdk.Client, workspaceID uuid.UUID) codersdk.Workspace { + t.Helper() + ctx := context.Background() + ws, err := client.Workspace(ctx, workspaceID) + if err != nil && strings.Contains(err.Error(), "status code 410") { + ws, err = client.DeletedWorkspace(ctx, workspaceID) + } + require.NoError(t, err, "no workspace found with id %s", workspaceID) + return ws +} + // NewGoogleInstanceIdentity returns a metadata client and ID token validator for faking // instance authentication for Google Cloud. // nolint:revive diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index f4531c1ce0b60..0176c82ac3a9e 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1277,16 +1277,26 @@ func (q *fakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTempl q.mutex.Lock() defer q.mutex.Unlock() + // default values + if arg.MaxTtl == 0 { + arg.MaxTtl = int64(168 * time.Hour) + } + if arg.MinAutostartInterval == 0 { + arg.MinAutostartInterval = int64(time.Hour) + } + //nolint:gosimple template := database.Template{ - ID: arg.ID, - CreatedAt: arg.CreatedAt, - UpdatedAt: arg.UpdatedAt, - OrganizationID: arg.OrganizationID, - Name: arg.Name, - Provisioner: arg.Provisioner, - ActiveVersionID: arg.ActiveVersionID, - Description: arg.Description, + ID: arg.ID, + CreatedAt: arg.CreatedAt, + UpdatedAt: arg.UpdatedAt, + OrganizationID: arg.OrganizationID, + Name: arg.Name, + Provisioner: arg.Provisioner, + ActiveVersionID: arg.ActiveVersionID, + Description: arg.Description, + MaxTtl: arg.MaxTtl, + MinAutostartInterval: arg.MinAutostartInterval, } q.templates = append(q.templates, template) return template, nil diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b19f7747a4252..eff1e9ea6350c 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -246,7 +246,9 @@ CREATE TABLE templates ( name character varying(64) NOT NULL, provisioner provisioner_type NOT NULL, active_version_id uuid NOT NULL, - description character varying(128) DEFAULT ''::character varying NOT NULL + description character varying(128) DEFAULT ''::character varying NOT NULL, + max_ttl bigint DEFAULT '604800000000000'::bigint NOT NULL, + min_autostart_interval bigint DEFAULT '3600000000000'::bigint NOT NULL ); CREATE TABLE users ( diff --git a/coderd/database/migrations/000021_template_autobuild_constraints.down.sql b/coderd/database/migrations/000021_template_autobuild_constraints.down.sql new file mode 100644 index 0000000000000..e03c22a7952c6 --- /dev/null +++ b/coderd/database/migrations/000021_template_autobuild_constraints.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE ONLY templates DROP COLUMN IF EXISTS max_ttl; +ALTER TABLE ONLY templates DROP COLUMN IF EXISTS min_autostart_interval; diff --git a/coderd/database/migrations/000021_template_autobuild_constraints.up.sql b/coderd/database/migrations/000021_template_autobuild_constraints.up.sql new file mode 100644 index 0000000000000..e38eacf872cf5 --- /dev/null +++ b/coderd/database/migrations/000021_template_autobuild_constraints.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE ONLY templates ADD COLUMN IF NOT EXISTS max_ttl BIGINT NOT NULL DEFAULT 604800000000000; -- 168 hours +ALTER TABLE ONLY templates ADD COLUMN IF NOT EXISTS min_autostart_interval BIGINT NOT NULL DEFAULT 3600000000000; -- 1 hour diff --git a/coderd/database/models.go b/coderd/database/models.go index bbe997c8a76b0..22f47053c3955 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -427,15 +427,17 @@ type ProvisionerJobLog struct { } type Template struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - Deleted bool `db:"deleted" json:"deleted"` - Name string `db:"name" json:"name"` - Provisioner ProvisionerType `db:"provisioner" json:"provisioner"` - ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"` - Description string `db:"description" json:"description"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Deleted bool `db:"deleted" json:"deleted"` + Name string `db:"name" json:"name"` + Provisioner ProvisionerType `db:"provisioner" json:"provisioner"` + ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"` + Description string `db:"description" json:"description"` + MaxTtl int64 `db:"max_ttl" json:"max_ttl"` + MinAutostartInterval int64 `db:"min_autostart_interval" json:"min_autostart_interval"` } type TemplateVersion struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3b4c9d53ab671..25e9031e2bafb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1603,7 +1603,7 @@ func (q *sqlQuerier) UpdateProvisionerJobWithCompleteByID(ctx context.Context, a const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval FROM templates WHERE @@ -1625,13 +1625,15 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.Provisioner, &i.ActiveVersionID, &i.Description, + &i.MaxTtl, + &i.MinAutostartInterval, ) return i, err } const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval FROM templates WHERE @@ -1661,13 +1663,15 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.Provisioner, &i.ActiveVersionID, &i.Description, + &i.MaxTtl, + &i.MinAutostartInterval, ) return i, err } const getTemplatesByIDs = `-- name: GetTemplatesByIDs :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval FROM templates WHERE @@ -1693,6 +1697,8 @@ func (q *sqlQuerier) GetTemplatesByIDs(ctx context.Context, ids []uuid.UUID) ([] &i.Provisioner, &i.ActiveVersionID, &i.Description, + &i.MaxTtl, + &i.MinAutostartInterval, ); err != nil { return nil, err } @@ -1709,7 +1715,7 @@ func (q *sqlQuerier) GetTemplatesByIDs(ctx context.Context, ids []uuid.UUID) ([] const getTemplatesByOrganization = `-- name: GetTemplatesByOrganization :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval FROM templates WHERE @@ -1741,6 +1747,8 @@ func (q *sqlQuerier) GetTemplatesByOrganization(ctx context.Context, arg GetTemp &i.Provisioner, &i.ActiveVersionID, &i.Description, + &i.MaxTtl, + &i.MinAutostartInterval, ); err != nil { return nil, err } @@ -1765,21 +1773,25 @@ INSERT INTO "name", provisioner, active_version_id, - description + description, + max_ttl, + min_autostart_interval ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval ` type InsertTemplateParams struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - Name string `db:"name" json:"name"` - Provisioner ProvisionerType `db:"provisioner" json:"provisioner"` - ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"` - Description string `db:"description" json:"description"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Name string `db:"name" json:"name"` + Provisioner ProvisionerType `db:"provisioner" json:"provisioner"` + ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"` + Description string `db:"description" json:"description"` + MaxTtl int64 `db:"max_ttl" json:"max_ttl"` + MinAutostartInterval int64 `db:"min_autostart_interval" json:"min_autostart_interval"` } func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { @@ -1792,6 +1804,8 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam arg.Provisioner, arg.ActiveVersionID, arg.Description, + arg.MaxTtl, + arg.MinAutostartInterval, ) var i Template err := row.Scan( @@ -1804,6 +1818,8 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.Provisioner, &i.ActiveVersionID, &i.Description, + &i.MaxTtl, + &i.MinAutostartInterval, ) return i, err } diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 133d3b4c47e5c..48bcd97722f09 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -47,10 +47,12 @@ INSERT INTO "name", provisioner, active_version_id, - description + description, + max_ttl, + min_autostart_interval ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *; -- name: UpdateTemplateActiveVersionByID :exec UPDATE diff --git a/coderd/templates.go b/coderd/templates.go index 4e9ff17feb155..047ef5fafb6eb 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -14,9 +15,15 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" ) +var ( + maxTTLDefault = 24 * 7 * time.Hour + minAutostartIntervalDefault = time.Hour +) + // Returns a single template. func (api *API) template(rw http.ResponseWriter, r *http.Request) { template := httpmw.TemplateParam(r) @@ -144,18 +151,30 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque return } + maxTTL := maxTTLDefault + if !ptr.NilOrZero(createTemplate.MaxTTLMillis) { + maxTTL = time.Duration(*createTemplate.MaxTTLMillis) * time.Millisecond + } + + minAutostartInterval := minAutostartIntervalDefault + if !ptr.NilOrZero(createTemplate.MinAutostartIntervalMillis) { + minAutostartInterval = time.Duration(*createTemplate.MinAutostartIntervalMillis) * time.Millisecond + } + var template codersdk.Template err = api.Database.InTx(func(db database.Store) error { now := database.Now() dbTemplate, err := db.InsertTemplate(r.Context(), database.InsertTemplateParams{ - ID: uuid.New(), - CreatedAt: now, - UpdatedAt: now, - OrganizationID: organization.ID, - Name: createTemplate.Name, - Provisioner: importJob.Provisioner, - ActiveVersionID: templateVersion.ID, - Description: createTemplate.Description, + ID: uuid.New(), + CreatedAt: now, + UpdatedAt: now, + OrganizationID: organization.ID, + Name: createTemplate.Name, + Provisioner: importJob.Provisioner, + ActiveVersionID: templateVersion.ID, + Description: createTemplate.Description, + MaxTtl: int64(maxTTL), + MinAutostartInterval: int64(minAutostartInterval), }) if err != nil { return xerrors.Errorf("insert template: %s", err) @@ -309,14 +328,16 @@ func convertTemplates(templates []database.Template, workspaceCounts []database. func convertTemplate(template database.Template, workspaceOwnerCount uint32) codersdk.Template { return codersdk.Template{ - ID: template.ID, - CreatedAt: template.CreatedAt, - UpdatedAt: template.UpdatedAt, - OrganizationID: template.OrganizationID, - Name: template.Name, - Provisioner: codersdk.ProvisionerType(template.Provisioner), - ActiveVersionID: template.ActiveVersionID, - WorkspaceOwnerCount: workspaceOwnerCount, - Description: template.Description, + ID: template.ID, + CreatedAt: template.CreatedAt, + UpdatedAt: template.UpdatedAt, + OrganizationID: template.OrganizationID, + Name: template.Name, + Provisioner: codersdk.ProvisionerType(template.Provisioner), + ActiveVersionID: template.ActiveVersionID, + WorkspaceOwnerCount: workspaceOwnerCount, + Description: template.Description, + MaxTTLMillis: time.Duration(template.MaxTtl).Milliseconds(), + MinAutostartIntervalMillis: time.Duration(template.MinAutostartInterval).Milliseconds(), } } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 23c142bcdf873..eff07b5d1611d 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -269,35 +269,29 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } - var dbAutostartSchedule sql.NullString - if createWorkspace.AutostartSchedule != nil { - _, err := schedule.Weekly(*createWorkspace.AutostartSchedule) - if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: "Error parsing autostart schedule", - Detail: err.Error(), - }) - return - } - dbAutostartSchedule.Valid = true - dbAutostartSchedule.String = *createWorkspace.AutostartSchedule + dbAutostartSchedule, err := validWorkspaceSchedule(createWorkspace.AutostartSchedule, time.Duration(template.MinAutostartInterval)) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "Invalid Autostart Schedule", + Validations: []httpapi.Error{{Field: "schedule", Detail: err.Error()}}, + }) + return } - dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis) + dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, time.Duration(template.MaxTtl)) if err != nil { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: "Invalid workspace TTL", - Detail: err.Error(), - Validations: []httpapi.Error{ - { - Field: "ttl", - Detail: err.Error(), - }, - }, + Message: "Invalid Workspace TTL", + Validations: []httpapi.Error{{Field: "ttl_ms", Detail: err.Error()}}, }) return } + if !dbTTL.Valid { + // Default to template maximum when creating a new workspace + dbTTL = sql.NullInt64{Valid: true, Int64: template.MaxTtl} + } + workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(r.Context(), database.GetWorkspaceByOwnerIDAndNameParams{ OwnerID: apiKey.UserID, Name: createWorkspace.Name, @@ -472,11 +466,20 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { return } - dbSched, err := validWorkspaceSchedule(req.Schedule) + template, err := api.Database.GetTemplateByID(r.Context(), workspace.TemplateID) if err != nil { + api.Logger.Error(r.Context(), "fetch workspace template", slog.F("workspace_id", workspace.ID), slog.F("template_id", workspace.TemplateID), slog.Error(err)) httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: "Invalid autostart schedule", - Detail: err.Error(), + Message: "Error fetching workspace template", + }) + return + } + + dbSched, err := validWorkspaceSchedule(req.Schedule, time.Duration(template.MinAutostartInterval)) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: "Invalid autostart schedule", + Validations: []httpapi.Error{{Field: "schedule", Detail: err.Error()}}, }) return } @@ -506,14 +509,22 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return } - dbTTL, err := validWorkspaceTTLMillis(req.TTLMillis) + template, err := api.Database.GetTemplateByID(r.Context(), workspace.TemplateID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: "Error fetching workspace template!", + }) + return + } + + dbTTL, err := validWorkspaceTTLMillis(req.TTLMillis, time.Duration(template.MaxTtl)) if err != nil { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ Message: "Invalid workspace TTL", Detail: err.Error(), Validations: []httpapi.Error{ { - Field: "ttl", + Field: "ttl_ms", Detail: err.Error(), }, }, @@ -814,7 +825,7 @@ func convertWorkspaceTTLMillis(i sql.NullInt64) *int64 { return &millis } -func validWorkspaceTTLMillis(millis *int64) (sql.NullInt64, error) { +func validWorkspaceTTLMillis(millis *int64, max time.Duration) (sql.NullInt64, error) { if ptr.NilOrZero(millis) { return sql.NullInt64{}, nil } @@ -829,6 +840,10 @@ func validWorkspaceTTLMillis(millis *int64) (sql.NullInt64, error) { return sql.NullInt64{}, xerrors.New("ttl must be less than 7 days") } + if truncated > max { + return sql.NullInt64{}, xerrors.Errorf("ttl must be below template maximum %s", max.String()) + } + return sql.NullInt64{ Valid: true, Int64: int64(truncated), @@ -857,16 +872,20 @@ func validWorkspaceDeadline(old, new time.Time) error { return nil } -func validWorkspaceSchedule(s *string) (sql.NullString, error) { +func validWorkspaceSchedule(s *string, min time.Duration) (sql.NullString, error) { if ptr.NilOrEmpty(s) { return sql.NullString{}, nil } - _, err := schedule.Weekly(*s) + sched, err := schedule.Weekly(*s) if err != nil { return sql.NullString{}, err } + if schedMin := sched.Min(); schedMin < min { + return sql.NullString{}, xerrors.Errorf("Minimum autostart interval %s below template minimum %s", schedMin, min) + } + return sql.NullString{ Valid: true, String: *s, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 52ecadb8d3c48..cb8a83e946039 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -165,6 +165,24 @@ func TestPostWorkspacesByOrganization(t *testing.T) { _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) }) + t.Run("TemplateCustomTTL", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + templateTTL := 24 * time.Hour.Milliseconds() + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.MaxTTLMillis = ptr.Ref(templateTTL) + }) + coderdtest.AwaitTemplateVersionJob(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 + }) + // TTL should be set by the template + require.Equal(t, template.MaxTTLMillis, templateTTL) + require.Equal(t, template.MaxTTLMillis, template.MaxTTLMillis, workspace.TTLMillis) + }) + t.Run("InvalidTTL", func(t *testing.T) { t.Parallel() t.Run("BelowMin", func(t *testing.T) { @@ -175,16 +193,18 @@ func TestPostWorkspacesByOrganization(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) req := codersdk.CreateWorkspaceRequest{ - TemplateID: template.ID, - Name: "testing", - AutostartSchedule: ptr.Ref("CRON_TZ=US/Central * * * * *"), - TTLMillis: ptr.Ref((59 * time.Second).Milliseconds()), + TemplateID: template.ID, + Name: "testing", + TTLMillis: ptr.Ref((59 * time.Second).Milliseconds()), } _, err := client.CreateWorkspace(context.Background(), template.OrganizationID, req) require.Error(t, err) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + require.Equal(t, apiErr.Validations[0].Field, "ttl_ms") + require.Equal(t, apiErr.Validations[0].Detail, "ttl must be at least one minute") }) t.Run("AboveMax", func(t *testing.T) { @@ -195,18 +215,42 @@ func TestPostWorkspacesByOrganization(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) req := codersdk.CreateWorkspaceRequest{ - TemplateID: template.ID, - Name: "testing", - AutostartSchedule: ptr.Ref("CRON_TZ=US/Central * * * * *"), - TTLMillis: ptr.Ref((24*7*time.Hour + time.Minute).Milliseconds()), + TemplateID: template.ID, + Name: "testing", + TTLMillis: ptr.Ref((24*7*time.Hour + time.Minute).Milliseconds()), } _, err := client.CreateWorkspace(context.Background(), template.OrganizationID, req) require.Error(t, err) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + require.Equal(t, apiErr.Validations[0].Field, "ttl_ms") + require.Equal(t, apiErr.Validations[0].Detail, "ttl must be less than 7 days") }) }) + + t.Run("InvalidAutostart", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "testing", + AutostartSchedule: ptr.Ref("CRON_TZ=US/Central * * * * *"), + } + _, err := client.CreateWorkspace(context.Background(), template.OrganizationID, req) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + require.Equal(t, apiErr.Validations[0].Field, "schedule") + require.Equal(t, apiErr.Validations[0].Detail, "Minimum autostart interval 1m0s below template minimum 1h0m0s") + }) } func TestWorkspaceByOwnerAndName(t *testing.T) { @@ -476,17 +520,20 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { { name: "invalid location", schedule: ptr.Ref("CRON_TZ=Imaginary/Place 30 9 * * 1-5"), - expectedError: "status code 500: Invalid autostart schedule\n\tError: parse schedule: provided bad location Imaginary/Place: unknown time zone Imaginary/Place", + expectedError: "parse schedule: provided bad location Imaginary/Place: unknown time zone Imaginary/Place", + // expectedError: "status code 500: Invalid autostart schedule\n\tError: parse schedule: provided bad location Imaginary/Place: unknown time zone Imaginary/Place", }, { name: "invalid schedule", schedule: ptr.Ref("asdf asdf asdf "), - expectedError: "status code 500: Invalid autostart schedule\n\tError: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix", + expectedError: `validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix`, + // expectedError: "status code 500: Invalid autostart schedule\n\tError: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix", }, { name: "only 3 values", schedule: ptr.Ref("CRON_TZ=Europe/Dublin 30 9 *"), - expectedError: "status code 500: Invalid autostart schedule\n\tError: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix", + expectedError: `validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix`, + // expectedError: "status code 500: Invalid autostart schedule\n\tError: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix", }, } @@ -564,9 +611,10 @@ func TestWorkspaceUpdateTTL(t *testing.T) { t.Parallel() testCases := []struct { - name string - ttlMillis *int64 - expectedError string + name string + ttlMillis *int64 + expectedError string + modifyTemplate func(*codersdk.CreateTemplateRequest) }{ { name: "disable ttl", @@ -593,28 +641,36 @@ func TestWorkspaceUpdateTTL(t *testing.T) { ttlMillis: ptr.Ref((24*7*time.Hour + time.Minute).Milliseconds()), expectedError: "ttl must be less than 7 days", }, + { + name: "above template maximum ttl", + ttlMillis: ptr.Ref((12 * time.Hour).Milliseconds()), + expectedError: "ttl_ms: ttl must be below template maximum 8h0m0s", + modifyTemplate: func(ctr *codersdk.CreateTemplateRequest) { ctr.MaxTTLMillis = ptr.Ref((8 * time.Hour).Milliseconds()) }, + }, } for _, testCase := range testCases { testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() + + mutators := make([]func(*codersdk.CreateTemplateRequest), 0) + if testCase.modifyTemplate != nil { + mutators = append(mutators, testCase.modifyTemplate) + } var ( ctx = context.Background() client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, mutators...) workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.AutostartSchedule = nil cwr.TTLMillis = nil }) ) - // ensure test invariant: new workspaces have no autostop schedule. - require.Nil(t, workspace.TTLMillis, "expected newly-minted workspace to have no TTL") - err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ TTLMillis: testCase.ttlMillis, }) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 1e28760e1b95e..2d3c3b3a9224a 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -61,6 +61,15 @@ type CreateTemplateRequest struct { // templates, but it doesn't make sense for users. VersionID uuid.UUID `json:"template_version_id" validate:"required"` ParameterValues []CreateParameterRequest `json:"parameter_values,omitempty"` + + // MaxTTLMillis allows optionally specifying the maximum allowable TTL + // for all workspaces created from this template. + MaxTTLMillis *int64 `json:"max_ttl_ms,omitempty"` + + // MinAutostartIntervalMillis allows optionally specifying the minimum + // allowable duration between autostarts for all workspaces created from + // this template. + MinAutostartIntervalMillis *int64 `json:"min_autostart_interval_ms,omitempty"` } // CreateWorkspaceRequest provides options for creating a new workspace. diff --git a/codersdk/templates.go b/codersdk/templates.go index 6ca9361ad89df..62ec738511cfd 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -13,15 +13,17 @@ import ( // Template is the JSON representation of a Coder template. This type matches the // database object for now, but is abstracted for ease of change later on. type Template struct { - ID uuid.UUID `json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - OrganizationID uuid.UUID `json:"organization_id"` - Name string `json:"name"` - Provisioner ProvisionerType `json:"provisioner"` - ActiveVersionID uuid.UUID `json:"active_version_id"` - WorkspaceOwnerCount uint32 `json:"workspace_owner_count"` - Description string `json:"description"` + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + OrganizationID uuid.UUID `json:"organization_id"` + Name string `json:"name"` + Provisioner ProvisionerType `json:"provisioner"` + ActiveVersionID uuid.UUID `json:"active_version_id"` + WorkspaceOwnerCount uint32 `json:"workspace_owner_count"` + Description string `json:"description"` + MaxTTLMillis int64 `json:"max_ttl_ms"` + MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms"` } type UpdateActiveTemplateVersion struct { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8e71d52a3906c..bbcd9cad406c9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -63,6 +63,8 @@ export interface CreateTemplateRequest { readonly description?: string readonly template_version_id: string readonly parameter_values?: CreateParameterRequest[] + readonly max_ttl_ms?: number + readonly min_autostart_interval_ms?: number } // From codersdk/templateversions.go:121:6 @@ -96,7 +98,7 @@ export interface CreateWorkspaceBuildRequest { readonly state?: string } -// From codersdk/organizations.go:67:6 +// From codersdk/organizations.go:76:6 export interface CreateWorkspaceRequest { readonly template_id: string readonly name: string @@ -243,6 +245,8 @@ export interface Template { readonly active_version_id: string readonly workspace_owner_count: number readonly description: string + readonly max_ttl_ms: number + readonly min_autostart_interval_ms: number } // From codersdk/templateversions.go:14:6 @@ -272,12 +276,12 @@ export interface TemplateVersionParameter { readonly default_source_value: boolean } -// From codersdk/templates.go:73:6 +// From codersdk/templates.go:75:6 export interface TemplateVersionsByTemplateRequest extends Pagination { readonly template_id: string } -// From codersdk/templates.go:27:6 +// From codersdk/templates.go:29:6 export interface UpdateActiveTemplateVersion { readonly id: string } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 2c06cb214020d..a154212c303f5 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -112,6 +112,8 @@ export const MockTemplate: TypesGen.Template = { active_version_id: MockTemplateVersion.id, workspace_owner_count: 1, description: "This is a test description.", + max_ttl_ms: 604800000, + min_autostart_interval_ms: 3600000, } export const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest = {