refactor: workspace autostop_schedule -> ttl (#1578)

Co-authored-by: G r e y <grey@coder.com>
This commit is contained in:
Cian Johnston 2022-05-19 20:09:27 +01:00 committed by GitHub
parent 6c1117094d
commit d72c45e483
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 549 additions and 490 deletions

View File

@ -3,6 +3,6 @@ package usershell
import "os"
// Get returns the $SHELL environment variable.
func Get(username string) (string, error) {
func Get(_ string) (string, error) {
return os.Getenv("SHELL"), nil
}

View File

@ -1,167 +0,0 @@
package cli
import (
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/coder/coder/coderd/autobuild/schedule"
"github.com/coder/coder/codersdk"
)
const autostopDescriptionLong = `To have your workspace stop automatically at a regular time you can enable autostop.
When enabling autostop, provide the minute, hour, and day(s) of week.
The default autostop schedule is at 18:00 in your local timezone (TZ env, UTC by default).
`
func autostop() *cobra.Command {
autostopCmd := &cobra.Command{
Annotations: workspaceCommand,
Use: "autostop enable <workspace>",
Short: "schedule a workspace to automatically stop at a regular time",
Long: autostopDescriptionLong,
Example: "coder autostop enable my-workspace --minute 0 --hour 18 --days 1-5 -tz Europe/Dublin",
}
autostopCmd.AddCommand(autostopShow())
autostopCmd.AddCommand(autostopEnable())
autostopCmd.AddCommand(autostopDisable())
return autostopCmd
}
func autostopShow() *cobra.Command {
cmd := &cobra.Command{
Use: "show <workspace_name>",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return err
}
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
if err != nil {
return err
}
if workspace.AutostopSchedule == "" {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "not enabled\n")
return nil
}
validSchedule, err := schedule.Weekly(workspace.AutostopSchedule)
if err != nil {
// This should never happen.
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "invalid autostop schedule %q for workspace %s: %s\n", workspace.AutostopSchedule, workspace.Name, err.Error())
return nil
}
next := validSchedule.Next(time.Now())
loc, _ := time.LoadLocation(validSchedule.Timezone())
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
"schedule: %s\ntimezone: %s\nnext: %s\n",
validSchedule.Cron(),
validSchedule.Timezone(),
next.In(loc),
)
return nil
},
}
return cmd
}
func autostopEnable() *cobra.Command {
// yes some of these are technically numbers but the cron library will do that work
var autostopMinute string
var autostopHour string
var autostopDayOfWeek string
var autostopTimezone string
cmd := &cobra.Command{
Use: "enable <workspace_name> <schedule>",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return err
}
spec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", autostopTimezone, autostopMinute, autostopHour, autostopDayOfWeek)
validSchedule, err := schedule.Weekly(spec)
if err != nil {
return err
}
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
if err != nil {
return err
}
err = client.UpdateWorkspaceAutostop(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
Schedule: validSchedule.String(),
})
if err != nil {
return err
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will automatically stop at %s.\n\n", workspace.Name, validSchedule.Next(time.Now()))
return nil
},
}
cmd.Flags().StringVar(&autostopMinute, "minute", "0", "autostop minute")
cmd.Flags().StringVar(&autostopHour, "hour", "18", "autostop hour")
cmd.Flags().StringVar(&autostopDayOfWeek, "days", "1-5", "autostop day(s) of week")
tzEnv := os.Getenv("TZ")
if tzEnv == "" {
tzEnv = "UTC"
}
cmd.Flags().StringVar(&autostopTimezone, "tz", tzEnv, "autostop timezone")
return cmd
}
func autostopDisable() *cobra.Command {
return &cobra.Command{
Use: "disable <workspace_name>",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return err
}
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
if err != nil {
return err
}
err = client.UpdateWorkspaceAutostop(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
Schedule: "",
})
if err != nil {
return err
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will no longer automatically stop.\n\n", workspace.Name)
return nil
},
}
}

View File

@ -49,7 +49,7 @@ func list() *cobra.Command {
}
tableWriter := cliui.Table()
header := table.Row{"workspace", "template", "status", "last built", "outdated", "autostart", "autostop"}
header := table.Row{"workspace", "template", "status", "last built", "outdated", "autostart", "ttl"}
tableWriter.AppendHeader(header)
tableWriter.SortBy([]table.SortBy{{
Name: "workspace",
@ -116,10 +116,8 @@ func list() *cobra.Command {
}
autostopDisplay := "-"
if workspace.AutostopSchedule != "" {
if sched, err := schedule.Weekly(workspace.AutostopSchedule); err == nil {
autostopDisplay = sched.Cron()
}
if workspace.TTL != nil {
autostopDisplay = workspace.TTL.String()
}
user := usersByID[workspace.OwnerID]

View File

@ -62,7 +62,6 @@ func Root() *cobra.Command {
cmd.AddCommand(
autostart(),
autostop(),
configSSH(),
create(),
delete(),
@ -78,6 +77,7 @@ func Root() *cobra.Command {
stop(),
ssh(),
templates(),
ttl(),
update(),
users(),
portForward(),

View File

@ -21,7 +21,6 @@ import (
"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/coderd/autobuild/notify"
"github.com/coder/coder/coderd/autobuild/schedule"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
)
@ -270,16 +269,11 @@ func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID u
return time.Time{}, nil
}
if ws.AutostopSchedule == "" {
if ws.TTL == nil || *ws.TTL == 0 {
return time.Time{}, nil
}
sched, err := schedule.Weekly(ws.AutostopSchedule)
if err != nil {
return time.Time{}, nil
}
deadline = sched.Next(now)
deadline = ws.LatestBuild.UpdatedAt.Add(*ws.TTL)
callback = func() {
ttl := deadline.Sub(now)
var title, body string

144
cli/ttl.go Normal file
View File

@ -0,0 +1,144 @@
package cli
import (
"fmt"
"time"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/codersdk"
)
const ttlDescriptionLong = `To have your workspace stop automatically after a configurable interval has passed.
Minimum TTL is 1 minute.
`
func ttl() *cobra.Command {
ttlCmd := &cobra.Command{
Annotations: workspaceCommand,
Use: "ttl [command]",
Short: "Schedule a workspace to automatically stop after a configurable interval",
Long: ttlDescriptionLong,
Example: "coder ttl set my-workspace 8h30m",
}
ttlCmd.AddCommand(ttlShow())
ttlCmd.AddCommand(ttlset())
ttlCmd.AddCommand(ttlunset())
return ttlCmd
}
func ttlShow() *cobra.Command {
cmd := &cobra.Command{
Use: "show <workspace_name>",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return xerrors.Errorf("create client: %w", err)
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return xerrors.Errorf("get current org: %w", err)
}
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
if err != nil {
return xerrors.Errorf("get workspace: %w", err)
}
if workspace.TTL == nil {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "not set\n")
return nil
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", workspace.TTL)
return nil
},
}
return cmd
}
func ttlset() *cobra.Command {
cmd := &cobra.Command{
Use: "set <workspace_name> <ttl>",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return xerrors.Errorf("create client: %w", err)
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return xerrors.Errorf("get current org: %w", err)
}
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
if err != nil {
return xerrors.Errorf("get workspace: %w", err)
}
ttl, err := time.ParseDuration(args[1])
if err != nil {
return xerrors.Errorf("parse ttl: %w", err)
}
truncated := ttl.Truncate(time.Minute)
if truncated == 0 {
return xerrors.Errorf("ttl must be at least 1m")
}
if truncated != ttl {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "warning: ttl rounded down to %s\n", truncated)
}
err = client.UpdateWorkspaceTTL(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: &truncated,
})
if err != nil {
return xerrors.Errorf("update workspace ttl: %w", err)
}
return nil
},
}
return cmd
}
func ttlunset() *cobra.Command {
return &cobra.Command{
Use: "unset <workspace_name>",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return xerrors.Errorf("create client: %w", err)
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return xerrors.Errorf("get current org: %w", err)
}
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
if err != nil {
return xerrors.Errorf("get workspace: %w", err)
}
err = client.UpdateWorkspaceTTL(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: nil,
})
if err != nil {
return xerrors.Errorf("update workspace ttl: %w", err)
}
_, _ = fmt.Fprint(cmd.OutOrStdout(), "ttl unset\n", workspace.Name)
return nil
},
}
}

View File

@ -3,9 +3,9 @@ package cli_test
import (
"bytes"
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
@ -14,7 +14,7 @@ import (
"github.com/coder/coder/codersdk"
)
func TestAutostop(t *testing.T) {
func TestTTL(t *testing.T) {
t.Parallel()
t.Run("ShowOK", func(t *testing.T) {
@ -29,13 +29,13 @@ func TestAutostop(t *testing.T) {
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
cmdArgs = []string{"autostop", "show", workspace.Name}
sched = "CRON_TZ=Europe/Dublin 30 17 * * 1-5"
cmdArgs = []string{"ttl", "show", workspace.Name}
ttl = 8*time.Hour + 30*time.Minute + 30*time.Second
stdoutBuf = &bytes.Buffer{}
)
err := client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
Schedule: sched,
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: &ttl,
})
require.NoError(t, err)
@ -45,11 +45,10 @@ func TestAutostop(t *testing.T) {
err = cmd.Execute()
require.NoError(t, err, "unexpected error")
// CRON_TZ gets stripped
require.Contains(t, stdoutBuf.String(), "schedule: 30 17 * * 1-5")
require.Equal(t, ttl.Truncate(time.Minute).String(), strings.TrimSpace(stdoutBuf.String()))
})
t.Run("EnableDisableOK", func(t *testing.T) {
t.Run("SetUnsetOK", func(t *testing.T) {
t.Parallel()
var (
@ -61,8 +60,8 @@ func TestAutostop(t *testing.T) {
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
cmdArgs = []string{"autostop", "enable", workspace.Name, "--minute", "30", "--hour", "17", "--days", "1-5", "--tz", "Europe/Dublin"}
sched = "CRON_TZ=Europe/Dublin 30 17 * * 1-5"
ttl = 8*time.Hour + 30*time.Minute + 30*time.Second
cmdArgs = []string{"ttl", "set", workspace.Name, ttl.String()}
stdoutBuf = &bytes.Buffer{}
)
@ -72,65 +71,28 @@ func TestAutostop(t *testing.T) {
err := cmd.Execute()
require.NoError(t, err, "unexpected error")
require.Contains(t, stdoutBuf.String(), "will automatically stop at", "unexpected output")
// Ensure autostop schedule updated
// Ensure ttl updated
updated, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err, "fetch updated workspace")
require.Equal(t, sched, updated.AutostopSchedule, "expected autostop schedule to be set")
require.Equal(t, ttl.Truncate(time.Minute), *updated.TTL)
require.Contains(t, stdoutBuf.String(), "warning: ttl rounded down")
// Disable schedule
cmd, root = clitest.New(t, "autostop", "disable", workspace.Name)
// unset schedule
cmd, root = clitest.New(t, "ttl", "unset", workspace.Name)
clitest.SetupConfig(t, client, root)
cmd.SetOut(stdoutBuf)
err = cmd.Execute()
require.NoError(t, err, "unexpected error")
require.Contains(t, stdoutBuf.String(), "will no longer automatically stop", "unexpected output")
// Ensure autostop schedule updated
// Ensure ttl updated
updated, err = client.Workspace(ctx, workspace.ID)
require.NoError(t, err, "fetch updated workspace")
require.Empty(t, updated.AutostopSchedule, "expected autostop schedule to not be set")
require.Nil(t, updated.TTL, "expected ttl to not be set")
})
t.Run("Enable_NotFound", func(t *testing.T) {
t.Parallel()
var (
client = coderdtest.New(t, nil)
_ = coderdtest.NewProvisionerDaemon(t, client)
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
)
cmd, root := clitest.New(t, "autostop", "enable", "doesnotexist")
clitest.SetupConfig(t, client, root)
err := cmd.Execute()
require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error")
})
t.Run("Disable_NotFound", func(t *testing.T) {
t.Parallel()
var (
client = coderdtest.New(t, nil)
_ = coderdtest.NewProvisionerDaemon(t, client)
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
)
cmd, root := clitest.New(t, "autostop", "disable", "doesnotexist")
clitest.SetupConfig(t, client, root)
err := cmd.Execute()
require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error")
})
t.Run("Enable_DefaultSchedule", func(t *testing.T) {
t.Run("ZeroInvalid", func(t *testing.T) {
t.Parallel()
var (
@ -142,24 +104,72 @@ func TestAutostop(t *testing.T) {
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
ttl = 8*time.Hour + 30*time.Minute + 30*time.Second
cmdArgs = []string{"ttl", "set", workspace.Name, ttl.String()}
stdoutBuf = &bytes.Buffer{}
)
// check current TZ env var
currTz := os.Getenv("TZ")
if currTz == "" {
currTz = "UTC"
}
expectedSchedule := fmt.Sprintf("CRON_TZ=%s 0 18 * * 1-5", currTz)
cmd, root := clitest.New(t, "autostop", "enable", workspace.Name)
cmd, root := clitest.New(t, cmdArgs...)
clitest.SetupConfig(t, client, root)
cmd.SetOut(stdoutBuf)
err := cmd.Execute()
require.NoError(t, err, "unexpected error")
// Ensure nothing happened
// Ensure ttl updated
updated, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err, "fetch updated workspace")
require.Equal(t, expectedSchedule, updated.AutostopSchedule, "expected default autostop schedule")
require.Equal(t, ttl.Truncate(time.Minute), *updated.TTL)
require.Contains(t, stdoutBuf.String(), "warning: ttl rounded down")
// A TTL of zero is not considered valid.
stdoutBuf.Reset()
cmd, root = clitest.New(t, "ttl", "set", workspace.Name, "0s")
clitest.SetupConfig(t, client, root)
cmd.SetOut(stdoutBuf)
err = cmd.Execute()
require.EqualError(t, err, "ttl must be at least 1m", "unexpected error")
// Ensure ttl remains as before
updated, err = client.Workspace(ctx, workspace.ID)
require.NoError(t, err, "fetch updated workspace")
require.Equal(t, ttl.Truncate(time.Minute), *updated.TTL)
})
t.Run("Set_NotFound", func(t *testing.T) {
t.Parallel()
var (
client = coderdtest.New(t, nil)
_ = coderdtest.NewProvisionerDaemon(t, client)
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
)
cmd, root := clitest.New(t, "ttl", "set", "doesnotexist", "8h30m")
clitest.SetupConfig(t, client, root)
err := cmd.Execute()
require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error")
})
t.Run("Unset_NotFound", func(t *testing.T) {
t.Parallel()
var (
client = coderdtest.New(t, nil)
_ = coderdtest.NewProvisionerDaemon(t, client)
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
)
cmd, root := clitest.New(t, "ttl", "unset", "doesnotexist")
clitest.SetupConfig(t, client, root)
err := cmd.Execute()
require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error")
})
}

View File

@ -123,6 +123,22 @@ func convertDiffType(left, right any) (newLeft, newRight any, changed bool) {
return leftStr, rightStr, true
case sql.NullInt64:
var leftInt64Ptr *int64
var rightInt64Ptr *int64
if !typed.Valid {
leftInt64Ptr = nil
} else {
leftInt64Ptr = ptr(typed.Int64)
}
rightInt64Ptr = ptr(right.(sql.NullInt64).Int64)
if !right.(sql.NullInt64).Valid {
rightInt64Ptr = nil
}
return leftInt64Ptr, rightInt64Ptr, true
default:
return left, right, false
}
@ -147,3 +163,7 @@ func derefPointer(ptr reflect.Value) reflect.Value {
return ptr
}
func ptr[T any](x T) *T {
return &x
}

View File

@ -172,7 +172,7 @@ func TestDiff(t *testing.T) {
TemplateID: uuid.UUID{3},
Name: "rust workspace",
AutostartSchedule: sql.NullString{String: "0 12 * * 1-5", Valid: true},
AutostopSchedule: sql.NullString{String: "0 2 * * 2-6", Valid: true},
Ttl: sql.NullInt64{Int64: int64(8 * time.Hour), Valid: true},
},
exp: audit.Map{
"id": uuid.UUID{1}.String(),
@ -180,7 +180,7 @@ func TestDiff(t *testing.T) {
"template_id": uuid.UUID{3}.String(),
"name": "rust workspace",
"autostart_schedule": "0 12 * * 1-5",
"autostop_schedule": "0 2 * * 2-6",
"ttl": int64(28800000000000), // XXX: pq still does not support time.Duration
},
},
{
@ -194,7 +194,7 @@ func TestDiff(t *testing.T) {
TemplateID: uuid.UUID{3},
Name: "rust workspace",
AutostartSchedule: sql.NullString{},
AutostopSchedule: sql.NullString{},
Ttl: sql.NullInt64{},
},
exp: audit.Map{
"id": uuid.UUID{1}.String(),

View File

@ -101,7 +101,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
"deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired.
"name": ActionTrack,
"autostart_schedule": ActionTrack,
"autostop_schedule": ActionTrack,
"ttl": ActionTrack,
},
})

View File

@ -50,7 +50,7 @@ func (e *Executor) Run() {
func (e *Executor) runOnce(t time.Time) error {
currentTick := t.Truncate(time.Minute)
return e.db.InTx(func(db database.Store) error {
eligibleWorkspaces, err := db.GetWorkspacesAutostartAutostop(e.ctx)
eligibleWorkspaces, err := db.GetWorkspacesAutostart(e.ctx)
if err != nil {
return xerrors.Errorf("get eligible workspaces for autostart or autostop: %w", err)
}
@ -84,21 +84,25 @@ func (e *Executor) runOnce(t time.Time) error {
}
var validTransition database.WorkspaceTransition
var sched *schedule.Schedule
var nextTransition time.Time
switch priorHistory.Transition {
case database.WorkspaceTransitionStart:
validTransition = database.WorkspaceTransitionStop
sched, err = schedule.Weekly(ws.AutostopSchedule.String)
if err != nil {
e.log.Warn(e.ctx, "workspace has invalid autostop schedule, skipping",
if !ws.Ttl.Valid || ws.Ttl.Int64 == 0 {
e.log.Debug(e.ctx, "invalid or zero ws ttl, skipping",
slog.F("workspace_id", ws.ID),
slog.F("autostart_schedule", ws.AutostopSchedule.String),
slog.F("ttl", time.Duration(ws.Ttl.Int64)),
)
continue
}
ttl := time.Duration(ws.Ttl.Int64)
// Measure TTL from the time the workspace finished building.
// Truncate to nearest minute for consistency with autostart
// behavior, and add one minute for padding.
nextTransition = priorHistory.UpdatedAt.Truncate(time.Minute).Add(ttl + time.Minute)
case database.WorkspaceTransitionStop:
validTransition = database.WorkspaceTransitionStart
sched, err = schedule.Weekly(ws.AutostartSchedule.String)
sched, err := schedule.Weekly(ws.AutostartSchedule.String)
if err != nil {
e.log.Warn(e.ctx, "workspace has invalid autostart schedule, skipping",
slog.F("workspace_id", ws.ID),
@ -106,6 +110,9 @@ func (e *Executor) runOnce(t time.Time) error {
)
continue
}
// Round down to the nearest minute, as this is the finest granularity cron supports.
// Truncate is probably not necessary here, but doing it anyway to be sure.
nextTransition = sched.Next(priorHistory.CreatedAt).Truncate(time.Minute)
default:
e.log.Debug(e.ctx, "last transition not valid for autostart or autostop",
slog.F("workspace_id", ws.ID),
@ -114,13 +121,10 @@ func (e *Executor) runOnce(t time.Time) error {
continue
}
// Round time down to the nearest minute, as this is the finest granularity cron supports.
// Truncate is probably not necessary here, but doing it anyway to be sure.
nextTransitionAt := sched.Next(priorHistory.CreatedAt).Truncate(time.Minute)
if currentTick.Before(nextTransitionAt) {
if currentTick.Before(nextTransition) {
e.log.Debug(e.ctx, "skipping workspace: too early",
slog.F("workspace_id", ws.ID),
slog.F("next_transition_at", nextTransitionAt),
slog.F("next_transition_at", nextTransition),
slog.F("transition", validTransition),
slog.F("current_tick", currentTick),
)

View File

@ -194,27 +194,27 @@ func TestExecutorAutostopOK(t *testing.T) {
})
// Given: we have a user with a workspace
workspace = mustProvisionWorkspace(t, client)
ttl = time.Minute
)
// Given: workspace is running
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
// Given: the workspace initially has autostop disabled
require.Empty(t, workspace.AutostopSchedule)
require.Nil(t, workspace.TTL)
// When: we enable workspace autostop
sched, err := schedule.Weekly("* * * * *")
require.NoError(t, err)
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
Schedule: sched.String(),
require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: &ttl,
}))
// When: the autobuild executor ticks
// When: the autobuild executor ticks *after* the TTL:
go func() {
tickCh <- time.Now().UTC().Add(time.Minute)
tickCh <- time.Now().UTC().Add(ttl + time.Minute)
close(tickCh)
}()
// Then: the workspace should be started
// Then: the workspace should be stopped
<-time.After(5 * time.Second)
ws := mustWorkspace(t, client, workspace.ID)
require.NotEqual(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected a workspace build to occur")
@ -234,24 +234,24 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) {
})
// Given: we have a user with a workspace
workspace = mustProvisionWorkspace(t, client)
ttl = time.Minute
)
// Given: workspace is stopped
workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
// Given: the workspace initially has autostop disabled
require.Empty(t, workspace.AutostopSchedule)
require.Nil(t, workspace.TTL)
// When: we enable workspace autostart
sched, err := schedule.Weekly("* * * * *")
// When: we set the TTL on the workspace
require.NoError(t, err)
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
Schedule: sched.String(),
require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: &ttl,
}))
// When: the autobuild executor ticks
// When: the autobuild executor ticks past the TTL
go func() {
tickCh <- time.Now().UTC().Add(time.Minute)
tickCh <- time.Now().UTC().Add(ttl)
close(tickCh)
}()
@ -278,7 +278,7 @@ func TestExecutorAutostopNotEnabled(t *testing.T) {
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
// Given: the workspace has autostop disabled
require.Empty(t, workspace.AutostopSchedule)
require.Empty(t, workspace.TTL)
// When: the autobuild executor ticks
go func() {
@ -308,12 +308,12 @@ func TestExecutorWorkspaceDeleted(t *testing.T) {
)
// Given: the workspace initially has autostart disabled
require.Empty(t, workspace.AutostopSchedule)
require.Empty(t, workspace.AutostartSchedule)
// When: we enable workspace autostart
sched, err := schedule.Weekly("* * * * *")
require.NoError(t, err)
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
Schedule: sched.String(),
}))
@ -333,7 +333,7 @@ func TestExecutorWorkspaceDeleted(t *testing.T) {
require.Equal(t, codersdk.WorkspaceTransitionDelete, ws.LatestBuild.Transition, "expected workspace to be deleted")
}
func TestExecutorWorkspaceTooEarly(t *testing.T) {
func TestExecutorWorkspaceAutostartTooEarly(t *testing.T) {
t.Parallel()
var (
@ -348,14 +348,14 @@ func TestExecutorWorkspaceTooEarly(t *testing.T) {
)
// Given: the workspace initially has autostart disabled
require.Empty(t, workspace.AutostopSchedule)
require.Empty(t, workspace.AutostartSchedule)
// When: we enable workspace autostart with some time in the future
futureTime := time.Now().Add(time.Hour)
futureTimeCron := fmt.Sprintf("%d %d * * *", futureTime.Minute(), futureTime.Hour())
sched, err := schedule.Weekly(futureTimeCron)
require.NoError(t, err)
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
Schedule: sched.String(),
}))
@ -372,6 +372,41 @@ func TestExecutorWorkspaceTooEarly(t *testing.T) {
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
}
func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
t.Parallel()
var (
ctx = context.Background()
tickCh = make(chan time.Time)
client = coderdtest.New(t, &coderdtest.Options{
AutobuildTicker: tickCh,
})
// Given: we have a user with a workspace
workspace = mustProvisionWorkspace(t, client)
ttl = time.Hour
)
// Given: the workspace initially has TTL unset
require.Nil(t, workspace.TTL)
// When: we set the TTL to some time in the distant future
require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: &ttl,
}))
// When: the autobuild executor ticks
go func() {
tickCh <- time.Now().UTC()
close(tickCh)
}()
// Then: nothing should happen
<-time.After(5 * time.Second)
ws := mustWorkspace(t, client, workspace.ID)
require.Equal(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected no further workspace builds to occur")
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
}
func TestExecutorAutostartMultipleOK(t *testing.T) {
if os.Getenv("DB") == "" {
t.Skip(`This test only really works when using a "real" database, similar to a HA setup`)

View File

@ -305,8 +305,8 @@ func New(options *Options) (http.Handler, func()) {
r.Route("/autostart", func(r chi.Router) {
r.Put("/", api.putWorkspaceAutostart)
})
r.Route("/autostop", func(r chi.Router) {
r.Put("/", api.putWorkspaceAutostop)
r.Route("/ttl", func(r chi.Router) {
r.Put("/", api.putWorkspaceTTL)
})
r.Get("/watch", api.watchWorkspace)
})

View File

@ -363,14 +363,14 @@ func (q *fakeQuerier) GetWorkspaceByOwnerIDAndName(_ context.Context, arg databa
return database.Workspace{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetWorkspacesAutostartAutostop(_ context.Context) ([]database.Workspace, error) {
func (q *fakeQuerier) GetWorkspacesAutostart(_ context.Context) ([]database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
workspaces := make([]database.Workspace, 0)
for _, ws := range q.workspaces {
if ws.AutostartSchedule.String != "" {
workspaces = append(workspaces, ws)
} else if ws.AutostopSchedule.String != "" {
} else if ws.Ttl.Valid {
workspaces = append(workspaces, ws)
}
}
@ -1666,7 +1666,7 @@ func (q *fakeQuerier) UpdateWorkspaceAutostart(_ context.Context, arg database.U
return sql.ErrNoRows
}
func (q *fakeQuerier) UpdateWorkspaceAutostop(_ context.Context, arg database.UpdateWorkspaceAutostopParams) error {
func (q *fakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateWorkspaceTTLParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
@ -1674,7 +1674,7 @@ func (q *fakeQuerier) UpdateWorkspaceAutostop(_ context.Context, arg database.Up
if workspace.ID != arg.ID {
continue
}
workspace.AutostopSchedule = arg.AutostopSchedule
workspace.Ttl = arg.Ttl
q.workspaces[index] = workspace
return nil
}

View File

@ -314,7 +314,7 @@ CREATE TABLE workspaces (
deleted boolean DEFAULT false NOT NULL,
name character varying(64) NOT NULL,
autostart_schedule text,
autostop_schedule text
ttl bigint
);
ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('public.licenses_id_seq'::regclass);
@ -483,4 +483,3 @@ ALTER TABLE ONLY workspaces
ALTER TABLE ONLY workspaces
ADD CONSTRAINT workspaces_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE RESTRICT;

View File

@ -0,0 +1,2 @@
ALTER TABLE ONLY workspaces DROP COLUMN ttl;
ALTER TABLE ONLY workspaces ADD COLUMN autostop_schedule text DEFAULT NULL;

View File

@ -0,0 +1,2 @@
ALTER TABLE ONLY workspaces DROP COLUMN autostop_schedule;
ALTER TABLE ONLY workspaces ADD COLUMN ttl BIGINT DEFAULT NULL;

View File

@ -471,7 +471,7 @@ type Workspace struct {
Deleted bool `db:"deleted" json:"deleted"`
Name string `db:"name" json:"name"`
AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"`
AutostopSchedule sql.NullString `db:"autostop_schedule" json:"autostop_schedule"`
Ttl sql.NullInt64 `db:"ttl" json:"ttl"`
}
type WorkspaceAgent struct {

View File

@ -70,7 +70,7 @@ type querier interface {
GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error)
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error)
GetWorkspacesAutostartAutostop(ctx context.Context) ([]Workspace, error)
GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error)
GetWorkspacesByOrganizationIDs(ctx context.Context, arg GetWorkspacesByOrganizationIDsParams) ([]Workspace, error)
GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error)
GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error)
@ -109,9 +109,9 @@ type querier interface {
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error
UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error
UpdateWorkspaceAutostop(ctx context.Context, arg UpdateWorkspaceAutostopParams) error
UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error
}
var _ querier = (*sqlQuerier)(nil)

View File

@ -3193,7 +3193,7 @@ func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWork
const getWorkspaceByID = `-- name: GetWorkspaceByID :one
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
FROM
workspaces
WHERE
@ -3215,14 +3215,14 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
)
return i, err
}
const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
FROM
workspaces
WHERE
@ -3250,7 +3250,7 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
)
return i, err
}
@ -3295,23 +3295,23 @@ func (q *sqlQuerier) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, i
return items, nil
}
const getWorkspacesAutostartAutostop = `-- name: GetWorkspacesAutostartAutostop :many
const getWorkspacesAutostart = `-- name: GetWorkspacesAutostart :many
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
FROM
workspaces
WHERE
deleted = false
AND
(
autostart_schedule <> ''
(autostart_schedule IS NOT NULL AND autostart_schedule <> '')
OR
autostop_schedule <> ''
(ttl IS NOT NULL AND ttl > 0)
)
`
func (q *sqlQuerier) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesAutostartAutostop)
func (q *sqlQuerier) GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesAutostart)
if err != nil {
return nil, err
}
@ -3329,7 +3329,7 @@ func (q *sqlQuerier) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Work
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
); err != nil {
return nil, err
}
@ -3345,7 +3345,7 @@ func (q *sqlQuerier) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Work
}
const getWorkspacesByOrganizationIDs = `-- name: GetWorkspacesByOrganizationIDs :many
SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE organization_id = ANY($1 :: uuid [ ]) AND deleted = $2
SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl FROM workspaces WHERE organization_id = ANY($1 :: uuid [ ]) AND deleted = $2
`
type GetWorkspacesByOrganizationIDsParams struct {
@ -3372,7 +3372,7 @@ func (q *sqlQuerier) GetWorkspacesByOrganizationIDs(ctx context.Context, arg Get
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
); err != nil {
return nil, err
}
@ -3389,7 +3389,7 @@ func (q *sqlQuerier) GetWorkspacesByOrganizationIDs(ctx context.Context, arg Get
const getWorkspacesByTemplateID = `-- name: GetWorkspacesByTemplateID :many
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
FROM
workspaces
WHERE
@ -3421,7 +3421,7 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorks
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
); err != nil {
return nil, err
}
@ -3438,7 +3438,7 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorks
const getWorkspacesWithFilter = `-- name: GetWorkspacesWithFilter :many
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
FROM
workspaces
WHERE
@ -3483,7 +3483,7 @@ func (q *sqlQuerier) GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspa
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
); err != nil {
return nil, err
}
@ -3510,7 +3510,7 @@ INSERT INTO
name
)
VALUES
($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
`
type InsertWorkspaceParams struct {
@ -3544,7 +3544,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
)
return i, err
}
@ -3568,25 +3568,6 @@ func (q *sqlQuerier) UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWor
return err
}
const updateWorkspaceAutostop = `-- name: UpdateWorkspaceAutostop :exec
UPDATE
workspaces
SET
autostop_schedule = $2
WHERE
id = $1
`
type UpdateWorkspaceAutostopParams struct {
ID uuid.UUID `db:"id" json:"id"`
AutostopSchedule sql.NullString `db:"autostop_schedule" json:"autostop_schedule"`
}
func (q *sqlQuerier) UpdateWorkspaceAutostop(ctx context.Context, arg UpdateWorkspaceAutostopParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceAutostop, arg.ID, arg.AutostopSchedule)
return err
}
const updateWorkspaceDeletedByID = `-- name: UpdateWorkspaceDeletedByID :exec
UPDATE
workspaces
@ -3605,3 +3586,22 @@ func (q *sqlQuerier) UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateW
_, err := q.db.ExecContext(ctx, updateWorkspaceDeletedByID, arg.ID, arg.Deleted)
return err
}
const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec
UPDATE
workspaces
SET
ttl = $2
WHERE
id = $1
`
type UpdateWorkspaceTTLParams struct {
ID uuid.UUID `db:"id" json:"id"`
Ttl sql.NullInt64 `db:"ttl" json:"ttl"`
}
func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceTTL, arg.ID, arg.Ttl)
return err
}

View File

@ -33,7 +33,7 @@ WHERE
-- name: GetWorkspacesByOrganizationIDs :many
SELECT * FROM workspaces WHERE organization_id = ANY(@ids :: uuid [ ]) AND deleted = @deleted;
-- name: GetWorkspacesAutostartAutostop :many
-- name: GetWorkspacesAutostart :many
SELECT
*
FROM
@ -42,9 +42,9 @@ WHERE
deleted = false
AND
(
autostart_schedule <> ''
(autostart_schedule IS NOT NULL AND autostart_schedule <> '')
OR
autostop_schedule <> ''
(ttl IS NOT NULL AND ttl > 0)
);
-- name: GetWorkspacesByTemplateID :many
@ -107,10 +107,10 @@ SET
WHERE
id = $1;
-- name: UpdateWorkspaceAutostop :exec
-- name: UpdateWorkspaceTTL :exec
UPDATE
workspaces
SET
autostop_schedule = $2
ttl = $2
WHERE
id = $1;

View File

@ -547,38 +547,32 @@ func (api *api) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
}
}
func (api *api) putWorkspaceAutostop(rw http.ResponseWriter, r *http.Request) {
func (api *api) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceWorkspace.
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
var req codersdk.UpdateWorkspaceAutostopRequest
var req codersdk.UpdateWorkspaceTTLRequest
if !httpapi.Read(rw, r, &req) {
return
}
var dbSched sql.NullString
if req.Schedule != "" {
validSched, err := schedule.Weekly(req.Schedule)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("invalid autostop schedule: %s", err),
})
return
}
dbSched.String = validSched.String()
dbSched.Valid = true
var dbTTL sql.NullInt64
if req.TTL != nil && *req.TTL > 0 {
truncated := req.TTL.Truncate(time.Minute)
dbTTL.Int64 = int64(truncated)
dbTTL.Valid = true
}
err := api.Database.UpdateWorkspaceAutostop(r.Context(), database.UpdateWorkspaceAutostopParams{
ID: workspace.ID,
AutostopSchedule: dbSched,
err := api.Database.UpdateWorkspaceTTL(r.Context(), database.UpdateWorkspaceTTLParams{
ID: workspace.ID,
Ttl: dbTTL,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("update workspace autostop schedule: %s", err),
Message: fmt.Sprintf("update workspace ttl: %s", err),
})
return
}
@ -777,6 +771,14 @@ func convertWorkspace(workspace database.Workspace, workspaceBuild codersdk.Work
Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(),
Name: workspace.Name,
AutostartSchedule: workspace.AutostartSchedule.String,
AutostopSchedule: workspace.AutostopSchedule.String,
TTL: convertSQLNullInt64(workspace.Ttl),
}
}
func convertSQLNullInt64(i sql.NullInt64) *time.Duration {
if !i.Valid {
return nil
}
return (*time.Duration)(&i.Int64)
}

View File

@ -551,69 +551,21 @@ func TestWorkspaceUpdateAutostart(t *testing.T) {
func TestWorkspaceUpdateAutostop(t *testing.T) {
t.Parallel()
var dublinLoc = mustLocation(t, "Europe/Dublin")
testCases := []struct {
name string
schedule string
expectedError string
at time.Time
expectedNext time.Time
expectedInterval time.Duration
name string
ttl *time.Duration
expectedError string
}{
{
name: "disable autostop",
schedule: "",
name: "disable ttl",
ttl: nil,
expectedError: "",
},
{
name: "friday to monday",
schedule: "CRON_TZ=Europe/Dublin 30 17 * * 1-5",
expectedError: "",
at: time.Date(2022, 5, 6, 17, 31, 0, 0, dublinLoc),
expectedNext: time.Date(2022, 5, 9, 17, 30, 0, 0, dublinLoc),
expectedInterval: 71*time.Hour + 59*time.Minute,
},
{
name: "monday to tuesday",
schedule: "CRON_TZ=Europe/Dublin 30 17 * * 1-5",
expectedError: "",
at: time.Date(2022, 5, 9, 17, 31, 0, 0, dublinLoc),
expectedNext: time.Date(2022, 5, 10, 17, 30, 0, 0, dublinLoc),
expectedInterval: 23*time.Hour + 59*time.Minute,
},
{
// DST in Ireland began on Mar 27 in 2022 at 0100. Forward 1 hour.
name: "DST start",
schedule: "CRON_TZ=Europe/Dublin 30 17 * * *",
expectedError: "",
at: time.Date(2022, 3, 26, 17, 31, 0, 0, dublinLoc),
expectedNext: time.Date(2022, 3, 27, 17, 30, 0, 0, dublinLoc),
expectedInterval: 22*time.Hour + 59*time.Minute,
},
{
// DST in Ireland ends on Oct 30 in 2022 at 0200. Back 1 hour.
name: "DST end",
schedule: "CRON_TZ=Europe/Dublin 30 17 * * *",
expectedError: "",
at: time.Date(2022, 10, 29, 17, 31, 0, 0, dublinLoc),
expectedNext: time.Date(2022, 10, 30, 17, 30, 0, 0, dublinLoc),
expectedInterval: 24*time.Hour + 59*time.Minute,
},
{
name: "invalid location",
schedule: "CRON_TZ=Imaginary/Place 30 17 * * 1-5",
expectedError: "status code 500: invalid autostop schedule: parse schedule: provided bad location Imaginary/Place: unknown time zone Imaginary/Place",
},
{
name: "invalid schedule",
schedule: "asdf asdf asdf ",
expectedError: `status code 500: invalid autostop schedule: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix`,
},
{
name: "only 3 values",
schedule: "CRON_TZ=Europe/Dublin 30 9 *",
expectedError: `status code 500: invalid autostop schedule: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix`,
name: "enable ttl",
ttl: ptr(time.Hour),
expectedError: "",
},
}
@ -633,10 +585,10 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
)
// ensure test invariant: new workspaces have no autostop schedule.
require.Empty(t, workspace.AutostopSchedule, "expected newly-minted workspace to have no autstop schedule")
require.Nil(t, workspace.TTL, "expected newly-minted workspace to have no TTL")
err := client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
Schedule: testCase.schedule,
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: testCase.ttl,
})
if testCase.expectedError != "" {
@ -649,18 +601,7 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
updated, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err, "fetch updated workspace")
require.Equal(t, testCase.schedule, updated.AutostopSchedule, "expected autostop schedule to equal requested")
if testCase.schedule == "" {
return
}
sched, err := schedule.Weekly(updated.AutostopSchedule)
require.NoError(t, err, "parse returned schedule")
next := sched.Next(testCase.at)
require.Equal(t, testCase.expectedNext, next, "unexpected next scheduled autostop time")
interval := next.Sub(testCase.at)
require.Equal(t, testCase.expectedInterval, interval, "unexpected interval")
require.Equal(t, testCase.ttl, updated.TTL, "expected autostop ttl to equal requested")
})
}
@ -670,12 +611,12 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
client = coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
wsid = uuid.New()
req = codersdk.UpdateWorkspaceAutostopRequest{
Schedule: "9 30 1-5",
req = codersdk.UpdateWorkspaceTTLRequest{
TTL: ptr(time.Hour),
}
)
err := client.UpdateWorkspaceAutostop(ctx, wsid, req)
err := client.UpdateWorkspaceTTL(ctx, wsid, req)
require.IsType(t, err, &codersdk.Error{}, "expected codersdk.Error")
coderSDKErr, _ := err.(*codersdk.Error) //nolint:errorlint
require.Equal(t, coderSDKErr.StatusCode(), 404, "expected status code 404")
@ -683,15 +624,6 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
})
}
func mustLocation(t *testing.T, location string) *time.Location {
loc, err := time.LoadLocation(location)
if err != nil {
t.Errorf("failed to load location %s: %s", location, err.Error())
}
return loc
}
func TestWorkspaceWatcher(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
@ -715,3 +647,17 @@ func TestWorkspaceWatcher(t *testing.T) {
cancel()
require.EqualValues(t, codersdk.Workspace{}, <-wc)
}
func mustLocation(t *testing.T, location string) *time.Location {
t.Helper()
loc, err := time.LoadLocation(location)
if err != nil {
t.Errorf("failed to load location %s: %s", location, err.Error())
}
return loc
}
func ptr[T any](x T) *T {
return &x
}

View File

@ -27,7 +27,7 @@ type Workspace struct {
Outdated bool `json:"outdated"`
Name string `json:"name"`
AutostartSchedule string `json:"autostart_schedule"`
AutostopSchedule string `json:"autostop_schedule"`
TTL *time.Duration `json:"ttl"`
}
// CreateWorkspaceBuildRequest provides options to update the latest workspace build.
@ -157,18 +157,18 @@ func (c *Client) UpdateWorkspaceAutostart(ctx context.Context, id uuid.UUID, req
return nil
}
// UpdateWorkspaceAutostopRequest is a request to update a workspace's autostop schedule.
type UpdateWorkspaceAutostopRequest struct {
Schedule string `json:"schedule"`
// UpdateWorkspaceTTLRequest is a request to update a workspace's TTL.
type UpdateWorkspaceTTLRequest struct {
TTL *time.Duration `json:"ttl"`
}
// UpdateWorkspaceAutostop sets the autostop schedule for workspace by id.
// If the provided schedule is empty, autostop is disabled for the workspace.
func (c *Client) UpdateWorkspaceAutostop(ctx context.Context, id uuid.UUID, req UpdateWorkspaceAutostopRequest) error {
path := fmt.Sprintf("/api/v2/workspaces/%s/autostop", id.String())
// UpdateWorkspaceTTL sets the ttl for workspace by id.
// If the provided duration is nil, autostop is disabled for the workspace.
func (c *Client) UpdateWorkspaceTTL(ctx context.Context, id uuid.UUID, req UpdateWorkspaceTTLRequest) error {
path := fmt.Sprintf("/api/v2/workspaces/%s/ttl", id.String())
res, err := c.Request(ctx, http.MethodPut, path, req)
if err != nil {
return xerrors.Errorf("update workspace autostop: %w", err)
return xerrors.Errorf("update workspace ttl: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {

View File

@ -197,10 +197,10 @@ export const putWorkspaceAutostart = async (
export const putWorkspaceAutostop = async (
workspaceID: string,
autostop: TypesGen.UpdateWorkspaceAutostopRequest,
ttl: TypesGen.UpdateWorkspaceTTLRequest,
): Promise<void> => {
const payload = JSON.stringify(autostop)
await axios.put(`/api/v2/workspaces/${workspaceID}/autostop`, payload, {
const payload = JSON.stringify(ttl)
await axios.put(`/api/v2/workspaces/${workspaceID}/ttl`, payload, {
headers: { ...CONTENT_TYPE_JSON },
})
}

View File

@ -291,8 +291,9 @@ export interface UpdateWorkspaceAutostartRequest {
}
// From codersdk/workspaces.go:161:6
export interface UpdateWorkspaceAutostopRequest {
readonly schedule: string
export interface UpdateWorkspaceTTLRequest {
// This is likely an enum in an external package ("time.Duration")
readonly ttl?: number
}
// From codersdk/files.go:16:6
@ -358,7 +359,8 @@ export interface Workspace {
readonly outdated: boolean
readonly name: string
readonly autostart_schedule: string
readonly autostop_schedule: string
// This is likely an enum in an external package ("time.Duration")
readonly ttl?: number
}
// From codersdk/workspaceresources.go:31:6

View File

@ -50,7 +50,7 @@ export const Workspace: React.FC<WorkspaceProps> = ({
<WorkspaceSection title="Applications">
<Placeholder />
</WorkspaceSection>
<WorkspaceSchedule autostart={workspace.autostart_schedule} autostop={workspace.autostop_schedule} />
<WorkspaceSchedule workspace={workspace} />
<WorkspaceSection title="Dev URLs">
<Placeholder />
</WorkspaceSection>

View File

@ -1,6 +1,7 @@
import { Story } from "@storybook/react"
import dayjs from "dayjs"
import React from "react"
import { MockWorkspaceAutostartEnabled } from "../../testHelpers/renderHelpers"
import * as Mocks from "../../testHelpers/renderHelpers"
import { WorkspaceSchedule, WorkspaceScheduleProps } from "./WorkspaceSchedule"
export default {
@ -10,8 +11,66 @@ export default {
const Template: Story<WorkspaceScheduleProps> = (args) => <WorkspaceSchedule {...args} />
export const Example = Template.bind({})
Example.args = {
autostart: MockWorkspaceAutostartEnabled.schedule,
autostop: "",
export const NoTTL = Template.bind({})
NoTTL.args = {
workspace: {
...Mocks.MockWorkspace,
ttl: undefined,
},
}
export const ShutdownSoon = Template.bind({})
ShutdownSoon.args = {
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
transition: "start",
updated_at: dayjs().subtract(1, "hour").toString(), // 1 hour ago
},
ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours
},
}
export const ShutdownLong = Template.bind({})
ShutdownLong.args = {
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
transition: "start",
updated_at: dayjs().toString(),
},
ttl: 7 * 24 * 60 * 60 * 1000 * 1_000_000, // 7 days
},
}
export const WorkspaceOffShort = Template.bind({})
WorkspaceOffShort.args = {
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
transition: "stop",
updated_at: dayjs().subtract(2, "days").toString(),
},
ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours
},
}
export const WorkspaceOffLong = Template.bind({})
WorkspaceOffLong.args = {
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
transition: "stop",
updated_at: dayjs().subtract(2, "days").toString(),
},
ttl: 2 * 365 * 24 * 60 * 60 * 1000 * 1_000_000, // 2 years
},
}

View File

@ -1,13 +1,20 @@
import Box from "@material-ui/core/Box"
import Typography from "@material-ui/core/Typography"
import cronstrue from "cronstrue"
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
import relativeTime from "dayjs/plugin/relativeTime"
import React from "react"
import * as TypesGen from "../../api/typesGenerated"
import { extractTimezone, stripTimezone } from "../../util/schedule"
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
dayjs.extend(duration)
dayjs.extend(relativeTime)
const Language = {
autoStartLabel: (schedule: string): string => {
const prefix = "Workspace start"
const prefix = "Start"
if (schedule) {
return `${prefix} (${extractTimezone(schedule)})`
@ -15,26 +22,37 @@ const Language = {
return prefix
}
},
autoStopLabel: (schedule: string): string => {
const prefix = "Workspace shutdown"
if (schedule) {
return `${prefix} (${extractTimezone(schedule)})`
} else {
return prefix
}
},
cronHumanDisplay: (schedule: string): string => {
autoStartDisplay: (schedule: string): string => {
if (schedule) {
return cronstrue.toString(stripTimezone(schedule), { throwExceptionOnParseError: false })
}
return "Manual"
},
autoStopLabel: "Shutdown",
autoStopDisplay: (workspace: TypesGen.Workspace): string => {
const latest = workspace.latest_build
if (!workspace.ttl || workspace.ttl < 1) {
return "Manual"
}
if (latest.transition === "start") {
const now = dayjs()
const updatedAt = dayjs(latest.updated_at)
const deadline = updatedAt.add(workspace.ttl / 1_000_000, "ms")
if (now.isAfter(deadline)) {
return "workspace is shutting down now"
}
return now.to(deadline)
}
const duration = dayjs.duration(workspace.ttl / 1_000_000, "milliseconds")
return `${duration.humanize()} after start`
},
}
export interface WorkspaceScheduleProps {
autostart: string
autostop: string
workspace: TypesGen.Workspace
}
/**
@ -42,17 +60,17 @@ export interface WorkspaceScheduleProps {
*
* @remarks Visual Component
*/
export const WorkspaceSchedule: React.FC<WorkspaceScheduleProps> = ({ autostart, autostop }) => {
export const WorkspaceSchedule: React.FC<WorkspaceScheduleProps> = ({ workspace }) => {
return (
<WorkspaceSection title="Workspace schedule">
<Box mt={2}>
<Typography variant="h6">{Language.autoStartLabel(autostart)}</Typography>
<Typography>{Language.cronHumanDisplay(autostart)}</Typography>
<Typography variant="h6">{Language.autoStartLabel(workspace.autostart_schedule)}</Typography>
<Typography>{Language.autoStartDisplay(workspace.autostart_schedule)}</Typography>
</Box>
<Box mt={2}>
<Typography variant="h6">{Language.autoStopLabel(autostop)}</Typography>
<Typography>{Language.cronHumanDisplay(autostop)}</Typography>
<Typography variant="h6">{Language.autoStopLabel}</Typography>
<Typography data-chromatic="ignore">{Language.autoStopDisplay(workspace)}</Typography>
</Box>
</WorkspaceSection>
)

View File

@ -100,15 +100,6 @@ export const MockWorkspaceAutostartEnabled: TypesGen.UpdateWorkspaceAutostartReq
schedule: "CRON_TZ=Canada/Eastern 30 9 * * 1-5",
}
export const MockWorkspaceAutostopDisabled: TypesGen.UpdateWorkspaceAutostartRequest = {
schedule: "",
}
export const MockWorkspaceAutostopEnabled: TypesGen.UpdateWorkspaceAutostartRequest = {
// Runs at 9:30pm Monday through Friday using America/Toronto
schedule: "CRON_TZ=America/Toronto 30 21 * * 1-5",
}
export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
build_number: 1,
created_at: "2022-05-17T17:39:01.382927298Z",
@ -147,7 +138,7 @@ export const MockWorkspace: TypesGen.Workspace = {
owner_id: MockUser.id,
owner_name: MockUser.username,
autostart_schedule: MockWorkspaceAutostartEnabled.schedule,
autostop_schedule: MockWorkspaceAutostopEnabled.schedule,
ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours as nanoseconds
latest_build: MockWorkspaceBuild,
}

View File

@ -100,7 +100,7 @@ export const handlers = [
rest.put("/api/v2/workspaces/:workspaceId/autostart", async (req, res, ctx) => {
return res(ctx.status(200))
}),
rest.put("/api/v2/workspaces/:workspaceId/autostop", async (req, res, ctx) => {
rest.put("/api/v2/workspaces/:workspaceId/ttl", async (req, res, ctx) => {
return res(ctx.status(200))
}),
rest.post("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => {