Skip to content

Commit 3e419dd

Browse files
authored
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.
1 parent 3878e64 commit 3e419dd

27 files changed

+644
-290
lines changed

cli/autostart_test.go

+23
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os"
88
"testing"
9+
"time"
910

1011
"github.com/stretchr/testify/require"
1112

@@ -158,4 +159,26 @@ func TestAutostart(t *testing.T) {
158159
require.NoError(t, err, "fetch updated workspace")
159160
require.Equal(t, expectedSchedule, *updated.AutostartSchedule, "expected default autostart schedule")
160161
})
162+
163+
t.Run("BelowTemplateConstraint", func(t *testing.T) {
164+
t.Parallel()
165+
166+
var (
167+
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
168+
user = coderdtest.CreateFirstUser(t, client)
169+
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
170+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
171+
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
172+
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
173+
})
174+
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
175+
cmdArgs = []string{"autostart", "enable", workspace.Name, "--minute", "*", "--hour", "*"}
176+
)
177+
178+
cmd, root := clitest.New(t, cmdArgs...)
179+
clitest.SetupConfig(t, client, root)
180+
181+
err := cmd.Execute()
182+
require.ErrorContains(t, err, "schedule: Minimum autostart interval 1m0s below template minimum 1h0m0s")
183+
})
161184
}

cli/bump_test.go

+12
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/coder/coder/cli/clitest"
1212
"github.com/coder/coder/coderd/coderdtest"
13+
"github.com/coder/coder/coderd/database"
1314
"github.com/coder/coder/codersdk"
1415
)
1516

@@ -152,12 +153,23 @@ func TestBump(t *testing.T) {
152153
cmdArgs = []string{"bump", workspace.Name}
153154
stdoutBuf = &bytes.Buffer{}
154155
)
156+
// Unset the workspace TTL
157+
err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil})
158+
require.NoError(t, err)
159+
workspace, err = client.Workspace(ctx, workspace.ID)
160+
require.NoError(t, err)
161+
require.Nil(t, workspace.TTLMillis)
155162

156163
// Given: we wait for the workspace to build
157164
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
158165
workspace, err = client.Workspace(ctx, workspace.ID)
159166
require.NoError(t, err)
160167

168+
// TODO(cian): need to stop and start the workspace as we do not update the deadline yet
169+
// see: https://github.com/coder/coder/issues/1783
170+
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
171+
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart)
172+
161173
// Assert test invariant: workspace has no TTL set
162174
require.Zero(t, workspace.LatestBuild.Deadline)
163175
require.NoError(t, err)

cli/create.go

+39-16
Original file line numberDiff line numberDiff line change
@@ -61,20 +61,6 @@ func create() *cobra.Command {
6161
}
6262
}
6363

64-
tz, err := time.LoadLocation(tzName)
65-
if err != nil {
66-
return xerrors.Errorf("Invalid workspace autostart timezone: %w", err)
67-
}
68-
schedSpec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", tz.String(), autostartMinute, autostartHour, autostartDow)
69-
_, err = schedule.Weekly(schedSpec)
70-
if err != nil {
71-
return xerrors.Errorf("invalid workspace autostart schedule: %w", err)
72-
}
73-
74-
if ttl == 0 {
75-
return xerrors.Errorf("TTL must be at least 1 minute")
76-
}
77-
7864
_, err = client.WorkspaceByOwnerAndName(cmd.Context(), codersdk.Me, workspaceName)
7965
if err == nil {
8066
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
@@ -129,6 +115,23 @@ func create() *cobra.Command {
129115
}
130116
}
131117

118+
schedSpec, err := validSchedule(
119+
autostartMinute,
120+
autostartHour,
121+
autostartDow,
122+
tzName,
123+
time.Duration(template.MinAutostartIntervalMillis)*time.Millisecond,
124+
)
125+
if err != nil {
126+
return xerrors.Errorf("Invalid autostart schedule: %w", err)
127+
}
128+
if ttl < time.Minute {
129+
return xerrors.Errorf("TTL must be at least 1 minute")
130+
}
131+
if ttlMax := time.Duration(template.MaxTTLMillis) * time.Millisecond; ttl > ttlMax {
132+
return xerrors.Errorf("TTL must be below template maximum %s", ttlMax)
133+
}
134+
132135
templateVersion, err := client.TemplateVersion(cmd.Context(), template.ActiveVersionID)
133136
if err != nil {
134137
return err
@@ -226,7 +229,7 @@ func create() *cobra.Command {
226229
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.CreateWorkspaceRequest{
227230
TemplateID: template.ID,
228231
Name: workspaceName,
229-
AutostartSchedule: &schedSpec,
232+
AutostartSchedule: schedSpec,
230233
TTLMillis: ptr.Ref(ttl.Milliseconds()),
231234
ParameterValues: parameters,
232235
})
@@ -262,7 +265,27 @@ func create() *cobra.Command {
262265
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).")
263266
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).")
264267
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)")
265-
cliflag.StringVarP(cmd.Flags(), &tzName, "tz", "", "TZ", "", "Specify your timezone location for workspace autostart (e.g. US/Central).")
268+
cliflag.StringVarP(cmd.Flags(), &tzName, "tz", "", "TZ", "UTC", "Specify your timezone location for workspace autostart (e.g. US/Central).")
266269
cliflag.DurationVarP(cmd.Flags(), &ttl, "ttl", "", "CODER_WORKSPACE_TTL", 8*time.Hour, "Specify a time-to-live (TTL) for the workspace (e.g. 8h).")
267270
return cmd
268271
}
272+
273+
func validSchedule(minute, hour, dow, tzName string, min time.Duration) (*string, error) {
274+
_, err := time.LoadLocation(tzName)
275+
if err != nil {
276+
return nil, xerrors.Errorf("Invalid workspace autostart timezone: %w", err)
277+
}
278+
279+
schedSpec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", tzName, minute, hour, dow)
280+
281+
sched, err := schedule.Weekly(schedSpec)
282+
if err != nil {
283+
return nil, err
284+
}
285+
286+
if schedMin := sched.Min(); schedMin < min {
287+
return nil, xerrors.Errorf("minimum autostart interval %s is above template constraint %s", schedMin, min)
288+
}
289+
290+
return &schedSpec, nil
291+
}

cli/create_test.go

+58-14
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/coder/coder/cli/clitest"
1515
"github.com/coder/coder/coderd/coderdtest"
1616
"github.com/coder/coder/coderd/database"
17+
"github.com/coder/coder/coderd/util/ptr"
1718
"github.com/coder/coder/codersdk"
1819
"github.com/coder/coder/provisioner/echo"
1920
"github.com/coder/coder/provisionersdk/proto"
@@ -62,6 +63,57 @@ func TestCreate(t *testing.T) {
6263
<-doneChan
6364
})
6465

66+
t.Run("AboveTemplateMaxTTL", func(t *testing.T) {
67+
t.Parallel()
68+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
69+
user := coderdtest.CreateFirstUser(t, client)
70+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
71+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
72+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
73+
ctr.MaxTTLMillis = ptr.Ref((12 * time.Hour).Milliseconds())
74+
})
75+
args := []string{
76+
"create",
77+
"my-workspace",
78+
"--template", template.Name,
79+
"--ttl", "12h1m",
80+
"-y", // don't bother with waiting
81+
}
82+
cmd, root := clitest.New(t, args...)
83+
clitest.SetupConfig(t, client, root)
84+
pty := ptytest.New(t)
85+
cmd.SetIn(pty.Input())
86+
cmd.SetOut(pty.Output())
87+
err := cmd.Execute()
88+
assert.ErrorContains(t, err, "TTL must be below template maximum 12h0m0s")
89+
})
90+
91+
t.Run("BelowTemplateMinAutostartInterval", func(t *testing.T) {
92+
t.Parallel()
93+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
94+
user := coderdtest.CreateFirstUser(t, client)
95+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
96+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
97+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
98+
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
99+
})
100+
args := []string{
101+
"create",
102+
"my-workspace",
103+
"--template", template.Name,
104+
"--autostart-minute", "*", // Every minute
105+
"--autostart-hour", "*", // Every hour
106+
"-y", // don't bother with waiting
107+
}
108+
cmd, root := clitest.New(t, args...)
109+
clitest.SetupConfig(t, client, root)
110+
pty := ptytest.New(t)
111+
cmd.SetIn(pty.Input())
112+
cmd.SetOut(pty.Output())
113+
err := cmd.Execute()
114+
assert.ErrorContains(t, err, "minimum autostart interval 1m0s is above template constraint 1h0m0s")
115+
})
116+
65117
t.Run("CreateErrInvalidTz", func(t *testing.T) {
66118
t.Parallel()
67119
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
@@ -74,19 +126,15 @@ func TestCreate(t *testing.T) {
74126
"my-workspace",
75127
"--template", template.Name,
76128
"--tz", "invalid",
129+
"-y",
77130
}
78131
cmd, root := clitest.New(t, args...)
79132
clitest.SetupConfig(t, client, root)
80-
doneChan := make(chan struct{})
81133
pty := ptytest.New(t)
82134
cmd.SetIn(pty.Input())
83135
cmd.SetOut(pty.Output())
84-
go func() {
85-
defer close(doneChan)
86-
err := cmd.Execute()
87-
assert.EqualError(t, err, "Invalid workspace autostart timezone: unknown time zone invalid")
88-
}()
89-
<-doneChan
136+
err := cmd.Execute()
137+
assert.ErrorContains(t, err, "Invalid autostart schedule: Invalid workspace autostart timezone: unknown time zone invalid")
90138
})
91139

92140
t.Run("CreateErrInvalidTTL", func(t *testing.T) {
@@ -101,19 +149,15 @@ func TestCreate(t *testing.T) {
101149
"my-workspace",
102150
"--template", template.Name,
103151
"--ttl", "0s",
152+
"-y",
104153
}
105154
cmd, root := clitest.New(t, args...)
106155
clitest.SetupConfig(t, client, root)
107-
doneChan := make(chan struct{})
108156
pty := ptytest.New(t)
109157
cmd.SetIn(pty.Input())
110158
cmd.SetOut(pty.Output())
111-
go func() {
112-
defer close(doneChan)
113-
err := cmd.Execute()
114-
assert.EqualError(t, err, "TTL must be at least 1 minute")
115-
}()
116-
<-doneChan
159+
err := cmd.Execute()
160+
assert.EqualError(t, err, "TTL must be at least 1 minute")
117161
})
118162

119163
t.Run("CreateFromListWithSkip", func(t *testing.T) {

cli/templatecreate.go

+17-8
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,19 @@ import (
1414

1515
"github.com/coder/coder/cli/cliui"
1616
"github.com/coder/coder/coderd/database"
17+
"github.com/coder/coder/coderd/util/ptr"
1718
"github.com/coder/coder/codersdk"
1819
"github.com/coder/coder/provisionerd"
1920
"github.com/coder/coder/provisionersdk"
2021
)
2122

2223
func templateCreate() *cobra.Command {
2324
var (
24-
directory string
25-
provisioner string
26-
parameterFile string
25+
directory string
26+
provisioner string
27+
parameterFile string
28+
maxTTL time.Duration
29+
minAutostartInterval time.Duration
2730
)
2831
cmd := &cobra.Command{
2932
Use: "create [name]",
@@ -92,11 +95,15 @@ func templateCreate() *cobra.Command {
9295
return err
9396
}
9497

95-
_, err = client.CreateTemplate(cmd.Context(), organization.ID, codersdk.CreateTemplateRequest{
96-
Name: templateName,
97-
VersionID: job.ID,
98-
ParameterValues: parameters,
99-
})
98+
createReq := codersdk.CreateTemplateRequest{
99+
Name: templateName,
100+
VersionID: job.ID,
101+
ParameterValues: parameters,
102+
MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()),
103+
MinAutostartIntervalMillis: ptr.Ref(minAutostartInterval.Milliseconds()),
104+
}
105+
106+
_, err = client.CreateTemplate(cmd.Context(), organization.ID, createReq)
100107
if err != nil {
101108
return err
102109
}
@@ -115,6 +122,8 @@ func templateCreate() *cobra.Command {
115122
cmd.Flags().StringVarP(&directory, "directory", "d", currentDirectory, "Specify the directory to create from")
116123
cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend")
117124
cmd.Flags().StringVarP(&parameterFile, "parameter-file", "", "", "Specify a file path with parameter values.")
125+
cmd.Flags().DurationVarP(&maxTTL, "max-ttl", "", 168*time.Hour, "Specify a maximum TTL for worksapces created from this template.")
126+
cmd.Flags().DurationVarP(&minAutostartInterval, "min-autostart-interval", "", time.Hour, "Specify a minimum autostart interval for workspaces created from this template.")
118127
// This is for testing!
119128
err := cmd.Flags().MarkHidden("test.provisioner")
120129
if err != nil {

cli/templatecreate_test.go

+10-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,16 @@ func TestTemplateCreate(t *testing.T) {
2424
Parse: echo.ParseComplete,
2525
Provision: echo.ProvisionComplete,
2626
})
27-
cmd, root := clitest.New(t, "templates", "create", "my-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho))
27+
args := []string{
28+
"templates",
29+
"create",
30+
"my-template",
31+
"--directory", source,
32+
"--test.provisioner", string(database.ProvisionerTypeEcho),
33+
"--max-ttl", "24h",
34+
"--min-autostart-interval", "2h",
35+
}
36+
cmd, root := clitest.New(t, args...)
2837
clitest.SetupConfig(t, client, root)
2938
pty := ptytest.New(t)
3039
cmd.SetIn(pty.Input())

cli/ttl_test.go

+33
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,37 @@ func TestTTL(t *testing.T) {
168168
err := cmd.Execute()
169169
require.ErrorContains(t, err, "status code 403: Forbidden", "unexpected error")
170170
})
171+
172+
t.Run("TemplateMaxTTL", func(t *testing.T) {
173+
t.Parallel()
174+
175+
var (
176+
ctx = context.Background()
177+
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
178+
user = coderdtest.CreateFirstUser(t, client)
179+
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
180+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
181+
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
182+
ctr.MaxTTLMillis = ptr.Ref((8 * time.Hour).Milliseconds())
183+
})
184+
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
185+
cwr.TTLMillis = ptr.Ref((8 * time.Hour).Milliseconds())
186+
})
187+
cmdArgs = []string{"ttl", "set", workspace.Name, "24h"}
188+
stdoutBuf = &bytes.Buffer{}
189+
)
190+
191+
cmd, root := clitest.New(t, cmdArgs...)
192+
clitest.SetupConfig(t, client, root)
193+
cmd.SetOut(stdoutBuf)
194+
195+
err := cmd.Execute()
196+
require.ErrorContains(t, err, "ttl_ms: ttl must be below template maximum 8h0m0s")
197+
198+
// Ensure ttl not updated
199+
updated, err := client.Workspace(ctx, workspace.ID)
200+
require.NoError(t, err, "fetch updated workspace")
201+
require.NotNil(t, updated.TTLMillis)
202+
require.Equal(t, (8 * time.Hour).Milliseconds(), *updated.TTLMillis)
203+
})
171204
}

0 commit comments

Comments
 (0)