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
|
package cli
|
||||||
|
|
||||||
const (
|
const (
|
||||||
timeFormat = "3:04:05 PM MST"
|
timeFormat = "3:04PM MST"
|
||||||
dateFormat = "Jan 2, 2006"
|
dateFormat = "Jan 2, 2006"
|
||||||
)
|
)
|
||||||
|
|
63
cli/list.go
63
cli/list.go
|
@ -2,7 +2,6 @@ package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
@ -50,13 +49,14 @@ func list() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
tableWriter := cliui.Table()
|
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.AppendHeader(header)
|
||||||
tableWriter.SortBy([]table.SortBy{{
|
tableWriter.SortBy([]table.SortBy{{
|
||||||
Name: "workspace",
|
Name: "workspace",
|
||||||
}})
|
}})
|
||||||
tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, columns))
|
tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, columns))
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
for _, workspace := range workspaces {
|
for _, workspace := range workspaces {
|
||||||
status := ""
|
status := ""
|
||||||
inProgress := false
|
inProgress := false
|
||||||
|
@ -86,11 +86,11 @@ func list() *cobra.Command {
|
||||||
status = "Failed"
|
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 := "-"
|
autostartDisplay := "-"
|
||||||
if !ptr.NilOrEmpty(workspace.AutostartSchedule) {
|
if !ptr.NilOrEmpty(workspace.AutostartSchedule) {
|
||||||
if sched, err := schedule.Weekly(*workspace.AutostartSchedule); err == nil {
|
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) {
|
if !ptr.NilOrZero(workspace.TTLMillis) {
|
||||||
dur := time.Duration(*workspace.TTLMillis) * time.Millisecond
|
dur := time.Duration(*workspace.TTLMillis) * time.Millisecond
|
||||||
autostopDisplay = durationDisplay(dur)
|
autostopDisplay = durationDisplay(dur)
|
||||||
if has, ext := hasExtension(workspace); has {
|
if !workspace.LatestBuild.Deadline.IsZero() && workspace.LatestBuild.Deadline.After(now) && status == "Running" {
|
||||||
autostopDisplay += fmt.Sprintf(" (+%s)", durationDisplay(ext.Round(time.Minute)))
|
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,
|
user.Username + "/" + workspace.Name,
|
||||||
workspace.TemplateName,
|
workspace.TemplateName,
|
||||||
status,
|
status,
|
||||||
durationDisplay(duration),
|
durationDisplay(lastBuilt),
|
||||||
workspace.Outdated,
|
workspace.Outdated,
|
||||||
autostartDisplay,
|
autostartDisplay,
|
||||||
autostopDisplay,
|
autostopDisplay,
|
||||||
|
@ -122,51 +123,3 @@ func list() *cobra.Command {
|
||||||
"Specify a column to filter in the table.")
|
"Specify a column to filter in the table.")
|
||||||
return cmd
|
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(
|
cmd.AddCommand(
|
||||||
autostart(),
|
|
||||||
bump(),
|
|
||||||
configSSH(),
|
configSSH(),
|
||||||
create(),
|
create(),
|
||||||
delete(),
|
delete(),
|
||||||
|
@ -77,6 +75,7 @@ func Root() *cobra.Command {
|
||||||
logout(),
|
logout(),
|
||||||
publickey(),
|
publickey(),
|
||||||
resetPassword(),
|
resetPassword(),
|
||||||
|
schedules(),
|
||||||
server(),
|
server(),
|
||||||
show(),
|
show(),
|
||||||
start(),
|
start(),
|
||||||
|
@ -84,7 +83,6 @@ func Root() *cobra.Command {
|
||||||
stop(),
|
stop(),
|
||||||
ssh(),
|
ssh(),
|
||||||
templates(),
|
templates(),
|
||||||
ttl(),
|
|
||||||
update(),
|
update(),
|
||||||
users(),
|
users(),
|
||||||
portForward(),
|
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
|
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.
|
// DaysOfWeek returns a humanized form of the day-of-week field.
|
||||||
func (s Schedule) DaysOfWeek() string {
|
func (s Schedule) DaysOfWeek() string {
|
||||||
dow := strings.Fields(s.cronStr)[4]
|
dow := strings.Fields(s.cronStr)[4]
|
||||||
|
|
|
@ -22,6 +22,7 @@ func Test_Weekly(t *testing.T) {
|
||||||
expectedCron string
|
expectedCron string
|
||||||
expectedLocation *time.Location
|
expectedLocation *time.Location
|
||||||
expectedString string
|
expectedString string
|
||||||
|
expectedTime string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "with timezone",
|
name: "with timezone",
|
||||||
|
@ -34,6 +35,7 @@ func Test_Weekly(t *testing.T) {
|
||||||
expectedCron: "30 9 * * 1-5",
|
expectedCron: "30 9 * * 1-5",
|
||||||
expectedLocation: mustLocation(t, "US/Central"),
|
expectedLocation: mustLocation(t, "US/Central"),
|
||||||
expectedString: "CRON_TZ=US/Central 30 9 * * 1-5",
|
expectedString: "CRON_TZ=US/Central 30 9 * * 1-5",
|
||||||
|
expectedTime: "9:30AM",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "without timezone",
|
name: "without timezone",
|
||||||
|
@ -46,6 +48,7 @@ func Test_Weekly(t *testing.T) {
|
||||||
expectedCron: "30 9 * * 1-5",
|
expectedCron: "30 9 * * 1-5",
|
||||||
expectedLocation: time.UTC,
|
expectedLocation: time.UTC,
|
||||||
expectedString: "CRON_TZ=UTC 30 9 * * 1-5",
|
expectedString: "CRON_TZ=UTC 30 9 * * 1-5",
|
||||||
|
expectedTime: "9:30AM",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "convoluted with timezone",
|
name: "convoluted with timezone",
|
||||||
|
@ -58,6 +61,7 @@ func Test_Weekly(t *testing.T) {
|
||||||
expectedCron: "*/5 12-18 * * 1,3,6",
|
expectedCron: "*/5 12-18 * * 1,3,6",
|
||||||
expectedLocation: mustLocation(t, "US/Central"),
|
expectedLocation: mustLocation(t, "US/Central"),
|
||||||
expectedString: "CRON_TZ=US/Central */5 12-18 * * 1,3,6",
|
expectedString: "CRON_TZ=US/Central */5 12-18 * * 1,3,6",
|
||||||
|
expectedTime: "cron(*/5 12-18)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "another convoluted example",
|
name: "another convoluted example",
|
||||||
|
@ -70,6 +74,7 @@ func Test_Weekly(t *testing.T) {
|
||||||
expectedCron: "10,20,40-50 * * * *",
|
expectedCron: "10,20,40-50 * * * *",
|
||||||
expectedLocation: mustLocation(t, "US/Central"),
|
expectedLocation: mustLocation(t, "US/Central"),
|
||||||
expectedString: "CRON_TZ=US/Central 10,20,40-50 * * * *",
|
expectedString: "CRON_TZ=US/Central 10,20,40-50 * * * *",
|
||||||
|
expectedTime: "cron(10,20,40-50 *)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "time.Local will bite you",
|
name: "time.Local will bite you",
|
||||||
|
|
|
@ -17,6 +17,14 @@ func NilOrEmpty(s *string) bool {
|
||||||
return s == nil || *s == ""
|
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.
|
// NilOrZero returns true if v is nil or 0.
|
||||||
func NilOrZero[T number](v *T) bool {
|
func NilOrZero[T number](v *T) bool {
|
||||||
return v == nil || *v == 0
|
return v == nil || *v == 0
|
||||||
|
|
Loading…
Reference in New Issue