mirror of https://github.com/coder/coder.git
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:
parent
c36b0d892b
commit
c9691eafcb
244
cli/autostart.go
244
cli/autostart.go
|
@ -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
|
||||
}
|
|
@ -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)")
|
||||
}
|
98
cli/bump.go
98
cli/bump.go
|
@ -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
|
||||
}
|
149
cli/bump_test.go
149
cli/bump_test.go
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
package cli
|
||||
|
||||
const (
|
||||
timeFormat = "3:04:05 PM MST"
|
||||
timeFormat = "3:04PM MST"
|
||||
dateFormat = "Jan 2, 2006"
|
||||
)
|
||||
|
|
63
cli/list.go
63
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
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
154
cli/ttl.go
154
cli/ttl.go
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
229
cli/ttl_test.go
229
cli/ttl_test.go
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue