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 15:09:27 -04:00
committed by GitHub
co-authored by G r e y
parent 6c1117094d
commit d72c45e483
31 changed files with 549 additions and 490 deletions
-167
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
},
}
}
+3 -5
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]
+1 -1
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(),
+2 -8
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
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
},
}
}
+77 -67
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")
})
}