Skip to content

Commit

Permalink
feat: enforce template-level constraints for TTL and autostart (#2018)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
johnstcn committed Jun 7, 2022
1 parent 3878e64 commit 3e419dd
Show file tree
Hide file tree
Showing 27 changed files with 644 additions and 290 deletions.
23 changes: 23 additions & 0 deletions cli/autostart_test.go
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"testing"
"time"

"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -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")
})
}
12 changes: 12 additions & 0 deletions cli/bump_test.go
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down
55 changes: 39 additions & 16 deletions cli/create.go
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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
}
72 changes: 58 additions & 14 deletions cli/create_test.go
Expand Up @@ -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"
Expand Down Expand Up @@ -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})
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
25 changes: 17 additions & 8 deletions cli/templatecreate.go
Expand Up @@ -14,16 +14,19 @@ 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"
)

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]",
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
11 changes: 10 additions & 1 deletion cli/templatecreate_test.go
Expand Up @@ -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())
Expand Down
33 changes: 33 additions & 0 deletions cli/ttl_test.go
Expand Up @@ -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)
})
}

0 comments on commit 3e419dd

Please sign in to comment.