feat: enforce template-level constraints for TTL and autostart (#2018)

This PR adds fields to templates that constrain values for workspaces derived from that template.

- Autostop: Adds a field max_ttl on the template which limits the maximum value of ttl on all workspaces derived from that template. Defaulting to 168 hours, enforced on edits to workspace metadata. New workspaces will default to the templates's `max_ttl` if not specified.
- Autostart: Adds a field min_autostart_duration which limits the minimum duration between successive autostarts of a template, measured from a single reference time. Defaulting to 1 hour, enforced on edits to workspace metadata.
This commit is contained in:
Cian Johnston 2022-06-07 13:37:45 +01:00 committed by GitHub
parent 3878e6434a
commit 3e419ddb3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 646 additions and 292 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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(&parameterFile, "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 {

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
workspace = coderdtest.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()),
}))
// 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)
return coderdtest.MustWorkspace(t, client, ws.ID)
}
func mustTransitionWorkspace(t *testing.T, client *codersdk.Client, workspaceID uuid.UUID, from, to database.WorkspaceTransition) codersdk.Workspace {
func mustSchedule(t *testing.T, s string) *schedule.Schedule {
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
}
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
sched, err := schedule.Weekly(s)
require.NoError(t, err)
return sched
}
func TestMain(m *testing.M) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis)
dbAutostartSchedule, err := validWorkspaceSchedule(createWorkspace.AutostartSchedule, time.Duration(template.MinAutostartInterval))
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 Autostart Schedule",
Validations: []httpapi.Error{{Field: "schedule", Detail: err.Error()}},
})
return
}
dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, time.Duration(template.MaxTtl))
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
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,

View File

@ -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=<timezone> prefix",
expectedError: `validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> 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=<timezone> 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=<timezone> prefix",
expectedError: `validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> 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=<timezone> 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,
})

View File

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

View File

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

View File

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

View File

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