diff --git a/cli/autostart.go b/cli/autostart.go deleted file mode 100644 index dd413a0498..0000000000 --- a/cli/autostart.go +++ /dev/null @@ -1,244 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - "time" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "github.com/coder/coder/coderd/autobuild/schedule" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/coderd/util/tz" - "github.com/coder/coder/codersdk" -) - -const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart. -When enabling autostart, enter a schedule in the format: [day-of-week] [location]. - * Start-time (required) is accepted either in 12-hour (hh:mm{am|pm}) format, or 24-hour format hh:mm. - * Day-of-week (optional) allows specifying in the cron format, e.g. 1,3,5 or Mon-Fri. - Aliases such as @daily are not supported. - Default: * (every day) - * Location (optional) must be a valid location in the IANA timezone database. - If omitted, we will fall back to either the TZ environment variable or /etc/localtime. - You can check your corresponding location by visiting https://ipinfo.io - it shows in the demo widget on the right. -` - -func autostart() *cobra.Command { - autostartCmd := &cobra.Command{ - Annotations: workspaceCommand, - Use: "autostart set [day-of-week] [location]", - Short: "schedule a workspace to automatically start at a regular time", - Long: autostartDescriptionLong, - Example: "coder autostart set my-workspace 9:30AM Mon-Fri Europe/Dublin", - } - - autostartCmd.AddCommand(autostartShow()) - autostartCmd.AddCommand(autostartSet()) - autostartCmd.AddCommand(autostartUnset()) - - return autostartCmd -} - -func autostartShow() *cobra.Command { - cmd := &cobra.Command{ - Use: "show ", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) - if err != nil { - return err - } - - workspace, err := namedWorkspace(cmd, client, args[0]) - if err != nil { - return err - } - - if workspace.AutostartSchedule == nil || *workspace.AutostartSchedule == "" { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "not enabled\n") - return nil - } - - validSchedule, err := schedule.Weekly(*workspace.AutostartSchedule) - if err != nil { - // This should never happen. - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid autostart schedule %q for workspace %s: %s\n", *workspace.AutostartSchedule, workspace.Name, err.Error()) - return nil - } - - next := validSchedule.Next(time.Now()) - - _, _ = fmt.Fprintf(cmd.OutOrStdout(), - "schedule: %s\ntimezone: %s\nnext: %s\n", - validSchedule.Cron(), - validSchedule.Location(), - next.In(validSchedule.Location()), - ) - - return nil - }, - } - return cmd -} - -func autostartSet() *cobra.Command { - cmd := &cobra.Command{ - Use: "set [day-of-week] [location]", - Args: cobra.RangeArgs(2, 4), - RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) - if err != nil { - return err - } - - sched, err := parseCLISchedule(args[1:]...) - if err != nil { - return err - } - - workspace, err := namedWorkspace(cmd, client, args[0]) - if err != nil { - return err - } - - err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ - Schedule: ptr.Ref(sched.String()), - }) - if err != nil { - return err - } - - schedNext := sched.Next(time.Now()) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), - "%s will automatically start at %s %s (%s)\n", - workspace.Name, - schedNext.In(sched.Location()).Format(time.Kitchen), - sched.DaysOfWeek(), - sched.Location().String(), - ) - return nil - }, - } - - return cmd -} - -func autostartUnset() *cobra.Command { - return &cobra.Command{ - Use: "unset ", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - client, err := createClient(cmd) - if err != nil { - return err - } - - workspace, err := namedWorkspace(cmd, client, args[0]) - if err != nil { - return err - } - - err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ - Schedule: nil, - }) - if err != nil { - return err - } - - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s will no longer automatically start.\n", workspace.Name) - - return nil - }, - } -} - -var errInvalidScheduleFormat = xerrors.New("Schedule must be in the format Mon-Fri 09:00AM America/Chicago") -var errInvalidTimeFormat = xerrors.New("Start time must be in the format hh:mm[am|pm] or HH:MM") -var errUnsupportedTimezone = xerrors.New("The location you provided looks like a timezone. Check https://ipinfo.io for your location.") - -// parseCLISchedule parses a schedule in the format HH:MM{AM|PM} [DOW] [LOCATION] -func parseCLISchedule(parts ...string) (*schedule.Schedule, error) { - // If the user was careful and quoted the schedule, un-quote it. - // In the case that only time was specified, this will be a no-op. - if len(parts) == 1 { - parts = strings.Fields(parts[0]) - } - var loc *time.Location - dayOfWeek := "*" - t, err := parseTime(parts[0]) - if err != nil { - return nil, err - } - hour, minute := t.Hour(), t.Minute() - - // Any additional parts get ignored. - switch len(parts) { - case 3: - dayOfWeek = parts[1] - loc, err = time.LoadLocation(parts[2]) - if err != nil { - _, err = time.Parse("MST", parts[2]) - if err == nil { - return nil, errUnsupportedTimezone - } - return nil, xerrors.Errorf("Invalid timezone %q specified: a valid IANA timezone is required", parts[2]) - } - case 2: - // Did they provide day-of-week or location? - if maybeLoc, err := time.LoadLocation(parts[1]); err != nil { - // Assume day-of-week. - dayOfWeek = parts[1] - } else { - loc = maybeLoc - } - case 1: // already handled - default: - return nil, errInvalidScheduleFormat - } - - // If location was not specified, attempt to automatically determine it as a last resort. - if loc == nil { - loc, err = tz.TimezoneIANA() - if err != nil { - return nil, xerrors.Errorf("Could not automatically determine your timezone") - } - } - - sched, err := schedule.Weekly(fmt.Sprintf( - "CRON_TZ=%s %d %d * * %s", - loc.String(), - minute, - hour, - dayOfWeek, - )) - if err != nil { - // This will either be an invalid dayOfWeek or an invalid timezone. - return nil, xerrors.Errorf("Invalid schedule: %w", err) - } - - return sched, nil -} - -func parseTime(s string) (time.Time, error) { - // Try a number of possible layouts. - for _, layout := range []string{ - time.Kitchen, // 03:04PM - "03:04pm", - "3:04PM", - "3:04pm", - "15:04", - "1504", - "03PM", - "03pm", - "3PM", - "3pm", - } { - t, err := time.Parse(layout, s) - if err == nil { - return t, nil - } - } - return time.Time{}, errInvalidTimeFormat -} diff --git a/cli/autostart_test.go b/cli/autostart_test.go deleted file mode 100644 index 1fb9b95b8b..0000000000 --- a/cli/autostart_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package cli_test - -import ( - "bytes" - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" -) - -func TestAutostart(t *testing.T) { - t.Parallel() - - t.Run("ShowOK", 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) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) - cmdArgs = []string{"autostart", "show", workspace.Name} - sched = "CRON_TZ=Europe/Dublin 30 17 * * 1-5" - stdoutBuf = &bytes.Buffer{} - ) - - err := client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ - Schedule: ptr.Ref(sched), - }) - require.NoError(t, err) - - cmd, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - cmd.SetOut(stdoutBuf) - - err = cmd.Execute() - require.NoError(t, err, "unexpected error") - // CRON_TZ gets stripped - require.Contains(t, stdoutBuf.String(), "schedule: 30 17 * * 1-5") - }) - - t.Run("setunsetOK", 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) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) - tz = "Europe/Dublin" - cmdArgs = []string{"autostart", "set", workspace.Name, "9:30AM", "Mon-Fri", tz} - sched = "CRON_TZ=Europe/Dublin 30 9 * * Mon-Fri" - stdoutBuf = &bytes.Buffer{} - ) - - cmd, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - cmd.SetOut(stdoutBuf) - - err := cmd.Execute() - require.NoError(t, err, "unexpected error") - require.Contains(t, stdoutBuf.String(), "will automatically start at 9:30AM Mon-Fri (Europe/Dublin)", "unexpected output") - - // Ensure autostart schedule updated - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err, "fetch updated workspace") - require.Equal(t, sched, *updated.AutostartSchedule, "expected autostart schedule to be set") - - // unset schedule - cmd, root = clitest.New(t, "autostart", "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 start", "unexpected output") - - // Ensure autostart schedule updated - updated, err = client.Workspace(ctx, workspace.ID) - require.NoError(t, err, "fetch updated workspace") - require.Nil(t, updated.AutostartSchedule, "expected autostart schedule to not be set") - }) - - t.Run("set_NotFound", 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) - ) - - cmd, root := clitest.New(t, "autostart", "set", "doesnotexist", "09:30AM") - clitest.SetupConfig(t, client, root) - - err := cmd.Execute() - require.ErrorContains(t, err, "status code 404", "unexpected error") - }) - - t.Run("unset_NotFound", 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) - ) - - cmd, root := clitest.New(t, "autostart", "unset", "doesnotexist") - clitest.SetupConfig(t, client, root) - - err := cmd.Execute() - require.ErrorContains(t, err, "status code 404:", "unexpected error") - }) -} - -//nolint:paralleltest // t.Setenv -func TestAutostartSetDefaultSchedule(t *testing.T) { - t.Setenv("TZ", "UTC") - 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) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) - stdoutBuf = &bytes.Buffer{} - ) - - expectedSchedule := fmt.Sprintf("CRON_TZ=%s 30 9 * * *", "UTC") - cmd, root := clitest.New(t, "autostart", "set", workspace.Name, "9:30AM") - clitest.SetupConfig(t, client, root) - cmd.SetOutput(stdoutBuf) - - err := cmd.Execute() - require.NoError(t, err, "unexpected error") - - // Ensure nothing happened - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err, "fetch updated workspace") - require.Equal(t, expectedSchedule, *updated.AutostartSchedule, "expected default autostart schedule") - require.Contains(t, stdoutBuf.String(), "will automatically start at 9:30AM daily (UTC)") -} diff --git a/cli/bump.go b/cli/bump.go deleted file mode 100644 index 8a98f361e3..0000000000 --- a/cli/bump.go +++ /dev/null @@ -1,98 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - "time" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "github.com/coder/coder/coderd/util/tz" - "github.com/coder/coder/codersdk" -) - -const ( - bumpDescriptionShort = `Shut your workspace down after a given duration has passed.` - bumpDescriptionLong = `Modify the time at which your workspace will shut down automatically. -* Provide a duration from now (for example, 1h30m). -* The minimum duration is 30 minutes. -* If the workspace template restricts the maximum runtime of a workspace, this will be enforced here. -* If the workspace does not already have a shutdown scheduled, this does nothing. -` -) - -func bump() *cobra.Command { - bumpCmd := &cobra.Command{ - Args: cobra.RangeArgs(1, 2), - Annotations: workspaceCommand, - Use: "bump ", - Short: bumpDescriptionShort, - Long: bumpDescriptionLong, - Example: "coder bump my-workspace 90m", - RunE: func(cmd *cobra.Command, args []string) error { - bumpDuration, err := tryParseDuration(args[1]) - if err != nil { - return err - } - - client, err := createClient(cmd) - if err != nil { - return xerrors.Errorf("create client: %w", err) - } - - workspace, err := namedWorkspace(cmd, client, args[0]) - if err != nil { - return xerrors.Errorf("get workspace: %w", err) - } - - loc, err := tz.TimezoneIANA() - if err != nil { - loc = time.UTC // best effort - } - - if bumpDuration < 29*time.Minute { - _, _ = fmt.Fprintf( - cmd.OutOrStdout(), - "Please specify a duration of at least 30 minutes.\n", - ) - return nil - } - - newDeadline := time.Now().In(loc).Add(bumpDuration) - if err := client.PutExtendWorkspace(cmd.Context(), workspace.ID, codersdk.PutExtendWorkspaceRequest{ - Deadline: newDeadline, - }); err != nil { - return err - } - - _, _ = fmt.Fprintf( - cmd.OutOrStdout(), - "Workspace %q will now stop at %s on %s\n", workspace.Name, - newDeadline.Format(timeFormat), - newDeadline.Format(dateFormat), - ) - return nil - }, - } - - return bumpCmd -} - -func tryParseDuration(raw string) (time.Duration, error) { - // If the user input a raw number, assume minutes - if isDigit(raw) { - raw = raw + "m" - } - d, err := time.ParseDuration(raw) - if err != nil { - return 0, err - } - return d, nil -} - -func isDigit(s string) bool { - return strings.IndexFunc(s, func(c rune) bool { - return c < '0' || c > '9' - }) == -1 -} diff --git a/cli/bump_test.go b/cli/bump_test.go deleted file mode 100644 index da8fc70151..0000000000 --- a/cli/bump_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package cli_test - -import ( - "bytes" - "context" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/codersdk" -) - -func TestBump(t *testing.T) { - t.Parallel() - - t.Run("BumpSpecificDuration", func(t *testing.T) { - t.Parallel() - - // Given: we have a workspace - var ( - err error - 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) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) - cmdArgs = []string{"bump", workspace.Name, "10h"} - stdoutBuf = &bytes.Buffer{} - ) - - // Given: we wait for the workspace to be built - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - workspace, err = client.Workspace(ctx, workspace.ID) - require.NoError(t, err) - expectedDeadline := time.Now().Add(10 * time.Hour) - - // Assert test invariant: workspace build has a deadline set equal to now plus ttl - initDeadline := time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond) - require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline, time.Minute) - - cmd, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - cmd.SetOut(stdoutBuf) - - // When: we execute `coder bump workspace ` - err = cmd.ExecuteContext(ctx) - require.NoError(t, err) - - // Then: the deadline of the latest build is updated assuming the units are minutes - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err) - require.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline, time.Minute) - }) - - t.Run("BumpInvalidDuration", func(t *testing.T) { - t.Parallel() - - // Given: we have a workspace - var ( - err error - 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) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) - cmdArgs = []string{"bump", workspace.Name, "kwyjibo"} - stdoutBuf = &bytes.Buffer{} - ) - - // Given: we wait for the workspace to be built - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - workspace, err = client.Workspace(ctx, workspace.ID) - require.NoError(t, err) - - // Assert test invariant: workspace build has a deadline set equal to now plus ttl - initDeadline := time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond) - require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline, time.Minute) - - cmd, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - cmd.SetOut(stdoutBuf) - - // When: we execute `coder bump workspace ` - err = cmd.ExecuteContext(ctx) - // Then: the command fails - require.ErrorContains(t, err, "invalid duration") - }) - - t.Run("BumpNoDeadline", func(t *testing.T) { - t.Parallel() - - // Given: we have a workspace with no deadline set - var ( - err error - 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) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.TTLMillis = nil - }) - cmdArgs = []string{"bump", workspace.Name, "1h"} - 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) - - // NOTE(cian): need to stop and start the workspace as we do not update the deadline - // see: https://github.com/coder/coder/issues/2224 - 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) - - cmd, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - cmd.SetOut(stdoutBuf) - - // When: we execute `coder bump workspace`` - err = cmd.ExecuteContext(ctx) - require.Error(t, err) - - // Then: nothing happens and the deadline remains unset - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err) - require.Zero(t, updated.LatestBuild.Deadline) - }) -} diff --git a/cli/constants.go b/cli/constants.go index af9dd5260e..64d28c7d2a 100644 --- a/cli/constants.go +++ b/cli/constants.go @@ -1,6 +1,6 @@ package cli const ( - timeFormat = "3:04:05 PM MST" + timeFormat = "3:04PM MST" dateFormat = "Jan 2, 2006" ) diff --git a/cli/list.go b/cli/list.go index c9400c984a..2a07508b01 100644 --- a/cli/list.go +++ b/cli/list.go @@ -2,7 +2,6 @@ package cli import ( "fmt" - "strings" "time" "github.com/google/uuid" @@ -50,13 +49,14 @@ func list() *cobra.Command { } tableWriter := cliui.Table() - header := table.Row{"workspace", "template", "status", "last built", "outdated", "autostart", "ttl"} + header := table.Row{"workspace", "template", "status", "last built", "outdated", "starts at", "stops after"} tableWriter.AppendHeader(header) tableWriter.SortBy([]table.SortBy{{ Name: "workspace", }}) tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, columns)) + now := time.Now() for _, workspace := range workspaces { status := "" inProgress := false @@ -86,11 +86,11 @@ func list() *cobra.Command { status = "Failed" } - duration := time.Now().UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second) + lastBuilt := time.Now().UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second) autostartDisplay := "-" if !ptr.NilOrEmpty(workspace.AutostartSchedule) { if sched, err := schedule.Weekly(*workspace.AutostartSchedule); err == nil { - autostartDisplay = sched.Cron() + autostartDisplay = fmt.Sprintf("%s %s (%s)", sched.Time(), sched.DaysOfWeek(), sched.Location()) } } @@ -98,8 +98,9 @@ func list() *cobra.Command { if !ptr.NilOrZero(workspace.TTLMillis) { dur := time.Duration(*workspace.TTLMillis) * time.Millisecond autostopDisplay = durationDisplay(dur) - if has, ext := hasExtension(workspace); has { - autostopDisplay += fmt.Sprintf(" (+%s)", durationDisplay(ext.Round(time.Minute))) + if !workspace.LatestBuild.Deadline.IsZero() && workspace.LatestBuild.Deadline.After(now) && status == "Running" { + remaining := time.Until(workspace.LatestBuild.Deadline) + autostopDisplay = fmt.Sprintf("%s (%s)", autostopDisplay, relative(remaining)) } } @@ -108,7 +109,7 @@ func list() *cobra.Command { user.Username + "/" + workspace.Name, workspace.TemplateName, status, - durationDisplay(duration), + durationDisplay(lastBuilt), workspace.Outdated, autostartDisplay, autostopDisplay, @@ -122,51 +123,3 @@ func list() *cobra.Command { "Specify a column to filter in the table.") return cmd } - -func hasExtension(ws codersdk.Workspace) (bool, time.Duration) { - if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart { - return false, 0 - } - if ws.LatestBuild.Job.CompletedAt == nil { - return false, 0 - } - if ws.LatestBuild.Deadline.IsZero() { - return false, 0 - } - if ws.TTLMillis == nil { - return false, 0 - } - ttl := time.Duration(*ws.TTLMillis) * time.Millisecond - delta := ws.LatestBuild.Deadline.Add(-ttl).Sub(*ws.LatestBuild.Job.CompletedAt) - if delta < time.Minute { - return false, 0 - } - - return true, delta -} - -func durationDisplay(d time.Duration) string { - duration := d - if duration > time.Hour { - duration = duration.Truncate(time.Hour) - } - if duration > time.Minute { - duration = duration.Truncate(time.Minute) - } - days := 0 - for duration.Hours() > 24 { - days++ - duration -= 24 * time.Hour - } - durationDisplay := duration.String() - if days > 0 { - durationDisplay = fmt.Sprintf("%dd%s", days, durationDisplay) - } - if strings.HasSuffix(durationDisplay, "m0s") { - durationDisplay = durationDisplay[:len(durationDisplay)-2] - } - if strings.HasSuffix(durationDisplay, "h0m") { - durationDisplay = durationDisplay[:len(durationDisplay)-2] - } - return durationDisplay -} diff --git a/cli/root.go b/cli/root.go index adb3a792a3..2f7ef384a6 100644 --- a/cli/root.go +++ b/cli/root.go @@ -65,8 +65,6 @@ func Root() *cobra.Command { } cmd.AddCommand( - autostart(), - bump(), configSSH(), create(), delete(), @@ -77,6 +75,7 @@ func Root() *cobra.Command { logout(), publickey(), resetPassword(), + schedules(), server(), show(), start(), @@ -84,7 +83,6 @@ func Root() *cobra.Command { stop(), ssh(), templates(), - ttl(), update(), users(), portForward(), diff --git a/cli/schedule.go b/cli/schedule.go new file mode 100644 index 0000000000..4c8a3a6be6 --- /dev/null +++ b/cli/schedule.go @@ -0,0 +1,292 @@ +package cli + +import ( + "fmt" + "io" + "time" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/autobuild/schedule" + "github.com/coder/coder/coderd/util/ptr" + "github.com/coder/coder/coderd/util/tz" + "github.com/coder/coder/codersdk" +) + +const ( + scheduleDescriptionLong = `Modify scheduled stop and start times for your workspace: + * schedule show: show workspace schedule + * schedule start: edit workspace start schedule + * schedule stop: edit workspace stop schedule + * schedule override-stop: edit stop time of active workspace +` + scheduleShowDescriptionLong = `Shows the following information for the given workspace: + * The automatic start schedule + * The next scheduled start time + * The duration after which it will stop + * The next scheduled stop time +` + scheduleStartDescriptionLong = `Schedules a workspace to regularly start at a specific time. +Schedule format: [day-of-week] [location]. + * Start-time (required) is accepted either in 12-hour (hh:mm{am|pm}) format, or 24-hour format hh:mm. + * Day-of-week (optional) allows specifying in the cron format, e.g. 1,3,5 or Mon-Fri. + Aliases such as @daily are not supported. + Default: * (every day) + * Location (optional) must be a valid location in the IANA timezone database. + If omitted, we will fall back to either the TZ environment variable or /etc/localtime. + You can check your corresponding location by visiting https://ipinfo.io - it shows in the demo widget on the right. +` + scheduleStopDescriptionLong = `Schedules a workspace to stop after a given duration has elapsed. + * Workspace runtime is measured from the time that the workspace build completed. + * The minimum scheduled stop time is 1 minute. + * The workspace template may place restrictions on the maximum shutdown time. + * Changes to workspace schedules only take effect upon the next build of the workspace, + and do not affect a running instance of a workspace. + +When enabling scheduled stop, enter a duration in one of the following formats: + * 3h2m (3 hours and two minutes) + * 3h (3 hours) + * 2m (2 minutes) + * 2 (2 minutes) +` + scheduleOverrideDescriptionLong = `Override the stop time of a currently running workspace instance. + * The new stop time is calculated from *now*. + * The new stop time must be at least 30 minutes in the future. + * The workspace template may restrict the maximum workspace runtime. +` +) + +func schedules() *cobra.Command { + scheduleCmd := &cobra.Command{ + Annotations: workspaceCommand, + Use: "schedule { show | start | stop | override } ", + Short: "Modify scheduled stop and start times for your workspace", + Long: scheduleDescriptionLong, + } + + scheduleCmd.AddCommand(scheduleShow()) + scheduleCmd.AddCommand(scheduleStart()) + scheduleCmd.AddCommand(scheduleStop()) + scheduleCmd.AddCommand(scheduleOverride()) + + return scheduleCmd +} + +func scheduleShow() *cobra.Command { + showCmd := &cobra.Command{ + Annotations: workspaceCommand, + Use: "show ", + Short: "Show workspace schedule", + Long: scheduleShowDescriptionLong, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return err + } + + workspace, err := namedWorkspace(cmd, client, args[0]) + if err != nil { + return err + } + + return displaySchedule(workspace, cmd.OutOrStdout()) + }, + } + return showCmd +} + +func scheduleStart() *cobra.Command { + cmd := &cobra.Command{ + Annotations: workspaceCommand, + Use: "start { [day-of-week] [location] | manual }", + Example: `start my-workspace 9:30AM Mon-Fri Europe/Dublin`, + Short: "Edit workspace start schedule", + Long: scheduleStartDescriptionLong, + Args: cobra.RangeArgs(2, 4), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return err + } + + workspace, err := namedWorkspace(cmd, client, args[0]) + if err != nil { + return err + } + + var schedStr *string + if args[1] != "manual" { + sched, err := parseCLISchedule(args[1:]...) + if err != nil { + return err + } + + schedStr = ptr.Ref(sched.String()) + } + + err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ + Schedule: schedStr, + }) + if err != nil { + return err + } + + updated, err := namedWorkspace(cmd, client, args[0]) + if err != nil { + return err + } + return displaySchedule(updated, cmd.OutOrStdout()) + }, + } + + return cmd +} + +func scheduleStop() *cobra.Command { + return &cobra.Command{ + Annotations: workspaceCommand, + Args: cobra.ExactArgs(2), + Use: "stop { | manual }", + Example: `stop my-workspace 2h30m`, + Short: "Edit workspace stop schedule", + Long: scheduleStopDescriptionLong, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return err + } + + workspace, err := namedWorkspace(cmd, client, args[0]) + if err != nil { + return err + } + + var durMillis *int64 + if args[1] != "manual" { + dur, err := parseDuration(args[1]) + if err != nil { + return err + } + durMillis = ptr.Ref(dur.Milliseconds()) + } + + if err := client.UpdateWorkspaceTTL(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ + TTLMillis: durMillis, + }); err != nil { + return err + } + + updated, err := namedWorkspace(cmd, client, args[0]) + if err != nil { + return err + } + return displaySchedule(updated, cmd.OutOrStdout()) + }, + } +} + +func scheduleOverride() *cobra.Command { + overrideCmd := &cobra.Command{ + Args: cobra.ExactArgs(2), + Annotations: workspaceCommand, + Use: "override-stop ", + Example: "override-stop my-workspace 90m", + Short: "Edit stop time of active workspace", + Long: scheduleOverrideDescriptionLong, + RunE: func(cmd *cobra.Command, args []string) error { + overrideDuration, err := parseDuration(args[1]) + if err != nil { + return err + } + + client, err := createClient(cmd) + if err != nil { + return xerrors.Errorf("create client: %w", err) + } + + workspace, err := namedWorkspace(cmd, client, args[0]) + if err != nil { + return xerrors.Errorf("get workspace: %w", err) + } + + loc, err := tz.TimezoneIANA() + if err != nil { + loc = time.UTC // best effort + } + + if overrideDuration < 29*time.Minute { + _, _ = fmt.Fprintf( + cmd.OutOrStdout(), + "Please specify a duration of at least 30 minutes.\n", + ) + return nil + } + + newDeadline := time.Now().In(loc).Add(overrideDuration) + if err := client.PutExtendWorkspace(cmd.Context(), workspace.ID, codersdk.PutExtendWorkspaceRequest{ + Deadline: newDeadline, + }); err != nil { + return err + } + + updated, err := namedWorkspace(cmd, client, args[0]) + if err != nil { + return err + } + return displaySchedule(updated, cmd.OutOrStdout()) + }, + } + return overrideCmd +} + +func displaySchedule(workspace codersdk.Workspace, out io.Writer) error { + loc, err := tz.TimezoneIANA() + if err != nil { + loc = time.UTC // best effort + } + + var ( + schedStart = "manual" + schedStop = "manual" + schedNextStart = "-" + schedNextStop = "-" + ) + if !ptr.NilOrEmpty(workspace.AutostartSchedule) { + sched, err := schedule.Weekly(ptr.NilToEmpty(workspace.AutostartSchedule)) + if err != nil { + // This should never happen. + _, _ = fmt.Fprintf(out, "Invalid autostart schedule %q for workspace %s: %s\n", *workspace.AutostartSchedule, workspace.Name, err.Error()) + return nil + } + schedNext := sched.Next(time.Now()).In(sched.Location()) + schedStart = fmt.Sprintf("%s %s (%s)", sched.Time(), sched.DaysOfWeek(), sched.Location()) + schedNextStart = schedNext.Format(timeFormat + " on " + dateFormat) + } + + if !ptr.NilOrZero(workspace.TTLMillis) { + d := time.Duration(*workspace.TTLMillis) * time.Millisecond + schedStop = durationDisplay(d) + " after start" + } + + if !workspace.LatestBuild.Deadline.IsZero() { + if workspace.LatestBuild.Transition != "start" { + schedNextStop = "-" + } else { + schedNextStop = workspace.LatestBuild.Deadline.In(loc).Format(timeFormat + " on " + dateFormat) + schedNextStop = fmt.Sprintf("%s (in %s)", schedNextStop, durationDisplay(time.Until(workspace.LatestBuild.Deadline))) + } + } + + tw := cliui.Table() + tw.AppendRow(table.Row{"Starts at", schedStart}) + tw.AppendRow(table.Row{"Starts next", schedNextStart}) + tw.AppendRow(table.Row{"Stops at", schedStop}) + tw.AppendRow(table.Row{"Stops next", schedNextStop}) + + _, _ = fmt.Fprintln(out, tw.Render()) + return nil +} diff --git a/cli/autostart_internal_test.go b/cli/schedule_internal_test.go similarity index 100% rename from cli/autostart_internal_test.go rename to cli/schedule_internal_test.go diff --git a/cli/schedule_test.go b/cli/schedule_test.go new file mode 100644 index 0000000000..199d474ce0 --- /dev/null +++ b/cli/schedule_test.go @@ -0,0 +1,376 @@ +package cli_test + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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" +) + +func TestScheduleShow(t *testing.T) { + t.Parallel() + t.Run("Enabled", func(t *testing.T) { + t.Parallel() + + var ( + tz = "Europe/Dublin" + sched = "30 7 * * 1-5" + schedCron = fmt.Sprintf("CRON_TZ=%s %s", tz, sched) + ttl = 8 * time.Hour + 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) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(schedCron) + cwr.TTLMillis = ptr.Ref(ttl.Milliseconds()) + }) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + cmdArgs = []string{"schedule", "show", workspace.Name} + stdoutBuf = &bytes.Buffer{} + ) + + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + err := cmd.Execute() + require.NoError(t, err, "unexpected error") + lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") + if assert.Len(t, lines, 4) { + assert.Contains(t, lines[0], "Starts at 7:30AM Mon-Fri (Europe/Dublin)") + assert.Contains(t, lines[1], "Starts next 7:30AM IST on ") + assert.Contains(t, lines[2], "Stops at 8h after start") + assert.NotContains(t, lines[3], "Stops next -") + } + }) + + t.Run("Manual", 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) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = nil + }) + cmdArgs = []string{"schedule", "show", workspace.Name} + stdoutBuf = &bytes.Buffer{} + ) + + // unset workspace TTL + require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil})) + + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + err := cmd.Execute() + require.NoError(t, err, "unexpected error") + lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") + if assert.Len(t, lines, 4) { + assert.Contains(t, lines[0], "Starts at manual") + assert.Contains(t, lines[1], "Starts next -") + assert.Contains(t, lines[2], "Stops at manual") + assert.Contains(t, lines[3], "Stops next -") + } + }) + + t.Run("NotFound", 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) + ) + + cmd, root := clitest.New(t, "schedule", "show", "doesnotexist") + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.ErrorContains(t, err, "status code 404", "unexpected error") + }) +} + +func TestScheduleStart(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) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = nil + }) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + tz = "Europe/Dublin" + sched = "CRON_TZ=Europe/Dublin 30 9 * * Mon-Fri" + stdoutBuf = &bytes.Buffer{} + ) + + // Set a well-specified autostart schedule + cmd, root := clitest.New(t, "schedule", "start", workspace.Name, "9:30AM", "Mon-Fri", tz) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + err := cmd.Execute() + assert.NoError(t, err, "unexpected error") + lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") + if assert.Len(t, lines, 4) { + assert.Contains(t, lines[0], "Starts at 9:30AM Mon-Fri (Europe/Dublin)") + assert.Contains(t, lines[1], "Starts next 9:30AM IST on") + } + + // Ensure autostart schedule updated + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + require.Equal(t, sched, *updated.AutostartSchedule, "expected autostart schedule to be set") + + // Reset stdout + stdoutBuf = &bytes.Buffer{} + + // unset schedule + cmd, root = clitest.New(t, "schedule", "start", workspace.Name, "manual") + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + err = cmd.Execute() + assert.NoError(t, err, "unexpected error") + lines = strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") + if assert.Len(t, lines, 4) { + assert.Contains(t, lines[0], "Starts at manual") + assert.Contains(t, lines[1], "Starts next -") + } +} + +func TestScheduleStop(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) + ttl = 8*time.Hour + 30*time.Minute + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + stdoutBuf = &bytes.Buffer{} + ) + + // Set the workspace TTL + cmd, root := clitest.New(t, "schedule", "stop", workspace.Name, ttl.String()) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + err := cmd.Execute() + assert.NoError(t, err, "unexpected error") + lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") + if assert.Len(t, lines, 4) { + assert.Contains(t, lines[2], "Stops at 8h30m after start") + // Should not be manual + assert.NotContains(t, lines[3], "Stops next -") + } + + // Reset stdout + stdoutBuf = &bytes.Buffer{} + + // Unset the workspace TTL + cmd, root = clitest.New(t, "schedule", "stop", workspace.Name, "manual") + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + err = cmd.Execute() + assert.NoError(t, err, "unexpected error") + lines = strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") + if assert.Len(t, lines, 4) { + assert.Contains(t, lines[2], "Stops at manual") + // Deadline of a running workspace is not updated. + assert.NotContains(t, lines[3], "Stops next -") + } +} + +func TestScheduleOverride(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + // Given: we have a workspace + var ( + err error + 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) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + cmdArgs = []string{"schedule", "override-stop", workspace.Name, "10h"} + stdoutBuf = &bytes.Buffer{} + ) + + // Given: we wait for the workspace to be built + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + expectedDeadline := time.Now().Add(10 * time.Hour) + + // Assert test invariant: workspace build has a deadline set equal to now plus ttl + initDeadline := time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond) + require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline, time.Minute) + + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + // When: we execute `coder schedule override workspace ` + err = cmd.ExecuteContext(ctx) + require.NoError(t, err) + + // Then: the deadline of the latest build is updated assuming the units are minutes + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline, time.Minute) + }) + + t.Run("InvalidDuration", func(t *testing.T) { + t.Parallel() + + // Given: we have a workspace + var ( + err error + 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) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + cmdArgs = []string{"schedule", "override-stop", workspace.Name, "kwyjibo"} + stdoutBuf = &bytes.Buffer{} + ) + + // Given: we wait for the workspace to be built + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + + // Assert test invariant: workspace build has a deadline set equal to now plus ttl + initDeadline := time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond) + require.WithinDuration(t, initDeadline, workspace.LatestBuild.Deadline, time.Minute) + + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + // When: we execute `coder bump workspace ` + err = cmd.ExecuteContext(ctx) + // Then: the command fails + require.ErrorContains(t, err, "invalid duration") + }) + + t.Run("NoDeadline", func(t *testing.T) { + t.Parallel() + + // Given: we have a workspace with no deadline set + var ( + err error + 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) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TTLMillis = nil + }) + cmdArgs = []string{"schedule", "override-stop", workspace.Name, "1h"} + 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) + + // NOTE(cian): need to stop and start the workspace as we do not update the deadline + // see: https://github.com/coder/coder/issues/2224 + 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) + + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + // When: we execute `coder bump workspace`` + err = cmd.ExecuteContext(ctx) + require.Error(t, err) + + // Then: nothing happens and the deadline remains unset + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Zero(t, updated.LatestBuild.Deadline) + }) +} + +//nolint:paralleltest // t.Setenv +func TestScheduleStartDefaults(t *testing.T) { + t.Setenv("TZ", "Pacific/Tongatapu") + 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) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = nil + }) + stdoutBuf = &bytes.Buffer{} + ) + + // Set an underspecified schedule + cmd, root := clitest.New(t, "schedule", "start", workspace.Name, "9:30AM") + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + err := cmd.Execute() + require.NoError(t, err, "unexpected error") + lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n") + if assert.Len(t, lines, 4) { + assert.Contains(t, lines[0], "Starts at 9:30AM daily (Pacific/Tongatapu)") + assert.Contains(t, lines[1], "Starts next 9:30AM +13 on") + assert.Contains(t, lines[2], "Stops at 8h after start") + } +} diff --git a/cli/ttl.go b/cli/ttl.go deleted file mode 100644 index 1b293c4304..0000000000 --- a/cli/ttl.go +++ /dev/null @@ -1,154 +0,0 @@ -package cli - -import ( - "fmt" - "time" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "github.com/coder/coder/coderd/autobuild/schedule" - "github.com/coder/coder/coderd/util/ptr" - "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 ", - 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) - } - - workspace, err := namedWorkspace(cmd, client, args[0]) - if err != nil { - return xerrors.Errorf("get workspace: %w", err) - } - - if workspace.TTLMillis == nil || *workspace.TTLMillis == 0 { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "not set\n") - return nil - } - - dur := time.Duration(*workspace.TTLMillis) * time.Millisecond - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", dur) - - return nil - }, - } - return cmd -} - -func ttlset() *cobra.Command { - cmd := &cobra.Command{ - Use: "set ", - 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) - } - - workspace, err := namedWorkspace(cmd, client, 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) - } - - millis := truncated.Milliseconds() - if err = client.UpdateWorkspaceTTL(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ - TTLMillis: &millis, - }); err != nil { - return xerrors.Errorf("update workspace ttl: %w", err) - } - - if ptr.NilOrEmpty(workspace.AutostartSchedule) { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%q will shut down %s after start.\n", workspace.Name, truncated) - return nil - } - - sched, err := schedule.Weekly(*workspace.AutostartSchedule) - if err != nil { - return xerrors.Errorf("parse workspace schedule: %w", err) - } - - nextShutdown := sched.Next(time.Now()).Add(truncated).In(sched.Location()) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%q will shut down at %s on %s (%s after start).\n", - workspace.Name, - nextShutdown.Format(timeFormat), - nextShutdown.Format(dateFormat), - truncated, - ) - - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "NOTE: this will only take effect the next time the workspace is started.\n") - return nil - }, - } - - return cmd -} - -func ttlunset() *cobra.Command { - return &cobra.Command{ - Use: "unset ", - 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) - } - - workspace, err := namedWorkspace(cmd, client, args[0]) - if err != nil { - return xerrors.Errorf("get workspace: %w", err) - } - - err = client.UpdateWorkspaceTTL(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ - TTLMillis: nil, - }) - if err != nil { - return xerrors.Errorf("update workspace ttl: %w", err) - } - - _, _ = fmt.Fprint(cmd.OutOrStdout(), "ttl unset\n", workspace.Name) - - return nil - }, - } -} diff --git a/cli/ttl_test.go b/cli/ttl_test.go deleted file mode 100644 index 8642272071..0000000000 --- a/cli/ttl_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package cli_test - -import ( - "bytes" - "context" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/coder/coder/cli/clitest" - "github.com/coder/coder/coderd/coderdtest" - "github.com/coder/coder/coderd/util/ptr" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/pty/ptytest" -) - -func TestTTL(t *testing.T) { - t.Parallel() - - t.Run("ShowOK", 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) - template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - ttl = 7*time.Hour + 30*time.Minute + 30*time.Second - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.TTLMillis = ptr.Ref(ttl.Milliseconds()) - }) - cmdArgs = []string{"ttl", "show", workspace.Name} - stdoutBuf = &bytes.Buffer{} - ) - - cmd, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - cmd.SetOut(stdoutBuf) - - err := cmd.Execute() - require.NoError(t, err, "unexpected error") - require.Equal(t, ttl.Truncate(time.Minute).String(), strings.TrimSpace(stdoutBuf.String())) - }) - - t.Run("UnsetOK", 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) - ttl = 8*time.Hour + 30*time.Minute + 30*time.Second - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.TTLMillis = ptr.Ref(ttl.Milliseconds()) - }) - cmdArgs = []string{"ttl", "unset", workspace.Name} - stdoutBuf = &bytes.Buffer{} - ) - - cmd, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - cmd.SetOut(stdoutBuf) - - err := cmd.Execute() - require.NoError(t, err, "unexpected error") - - // Ensure ttl unset - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err, "fetch updated workspace") - require.Nil(t, updated.TTLMillis, "expected ttl to not be set") - }) - - t.Run("SetOK", 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) - ttl = 8*time.Hour + 30*time.Minute + 30*time.Second - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.TTLMillis = ptr.Ref(ttl.Milliseconds()) - }) - _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - cmdArgs = []string{"ttl", "set", workspace.Name, ttl.String()} - done = make(chan struct{}) - ) - - cmd, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - pty := ptytest.New(t) - cmd.SetIn(pty.Input()) - cmd.SetOut(pty.Output()) - - go func() { - defer close(done) - err := cmd.Execute() - assert.NoError(t, err, "unexpected error") - }() - - // Ensure ttl updated - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err, "fetch updated workspace") - require.Equal(t, ttl.Truncate(time.Minute), time.Duration(*updated.TTLMillis)*time.Millisecond) - - <-done - }) - - t.Run("ZeroInvalid", 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) - 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{} - ) - - cmd, root := clitest.New(t, cmdArgs...) - clitest.SetupConfig(t, client, root) - cmd.SetOut(stdoutBuf) - - err := cmd.Execute() - require.NoError(t, err, "unexpected error") - - // Ensure ttl updated - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err, "fetch updated workspace") - require.Equal(t, ttl.Truncate(time.Minute), time.Duration(*updated.TTLMillis)*time.Millisecond) - 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), time.Duration(*updated.TTLMillis)*time.Millisecond) - }) - - t.Run("Set_NotFound", 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) - ) - - cmd, root := clitest.New(t, "ttl", "set", "doesnotexist", "8h30m") - clitest.SetupConfig(t, client, root) - - err := cmd.Execute() - require.ErrorContains(t, err, "status code 404:", "unexpected error") - }) - - t.Run("Unset_NotFound", 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) - ) - - cmd, root := clitest.New(t, "ttl", "unset", "doesnotexist") - clitest.SetupConfig(t, client, root) - - err := cmd.Execute() - require.ErrorContains(t, err, "status code 404:", "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) - }) -} diff --git a/cli/util.go b/cli/util.go new file mode 100644 index 0000000000..aac8888b97 --- /dev/null +++ b/cli/util.go @@ -0,0 +1,177 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/autobuild/schedule" + "github.com/coder/coder/coderd/util/tz" +) + +var errInvalidScheduleFormat = xerrors.New("Schedule must be in the format Mon-Fri 09:00AM America/Chicago") +var errInvalidTimeFormat = xerrors.New("Start time must be in the format hh:mm[am|pm] or HH:MM") +var errUnsupportedTimezone = xerrors.New("The location you provided looks like a timezone. Check https://ipinfo.io for your location.") + +// durationDisplay formats a duration for easier display: +// * Durations of 24 hours or greater are displays as Xd +// * Durations less than 1 minute are displayed as <1m +// * Duration is truncated to the nearest minute +// * Empty minutes and seconds are truncated +// * The returned string is the absolute value. Use sign() +// if you need to indicate if the duration is positive or +// negative. +func durationDisplay(d time.Duration) string { + duration := d + sign := "" + if duration == 0 { + return "0s" + } + if duration < 0 { + duration *= -1 + } + // duration > 0 now + if duration < time.Minute { + return sign + "<1m" + } + if duration > 24*time.Hour { + duration = duration.Truncate(time.Hour) + } + if duration > time.Minute { + duration = duration.Truncate(time.Minute) + } + days := 0 + for duration.Hours() >= 24 { + days++ + duration -= 24 * time.Hour + } + durationDisplay := duration.String() + if days > 0 { + durationDisplay = fmt.Sprintf("%dd%s", days, durationDisplay) + } + for _, suffix := range []string{"m0s", "h0m", "d0s", "d0h"} { + if strings.HasSuffix(durationDisplay, suffix) { + durationDisplay = durationDisplay[:len(durationDisplay)-2] + } + } + return sign + durationDisplay +} + +// relative relativizes a duration with the prefix "ago" or "in" +func relative(d time.Duration) string { + if d > 0 { + return "in " + durationDisplay(d) + } + if d < 0 { + return durationDisplay(d) + " ago" + } + return "now" +} + +// parseCLISchedule parses a schedule in the format HH:MM{AM|PM} [DOW] [LOCATION] +func parseCLISchedule(parts ...string) (*schedule.Schedule, error) { + // If the user was careful and quoted the schedule, un-quote it. + // In the case that only time was specified, this will be a no-op. + if len(parts) == 1 { + parts = strings.Fields(parts[0]) + } + var loc *time.Location + dayOfWeek := "*" + t, err := parseTime(parts[0]) + if err != nil { + return nil, err + } + hour, minute := t.Hour(), t.Minute() + + // Any additional parts get ignored. + switch len(parts) { + case 3: + dayOfWeek = parts[1] + loc, err = time.LoadLocation(parts[2]) + if err != nil { + _, err = time.Parse("MST", parts[2]) + if err == nil { + return nil, errUnsupportedTimezone + } + return nil, xerrors.Errorf("Invalid timezone %q specified: a valid IANA timezone is required", parts[2]) + } + case 2: + // Did they provide day-of-week or location? + if maybeLoc, err := time.LoadLocation(parts[1]); err != nil { + // Assume day-of-week. + dayOfWeek = parts[1] + } else { + loc = maybeLoc + } + case 1: // already handled + default: + return nil, errInvalidScheduleFormat + } + + // If location was not specified, attempt to automatically determine it as a last resort. + if loc == nil { + loc, err = tz.TimezoneIANA() + if err != nil { + return nil, xerrors.Errorf("Could not automatically determine your timezone") + } + } + + sched, err := schedule.Weekly(fmt.Sprintf( + "CRON_TZ=%s %d %d * * %s", + loc.String(), + minute, + hour, + dayOfWeek, + )) + if err != nil { + // This will either be an invalid dayOfWeek or an invalid timezone. + return nil, xerrors.Errorf("Invalid schedule: %w", err) + } + + return sched, nil +} + +// parseDuration parses a duration from a string. +// If units are omitted, minutes are assumed. +func parseDuration(raw string) (time.Duration, error) { + // If the user input a raw number, assume minutes + if isDigit(raw) { + raw = raw + "m" + } + d, err := time.ParseDuration(raw) + if err != nil { + return 0, err + } + return d, nil +} + +func isDigit(s string) bool { + return strings.IndexFunc(s, func(c rune) bool { + return c < '0' || c > '9' + }) == -1 +} + +// parseTime attempts to parse a time (no date) from the given string using a number of layouts. +func parseTime(s string) (time.Time, error) { + // Try a number of possible layouts. + for _, layout := range []string{ + time.Kitchen, // 03:04PM + "03:04pm", + "3:04PM", + "3:04pm", + "15:04", + "1504", + "03PM", + "03pm", + "3PM", + "3pm", + } { + t, err := time.Parse(layout, s) + if err == nil { + return t, nil + } + } + return time.Time{}, errInvalidTimeFormat +} diff --git a/cli/util_internal_test.go b/cli/util_internal_test.go new file mode 100644 index 0000000000..3e3d168fff --- /dev/null +++ b/cli/util_internal_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDurationDisplay(t *testing.T) { + t.Parallel() + for _, testCase := range []struct { + Duration string + Expected string + }{ + {"-1s", "<1m"}, + {"0s", "0s"}, + {"1s", "<1m"}, + {"59s", "<1m"}, + {"1m", "1m"}, + {"1m1s", "1m"}, + {"2m", "2m"}, + {"59m", "59m"}, + {"1h", "1h"}, + {"1h1m1s", "1h1m"}, + {"2h", "2h"}, + {"23h", "23h"}, + {"24h", "1d"}, + {"24h1m1s", "1d"}, + {"25h", "1d1h"}, + } { + testCase := testCase + t.Run(testCase.Duration, func(t *testing.T) { + t.Parallel() + d, err := time.ParseDuration(testCase.Duration) + require.NoError(t, err) + actual := durationDisplay(d) + assert.Equal(t, testCase.Expected, actual) + }) + } +} + +func TestRelative(t *testing.T) { + t.Parallel() + assert.Equal(t, relative(time.Minute), "in 1m") + assert.Equal(t, relative(-time.Minute), "1m ago") + assert.Equal(t, relative(0), "now") +} diff --git a/coderd/autobuild/schedule/schedule.go b/coderd/autobuild/schedule/schedule.go index a687593478..076c96f062 100644 --- a/coderd/autobuild/schedule/schedule.go +++ b/coderd/autobuild/schedule/schedule.go @@ -139,6 +139,19 @@ func (s Schedule) Min() time.Duration { return durMin } +// Time returns a humanized form of the minute and hour fields. +func (s Schedule) Time() string { + minute := strings.Fields(s.cronStr)[0] + hour := strings.Fields(s.cronStr)[1] + maybeTime := fmt.Sprintf("%s:%s", hour, minute) + t, err := time.ParseInLocation("3:4", maybeTime, s.sched.Location) + if err != nil { + // return the original cronspec for minute and hour, who knows what's in there! + return fmt.Sprintf("cron(%s %s)", minute, hour) + } + return t.Format(time.Kitchen) +} + // DaysOfWeek returns a humanized form of the day-of-week field. func (s Schedule) DaysOfWeek() string { dow := strings.Fields(s.cronStr)[4] diff --git a/coderd/autobuild/schedule/schedule_test.go b/coderd/autobuild/schedule/schedule_test.go index fee40232e9..be30d19972 100644 --- a/coderd/autobuild/schedule/schedule_test.go +++ b/coderd/autobuild/schedule/schedule_test.go @@ -22,6 +22,7 @@ func Test_Weekly(t *testing.T) { expectedCron string expectedLocation *time.Location expectedString string + expectedTime string }{ { name: "with timezone", @@ -34,6 +35,7 @@ func Test_Weekly(t *testing.T) { expectedCron: "30 9 * * 1-5", expectedLocation: mustLocation(t, "US/Central"), expectedString: "CRON_TZ=US/Central 30 9 * * 1-5", + expectedTime: "9:30AM", }, { name: "without timezone", @@ -46,6 +48,7 @@ func Test_Weekly(t *testing.T) { expectedCron: "30 9 * * 1-5", expectedLocation: time.UTC, expectedString: "CRON_TZ=UTC 30 9 * * 1-5", + expectedTime: "9:30AM", }, { name: "convoluted with timezone", @@ -58,6 +61,7 @@ func Test_Weekly(t *testing.T) { expectedCron: "*/5 12-18 * * 1,3,6", expectedLocation: mustLocation(t, "US/Central"), expectedString: "CRON_TZ=US/Central */5 12-18 * * 1,3,6", + expectedTime: "cron(*/5 12-18)", }, { name: "another convoluted example", @@ -70,6 +74,7 @@ func Test_Weekly(t *testing.T) { expectedCron: "10,20,40-50 * * * *", expectedLocation: mustLocation(t, "US/Central"), expectedString: "CRON_TZ=US/Central 10,20,40-50 * * * *", + expectedTime: "cron(10,20,40-50 *)", }, { name: "time.Local will bite you", diff --git a/coderd/util/ptr/ptr.go b/coderd/util/ptr/ptr.go index dfed4c83ec..eef582b95d 100644 --- a/coderd/util/ptr/ptr.go +++ b/coderd/util/ptr/ptr.go @@ -17,6 +17,14 @@ func NilOrEmpty(s *string) bool { return s == nil || *s == "" } +// NilToEmpty coalesces a nil str to the empty string. +func NilToEmpty(s *string) string { + if s == nil { + return "" + } + return *s +} + // NilOrZero returns true if v is nil or 0. func NilOrZero[T number](v *T) bool { return v == nil || *v == 0