feat: cli: consolidate schedule-related commands (#2402)

* feat: cli: consolidate schedule-related commands

This commit makes the following changes:
- renames autostart -> schedule starat
- renames ttl -> schedule stop
- renames bump -> schedule override
- adds schedule show command
- moves some cli-related stuff to util.go
This commit is contained in:
Cian Johnston 2022-06-16 18:24:10 +01:00 committed by GitHub
parent c36b0d892b
commit c9691eafcb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 930 additions and 1091 deletions

View File

@ -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: <start-time> [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 <workspace> <start-time> [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 <workspace_name>",
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 <workspace_name> <start-time> [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 <workspace_name>",
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
}

View File

@ -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)")
}

View File

@ -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 <workspace-name> <duration from now>",
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
}

View File

@ -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 <number without units>`
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 <not a number>`
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)
})
}

View File

@ -1,6 +1,6 @@
package cli
const (
timeFormat = "3:04:05 PM MST"
timeFormat = "3:04PM MST"
dateFormat = "Jan 2, 2006"
)

View File

@ -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
}

View File

@ -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(),

292
cli/schedule.go Normal file
View File

@ -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: <start-time> [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 } <workspace>",
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 <workspace-name>",
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 <workspace-name> { <start-time> [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 <workspace-name> { <duration> | 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 <workspace-name> <duration from now>",
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
}

376
cli/schedule_test.go Normal file
View File

@ -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 <number without units>`
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 <not a number>`
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")
}
}

View File

@ -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 <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)
}
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 <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)
}
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 <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)
}
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
},
}
}

View File

@ -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)
})
}

177
cli/util.go Normal file
View File

@ -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
}

49
cli/util_internal_test.go Normal file
View File

@ -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")
}

View File

@ -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]

View File

@ -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",

View File

@ -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