mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00:00
cli: streamline autostart ux (#2251)
This commit adds the following changes: - autostart enable|disable => autostart set|unset - autostart enable now accepts a more natual schedule format: <time> <days-of-week> <location> - autostart show now shows configured timezone - 🎉 automatic timezone detection across mac, windows, linux 🎉 Fixes #1647
This commit is contained in:
157
cli/autostart.go
157
cli/autostart.go
@ -2,32 +2,41 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
"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"
|
"github.com/coder/coder/codersdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart.
|
const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart.
|
||||||
When enabling autostart, provide the minute, hour, and day(s) of week.
|
When enabling autostart, enter a schedule in the format: <start-time> [day-of-week] [location].
|
||||||
The default schedule is at 09:00 in your local timezone (TZ env, UTC by default).
|
* 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 {
|
func autostart() *cobra.Command {
|
||||||
autostartCmd := &cobra.Command{
|
autostartCmd := &cobra.Command{
|
||||||
Annotations: workspaceCommand,
|
Annotations: workspaceCommand,
|
||||||
Use: "autostart enable <workspace>",
|
Use: "autostart set <workspace> <start-time> [day-of-week] [location]",
|
||||||
Short: "schedule a workspace to automatically start at a regular time",
|
Short: "schedule a workspace to automatically start at a regular time",
|
||||||
Long: autostartDescriptionLong,
|
Long: autostartDescriptionLong,
|
||||||
Example: "coder autostart enable my-workspace --minute 30 --hour 9 --days 1-5 --tz Europe/Dublin",
|
Example: "coder autostart set my-workspace 9:30AM Mon-Fri Europe/Dublin",
|
||||||
}
|
}
|
||||||
|
|
||||||
autostartCmd.AddCommand(autostartShow())
|
autostartCmd.AddCommand(autostartShow())
|
||||||
autostartCmd.AddCommand(autostartEnable())
|
autostartCmd.AddCommand(autostartSet())
|
||||||
autostartCmd.AddCommand(autostartDisable())
|
autostartCmd.AddCommand(autostartUnset())
|
||||||
|
|
||||||
return autostartCmd
|
return autostartCmd
|
||||||
}
|
}
|
||||||
@ -60,13 +69,12 @@ func autostartShow() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
next := validSchedule.Next(time.Now())
|
next := validSchedule.Next(time.Now())
|
||||||
loc, _ := time.LoadLocation(validSchedule.Timezone())
|
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
|
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
|
||||||
"schedule: %s\ntimezone: %s\nnext: %s\n",
|
"schedule: %s\ntimezone: %s\nnext: %s\n",
|
||||||
validSchedule.Cron(),
|
validSchedule.Cron(),
|
||||||
validSchedule.Timezone(),
|
validSchedule.Location(),
|
||||||
next.In(loc),
|
next.In(validSchedule.Location()),
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -75,23 +83,17 @@ func autostartShow() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func autostartEnable() *cobra.Command {
|
func autostartSet() *cobra.Command {
|
||||||
// yes some of these are technically numbers but the cron library will do that work
|
|
||||||
var autostartMinute string
|
|
||||||
var autostartHour string
|
|
||||||
var autostartDayOfWeek string
|
|
||||||
var autostartTimezone string
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "enable <workspace_name> <schedule>",
|
Use: "set <workspace_name> <start-time> [day-of-week] [location]",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.RangeArgs(2, 4),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
client, err := createClient(cmd)
|
client, err := createClient(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
spec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", autostartTimezone, autostartMinute, autostartHour, autostartDayOfWeek)
|
sched, err := parseCLISchedule(args[1:]...)
|
||||||
validSchedule, err := schedule.Weekly(spec)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -102,32 +104,30 @@ func autostartEnable() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||||
Schedule: &spec,
|
Schedule: ptr.Ref(sched.String()),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will automatically start at %s.\n\n", workspace.Name, validSchedule.Next(time.Now()))
|
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 nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVar(&autostartMinute, "minute", "0", "autostart minute")
|
|
||||||
cmd.Flags().StringVar(&autostartHour, "hour", "9", "autostart hour")
|
|
||||||
cmd.Flags().StringVar(&autostartDayOfWeek, "days", "1-5", "autostart day(s) of week")
|
|
||||||
tzEnv := os.Getenv("TZ")
|
|
||||||
if tzEnv == "" {
|
|
||||||
tzEnv = "UTC"
|
|
||||||
}
|
|
||||||
cmd.Flags().StringVar(&autostartTimezone, "tz", tzEnv, "autostart timezone")
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func autostartDisable() *cobra.Command {
|
func autostartUnset() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "disable <workspace_name>",
|
Use: "unset <workspace_name>",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
client, err := createClient(cmd)
|
client, err := createClient(cmd)
|
||||||
@ -147,9 +147,98 @@ func autostartDisable() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will no longer automatically start.\n\n", workspace.Name)
|
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s will no longer automatically start.\n", workspace.Name)
|
||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
119
cli/autostart_internal_test.go
Normal file
119
cli/autostart_internal_test.go
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:paralleltest // t.Setenv
|
||||||
|
func TestParseCLISchedule(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
input []string
|
||||||
|
expectedSchedule string
|
||||||
|
expectedError string
|
||||||
|
tzEnv string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "TimeAndDayOfWeekAndLocation",
|
||||||
|
input: []string{"09:00AM", "Sun-Sat", "America/Chicago"},
|
||||||
|
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
|
||||||
|
tzEnv: "UTC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TimeOfDay24HourAndDayOfWeekAndLocation",
|
||||||
|
input: []string{"09:00", "Sun-Sat", "America/Chicago"},
|
||||||
|
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
|
||||||
|
tzEnv: "UTC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TimeOfDay24HourAndDayOfWeekAndLocationButItsAllQuoted",
|
||||||
|
input: []string{"09:00 Sun-Sat America/Chicago"},
|
||||||
|
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
|
||||||
|
tzEnv: "UTC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TimeOfDayOnly",
|
||||||
|
input: []string{"09:00AM"},
|
||||||
|
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
|
||||||
|
tzEnv: "America/Chicago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Time24Military",
|
||||||
|
input: []string{"0900"},
|
||||||
|
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
|
||||||
|
tzEnv: "America/Chicago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DayOfWeekAndTime",
|
||||||
|
input: []string{"09:00AM", "Sun-Sat"},
|
||||||
|
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * Sun-Sat",
|
||||||
|
tzEnv: "America/Chicago",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TimeAndLocation",
|
||||||
|
input: []string{"09:00AM", "America/Chicago"},
|
||||||
|
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
|
||||||
|
tzEnv: "UTC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LazyTime",
|
||||||
|
input: []string{"9am", "America/Chicago"},
|
||||||
|
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
|
||||||
|
tzEnv: "UTC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ZeroPrefixedLazyTime",
|
||||||
|
input: []string{"09am", "America/Chicago"},
|
||||||
|
expectedSchedule: "CRON_TZ=America/Chicago 0 9 * * *",
|
||||||
|
tzEnv: "UTC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidTime",
|
||||||
|
input: []string{"nine"},
|
||||||
|
expectedError: errInvalidTimeFormat.Error(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DayOfWeekAndInvalidTime",
|
||||||
|
input: []string{"nine", "Sun-Sat"},
|
||||||
|
expectedError: errInvalidTimeFormat.Error(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidTimeAndLocation",
|
||||||
|
input: []string{"nine", "America/Chicago"},
|
||||||
|
expectedError: errInvalidTimeFormat.Error(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DayOfWeekAndInvalidTimeAndLocation",
|
||||||
|
input: []string{"nine", "Sun-Sat", "America/Chicago"},
|
||||||
|
expectedError: errInvalidTimeFormat.Error(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TimezoneProvidedInsteadOfLocation",
|
||||||
|
input: []string{"09:00AM", "Sun-Sat", "CST"},
|
||||||
|
expectedError: errUnsupportedTimezone.Error(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "WhoKnows",
|
||||||
|
input: []string{"Time", "is", "a", "human", "construct"},
|
||||||
|
expectedError: errInvalidTimeFormat.Error(),
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
testCase := testCase
|
||||||
|
//nolint:paralleltest // t.Setenv
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
t.Setenv("TZ", testCase.tzEnv)
|
||||||
|
actualSchedule, actualError := parseCLISchedule(testCase.input...)
|
||||||
|
if testCase.expectedError != "" {
|
||||||
|
assert.Nil(t, actualSchedule)
|
||||||
|
assert.ErrorContains(t, actualError, testCase.expectedError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NoError(t, actualError)
|
||||||
|
if assert.NotEmpty(t, actualSchedule) {
|
||||||
|
assert.Equal(t, testCase.expectedSchedule, actualSchedule.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -4,9 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@ -50,7 +48,7 @@ func TestAutostart(t *testing.T) {
|
|||||||
require.Contains(t, stdoutBuf.String(), "schedule: 30 17 * * 1-5")
|
require.Contains(t, stdoutBuf.String(), "schedule: 30 17 * * 1-5")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("EnableDisableOK", func(t *testing.T) {
|
t.Run("setunsetOK", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -62,8 +60,8 @@ func TestAutostart(t *testing.T) {
|
|||||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
||||||
tz = "Europe/Dublin"
|
tz = "Europe/Dublin"
|
||||||
cmdArgs = []string{"autostart", "enable", workspace.Name, "--minute", "30", "--hour", "9", "--days", "1-5", "--tz", tz}
|
cmdArgs = []string{"autostart", "set", workspace.Name, "9:30AM", "Mon-Fri", tz}
|
||||||
sched = "CRON_TZ=Europe/Dublin 30 9 * * 1-5"
|
sched = "CRON_TZ=Europe/Dublin 30 9 * * Mon-Fri"
|
||||||
stdoutBuf = &bytes.Buffer{}
|
stdoutBuf = &bytes.Buffer{}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -73,15 +71,15 @@ func TestAutostart(t *testing.T) {
|
|||||||
|
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
require.NoError(t, err, "unexpected error")
|
require.NoError(t, err, "unexpected error")
|
||||||
require.Contains(t, stdoutBuf.String(), "will automatically start at", "unexpected output")
|
require.Contains(t, stdoutBuf.String(), "will automatically start at 9:30AM Mon-Fri (Europe/Dublin)", "unexpected output")
|
||||||
|
|
||||||
// Ensure autostart schedule updated
|
// Ensure autostart schedule updated
|
||||||
updated, err := client.Workspace(ctx, workspace.ID)
|
updated, err := client.Workspace(ctx, workspace.ID)
|
||||||
require.NoError(t, err, "fetch updated workspace")
|
require.NoError(t, err, "fetch updated workspace")
|
||||||
require.Equal(t, sched, *updated.AutostartSchedule, "expected autostart schedule to be set")
|
require.Equal(t, sched, *updated.AutostartSchedule, "expected autostart schedule to be set")
|
||||||
|
|
||||||
// Disable schedule
|
// unset schedule
|
||||||
cmd, root = clitest.New(t, "autostart", "disable", workspace.Name)
|
cmd, root = clitest.New(t, "autostart", "unset", workspace.Name)
|
||||||
clitest.SetupConfig(t, client, root)
|
clitest.SetupConfig(t, client, root)
|
||||||
cmd.SetOut(stdoutBuf)
|
cmd.SetOut(stdoutBuf)
|
||||||
|
|
||||||
@ -95,7 +93,7 @@ func TestAutostart(t *testing.T) {
|
|||||||
require.Nil(t, updated.AutostartSchedule, "expected autostart schedule to not be set")
|
require.Nil(t, updated.AutostartSchedule, "expected autostart schedule to not be set")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Enable_NotFound", func(t *testing.T) {
|
t.Run("set_NotFound", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -105,14 +103,14 @@ func TestAutostart(t *testing.T) {
|
|||||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd, root := clitest.New(t, "autostart", "enable", "doesnotexist")
|
cmd, root := clitest.New(t, "autostart", "set", "doesnotexist", "09:30AM")
|
||||||
clitest.SetupConfig(t, client, root)
|
clitest.SetupConfig(t, client, root)
|
||||||
|
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
require.ErrorContains(t, err, "status code 403: Forbidden", "unexpected error")
|
require.ErrorContains(t, err, "status code 403: Forbidden", "unexpected error")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Disable_NotFound", func(t *testing.T) {
|
t.Run("unset_NotFound", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -122,16 +120,17 @@ func TestAutostart(t *testing.T) {
|
|||||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd, root := clitest.New(t, "autostart", "disable", "doesnotexist")
|
cmd, root := clitest.New(t, "autostart", "unset", "doesnotexist")
|
||||||
clitest.SetupConfig(t, client, root)
|
clitest.SetupConfig(t, client, root)
|
||||||
|
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
require.ErrorContains(t, err, "status code 403: Forbidden", "unexpected error")
|
require.ErrorContains(t, err, "status code 403: Forbidden", "unexpected error")
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("Enable_DefaultSchedule", func(t *testing.T) {
|
//nolint:paralleltest // t.Setenv
|
||||||
t.Parallel()
|
func TestAutostartSetDefaultSchedule(t *testing.T) {
|
||||||
|
t.Setenv("TZ", "UTC")
|
||||||
var (
|
var (
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||||
@ -140,16 +139,13 @@ func TestAutostart(t *testing.T) {
|
|||||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
||||||
|
stdoutBuf = &bytes.Buffer{}
|
||||||
)
|
)
|
||||||
|
|
||||||
// check current TZ env var
|
expectedSchedule := fmt.Sprintf("CRON_TZ=%s 30 9 * * *", "UTC")
|
||||||
currTz := os.Getenv("TZ")
|
cmd, root := clitest.New(t, "autostart", "set", workspace.Name, "9:30AM")
|
||||||
if currTz == "" {
|
|
||||||
currTz = "UTC"
|
|
||||||
}
|
|
||||||
expectedSchedule := fmt.Sprintf("CRON_TZ=%s 0 9 * * 1-5", currTz)
|
|
||||||
cmd, root := clitest.New(t, "autostart", "enable", workspace.Name)
|
|
||||||
clitest.SetupConfig(t, client, root)
|
clitest.SetupConfig(t, client, root)
|
||||||
|
cmd.SetOutput(stdoutBuf)
|
||||||
|
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
require.NoError(t, err, "unexpected error")
|
require.NoError(t, err, "unexpected error")
|
||||||
@ -158,27 +154,5 @@ func TestAutostart(t *testing.T) {
|
|||||||
updated, err := client.Workspace(ctx, workspace.ID)
|
updated, err := client.Workspace(ctx, workspace.ID)
|
||||||
require.NoError(t, err, "fetch updated workspace")
|
require.NoError(t, err, "fetch updated workspace")
|
||||||
require.Equal(t, expectedSchedule, *updated.AutostartSchedule, "expected default autostart schedule")
|
require.Equal(t, expectedSchedule, *updated.AutostartSchedule, "expected default autostart schedule")
|
||||||
})
|
require.Contains(t, stdoutBuf.String(), "will automatically start at 9:30AM daily (UTC)")
|
||||||
|
|
||||||
t.Run("BelowTemplateConstraint", 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)
|
|
||||||
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
|
||||||
ctr.MinAutostartIntervalMillis = ptr.Ref(time.Hour.Milliseconds())
|
|
||||||
})
|
|
||||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
|
|
||||||
cmdArgs = []string{"autostart", "enable", workspace.Name, "--minute", "*", "--hour", "*"}
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd, root := clitest.New(t, cmdArgs...)
|
|
||||||
clitest.SetupConfig(t, client, root)
|
|
||||||
|
|
||||||
err := cmd.Execute()
|
|
||||||
require.ErrorContains(t, err, "schedule: Minimum autostart interval 1m0s below template minimum 1h0m0s")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
package schedule
|
package schedule
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -74,7 +75,8 @@ func Weekly(raw string) (*Schedule, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Schedule represents a cron schedule.
|
// Schedule represents a cron schedule.
|
||||||
// It's essentially a thin wrapper for robfig/cron/v3 that implements Stringer.
|
// It's essentially a wrapper for robfig/cron/v3 that has additional
|
||||||
|
// convenience methods.
|
||||||
type Schedule struct {
|
type Schedule struct {
|
||||||
sched *cron.SpecSchedule
|
sched *cron.SpecSchedule
|
||||||
// XXX: there isn't any nice way for robfig/cron to serialize
|
// XXX: there isn't any nice way for robfig/cron to serialize
|
||||||
@ -92,9 +94,9 @@ func (s Schedule) String() string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timezone returns the timezone for the schedule.
|
// Location returns the IANA location for the schedule.
|
||||||
func (s Schedule) Timezone() string {
|
func (s Schedule) Location() *time.Location {
|
||||||
return s.sched.Location.String()
|
return s.sched.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cron returns the cron spec for the schedule with the leading CRON_TZ
|
// Cron returns the cron spec for the schedule with the leading CRON_TZ
|
||||||
@ -137,6 +139,26 @@ func (s Schedule) Min() time.Duration {
|
|||||||
return durMin
|
return durMin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DaysOfWeek returns a humanized form of the day-of-week field.
|
||||||
|
func (s Schedule) DaysOfWeek() string {
|
||||||
|
dow := strings.Fields(s.cronStr)[4]
|
||||||
|
if dow == "*" {
|
||||||
|
return "daily"
|
||||||
|
}
|
||||||
|
for _, weekday := range []time.Weekday{
|
||||||
|
time.Sunday,
|
||||||
|
time.Monday,
|
||||||
|
time.Tuesday,
|
||||||
|
time.Wednesday,
|
||||||
|
time.Thursday,
|
||||||
|
time.Friday,
|
||||||
|
time.Saturday,
|
||||||
|
} {
|
||||||
|
dow = strings.Replace(dow, fmt.Sprintf("%d", weekday), weekday.String()[:3], 1)
|
||||||
|
}
|
||||||
|
return dow
|
||||||
|
}
|
||||||
|
|
||||||
// validateWeeklySpec ensures that the day-of-month and month options of
|
// validateWeeklySpec ensures that the day-of-month and month options of
|
||||||
// spec are both set to *
|
// spec are both set to *
|
||||||
func validateWeeklySpec(spec string) error {
|
func validateWeeklySpec(spec string) error {
|
||||||
|
@ -17,9 +17,10 @@ func Test_Weekly(t *testing.T) {
|
|||||||
at time.Time
|
at time.Time
|
||||||
expectedNext time.Time
|
expectedNext time.Time
|
||||||
expectedMin time.Duration
|
expectedMin time.Duration
|
||||||
|
expectedDaysOfWeek string
|
||||||
expectedError string
|
expectedError string
|
||||||
expectedCron string
|
expectedCron string
|
||||||
expectedTz string
|
expectedLocation *time.Location
|
||||||
expectedString string
|
expectedString string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@ -28,9 +29,10 @@ func Test_Weekly(t *testing.T) {
|
|||||||
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
|
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
|
||||||
expectedNext: time.Date(2022, 4, 1, 14, 30, 0, 0, time.UTC),
|
expectedNext: time.Date(2022, 4, 1, 14, 30, 0, 0, time.UTC),
|
||||||
expectedMin: 24 * time.Hour,
|
expectedMin: 24 * time.Hour,
|
||||||
|
expectedDaysOfWeek: "Mon-Fri",
|
||||||
expectedError: "",
|
expectedError: "",
|
||||||
expectedCron: "30 9 * * 1-5",
|
expectedCron: "30 9 * * 1-5",
|
||||||
expectedTz: "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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -39,9 +41,10 @@ func Test_Weekly(t *testing.T) {
|
|||||||
at: time.Date(2022, 4, 1, 9, 29, 0, 0, time.UTC),
|
at: time.Date(2022, 4, 1, 9, 29, 0, 0, time.UTC),
|
||||||
expectedNext: time.Date(2022, 4, 1, 9, 30, 0, 0, time.UTC),
|
expectedNext: time.Date(2022, 4, 1, 9, 30, 0, 0, time.UTC),
|
||||||
expectedMin: 24 * time.Hour,
|
expectedMin: 24 * time.Hour,
|
||||||
|
expectedDaysOfWeek: "Mon-Fri",
|
||||||
expectedError: "",
|
expectedError: "",
|
||||||
expectedCron: "30 9 * * 1-5",
|
expectedCron: "30 9 * * 1-5",
|
||||||
expectedTz: "UTC",
|
expectedLocation: time.UTC,
|
||||||
expectedString: "CRON_TZ=UTC 30 9 * * 1-5",
|
expectedString: "CRON_TZ=UTC 30 9 * * 1-5",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -50,9 +53,10 @@ func Test_Weekly(t *testing.T) {
|
|||||||
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
|
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
|
||||||
expectedNext: time.Date(2022, 4, 2, 17, 0, 0, 0, time.UTC), // Apr 1 was a Friday in 2022
|
expectedNext: time.Date(2022, 4, 2, 17, 0, 0, 0, time.UTC), // Apr 1 was a Friday in 2022
|
||||||
expectedMin: 5 * time.Minute,
|
expectedMin: 5 * time.Minute,
|
||||||
|
expectedDaysOfWeek: "Mon,Wed,Sat",
|
||||||
expectedError: "",
|
expectedError: "",
|
||||||
expectedCron: "*/5 12-18 * * 1,3,6",
|
expectedCron: "*/5 12-18 * * 1,3,6",
|
||||||
expectedTz: "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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -61,9 +65,10 @@ func Test_Weekly(t *testing.T) {
|
|||||||
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
|
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
|
||||||
expectedNext: time.Date(2022, 4, 1, 14, 40, 0, 0, time.UTC),
|
expectedNext: time.Date(2022, 4, 1, 14, 40, 0, 0, time.UTC),
|
||||||
expectedMin: time.Minute,
|
expectedMin: time.Minute,
|
||||||
|
expectedDaysOfWeek: "daily",
|
||||||
expectedError: "",
|
expectedError: "",
|
||||||
expectedCron: "10,20,40-50 * * * *",
|
expectedCron: "10,20,40-50 * * * *",
|
||||||
expectedTz: "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 * * * *",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -127,9 +132,10 @@ func Test_Weekly(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, testCase.expectedNext, nextTime)
|
require.Equal(t, testCase.expectedNext, nextTime)
|
||||||
require.Equal(t, testCase.expectedCron, actual.Cron())
|
require.Equal(t, testCase.expectedCron, actual.Cron())
|
||||||
require.Equal(t, testCase.expectedTz, actual.Timezone())
|
require.Equal(t, testCase.expectedLocation, actual.Location())
|
||||||
require.Equal(t, testCase.expectedString, actual.String())
|
require.Equal(t, testCase.expectedString, actual.String())
|
||||||
require.Equal(t, testCase.expectedMin, actual.Min())
|
require.Equal(t, testCase.expectedMin, actual.Min())
|
||||||
|
require.Equal(t, testCase.expectedDaysOfWeek, actual.DaysOfWeek())
|
||||||
} else {
|
} else {
|
||||||
require.EqualError(t, err, testCase.expectedError)
|
require.EqualError(t, err, testCase.expectedError)
|
||||||
require.Nil(t, actual)
|
require.Nil(t, actual)
|
||||||
@ -137,3 +143,10 @@ func Test_Weekly(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mustLocation(t *testing.T, s string) *time.Location {
|
||||||
|
t.Helper()
|
||||||
|
loc, err := time.LoadLocation(s)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
30
coderd/util/tz/tz.go
Normal file
30
coderd/util/tz/tz.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// Package tz includes utilities for cross-platform timezone/location detection.
|
||||||
|
package tz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNoEnvSet = xerrors.New("no env set")
|
||||||
|
|
||||||
|
func locationFromEnv() (*time.Location, error) {
|
||||||
|
tzEnv, found := os.LookupEnv("TZ")
|
||||||
|
if !found {
|
||||||
|
return nil, errNoEnvSet
|
||||||
|
}
|
||||||
|
|
||||||
|
// TZ set but empty means UTC.
|
||||||
|
if tzEnv == "" {
|
||||||
|
return time.UTC, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, err := time.LoadLocation(tzEnv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("load location from TZ env: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return loc, nil
|
||||||
|
}
|
50
coderd/util/tz/tz_darwin.go
Normal file
50
coderd/util/tz/tz_darwin.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package tz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const etcLocaltime = "/etc/localtime"
|
||||||
|
const zoneInfoPath = "/var/db/timezone/zoneinfo/"
|
||||||
|
|
||||||
|
// TimezoneIANA attempts to determine the local timezone in IANA format.
|
||||||
|
// If the TZ environment variable is set, this is used.
|
||||||
|
// Otherwise, /etc/localtime is used to determine the timezone.
|
||||||
|
// Reference: https://stackoverflow.com/a/63805394
|
||||||
|
// On Windows platforms, instead of reading /etc/localtime, powershell
|
||||||
|
// is used instead to get the current time location in IANA format.
|
||||||
|
// Reference: https://superuser.com/a/1584968
|
||||||
|
func TimezoneIANA() (*time.Location, error) {
|
||||||
|
loc, err := locationFromEnv()
|
||||||
|
if err == nil {
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
if !xerrors.Is(err, errNoEnvSet) {
|
||||||
|
return nil, xerrors.Errorf("lookup timezone from env: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lp, err := filepath.EvalSymlinks(etcLocaltime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("read location of %s: %w", etcLocaltime, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Darwin, /var/db/timezone/zoneinfo is also a symlink
|
||||||
|
realZoneInfoPath, err := filepath.EvalSymlinks(zoneInfoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("read location of %s: %w", zoneInfoPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stripped := strings.Replace(lp, realZoneInfoPath, "", -1)
|
||||||
|
stripped = strings.TrimPrefix(stripped, string(filepath.Separator))
|
||||||
|
loc, err = time.LoadLocation(stripped)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("invalid location %q guessed from %s: %w", stripped, lp, err)
|
||||||
|
}
|
||||||
|
return loc, nil
|
||||||
|
}
|
44
coderd/util/tz/tz_linux.go
Normal file
44
coderd/util/tz/tz_linux.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package tz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const etcLocaltime = "/etc/localtime"
|
||||||
|
const zoneInfoPath = "/usr/share/zoneinfo"
|
||||||
|
|
||||||
|
// TimezoneIANA attempts to determine the local timezone in IANA format.
|
||||||
|
// If the TZ environment variable is set, this is used.
|
||||||
|
// Otherwise, /etc/localtime is used to determine the timezone.
|
||||||
|
// Reference: https://stackoverflow.com/a/63805394
|
||||||
|
// On Windows platforms, instead of reading /etc/localtime, powershell
|
||||||
|
// is used instead to get the current time location in IANA format.
|
||||||
|
// Reference: https://superuser.com/a/1584968
|
||||||
|
func TimezoneIANA() (*time.Location, error) {
|
||||||
|
loc, err := locationFromEnv()
|
||||||
|
if err == nil {
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
if !xerrors.Is(err, errNoEnvSet) {
|
||||||
|
return nil, xerrors.Errorf("lookup timezone from env: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lp, err := filepath.EvalSymlinks(etcLocaltime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("read location of %s: %w", etcLocaltime, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stripped := strings.Replace(lp, zoneInfoPath, "", -1)
|
||||||
|
stripped = strings.TrimPrefix(stripped, string(filepath.Separator))
|
||||||
|
loc, err = time.LoadLocation(stripped)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("invalid location %q guessed from %s: %w", stripped, lp, err)
|
||||||
|
}
|
||||||
|
return loc, nil
|
||||||
|
}
|
40
coderd/util/tz/tz_test.go
Normal file
40
coderd/util/tz/tz_test.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package tz_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/coder/coder/coderd/util/tz"
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:paralleltest // Environment variables
|
||||||
|
func Test_TimezoneIANA(t *testing.T) {
|
||||||
|
//nolint:paralleltest // t.Setenv
|
||||||
|
t.Run("Env", func(t *testing.T) {
|
||||||
|
t.Setenv("TZ", "Europe/Dublin")
|
||||||
|
|
||||||
|
zone, err := tz.TimezoneIANA()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if assert.NotNil(t, zone) {
|
||||||
|
assert.Equal(t, "Europe/Dublin", zone.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
//nolint:paralleltest // UnsetEnv
|
||||||
|
t.Run("NoEnv", func(t *testing.T) {
|
||||||
|
oldEnv, found := os.LookupEnv("TZ")
|
||||||
|
if found {
|
||||||
|
require.NoError(t, os.Unsetenv("TZ"))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Setenv("TZ", oldEnv)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
zone, err := tz.TimezoneIANA()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, zone)
|
||||||
|
})
|
||||||
|
}
|
55
coderd/util/tz/tz_windows.go
Normal file
55
coderd/util/tz/tz_windows.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// go:build windows
|
||||||
|
|
||||||
|
package tz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cmdTimezone is a Powershell incantation that will return the system
|
||||||
|
// time location in IANA format.
|
||||||
|
const cmdTimezone = "[Windows.Globalization.Calendar,Windows.Globalization,ContentType=WindowsRuntime]::New().GetTimeZone()"
|
||||||
|
|
||||||
|
// TimezoneIANA attempts to determine the local timezone in IANA format.
|
||||||
|
// If the TZ environment variable is set, this is used.
|
||||||
|
// Otherwise, /etc/localtime is used to determine the timezone.
|
||||||
|
// Reference: https://stackoverflow.com/a/63805394
|
||||||
|
// On Windows platforms, instead of reading /etc/localtime, powershell
|
||||||
|
// is used instead to get the current time location in IANA format.
|
||||||
|
// Reference: https://superuser.com/a/1584968
|
||||||
|
func TimezoneIANA() (*time.Location, error) {
|
||||||
|
loc, err := locationFromEnv()
|
||||||
|
if err == nil {
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
if !xerrors.Is(err, errNoEnvSet) {
|
||||||
|
return nil, xerrors.Errorf("lookup timezone from env: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://superuser.com/a/1584968
|
||||||
|
cmd := exec.Command("powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive")
|
||||||
|
// Powershell echoes its stdin so write a newline
|
||||||
|
cmd.Stdin = strings.NewReader(cmdTimezone + "\n")
|
||||||
|
|
||||||
|
outBytes, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("execute powershell command %q: %w", cmdTimezone, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outLines := strings.Split(string(outBytes), "\n")
|
||||||
|
if len(outLines) < 2 {
|
||||||
|
return nil, xerrors.Errorf("unexpected output from powershell command %q: %q", cmdTimezone, outLines)
|
||||||
|
}
|
||||||
|
// What we want is the second line of output
|
||||||
|
locStr := strings.TrimSpace(outLines[1])
|
||||||
|
loc, err = time.LoadLocation(locStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("invalid location %q from powershell: %w", locStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return loc, nil
|
||||||
|
}
|
Reference in New Issue
Block a user