mirror of https://github.com/coder/coder.git
refactor: workspace autostop_schedule -> ttl (#1578)
Co-authored-by: G r e y <grey@coder.com>
This commit is contained in:
parent
6c1117094d
commit
d72c45e483
|
@ -3,6 +3,6 @@ package usershell
|
|||
import "os"
|
||||
|
||||
// Get returns the $SHELL environment variable.
|
||||
func Get(username string) (string, error) {
|
||||
func Get(_ string) (string, error) {
|
||||
return os.Getenv("SHELL"), nil
|
||||
}
|
||||
|
|
167
cli/autostop.go
167
cli/autostop.go
|
@ -1,167 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
const autostopDescriptionLong = `To have your workspace stop automatically at a regular time you can enable autostop.
|
||||
When enabling autostop, provide the minute, hour, and day(s) of week.
|
||||
The default autostop schedule is at 18:00 in your local timezone (TZ env, UTC by default).
|
||||
`
|
||||
|
||||
func autostop() *cobra.Command {
|
||||
autostopCmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "autostop enable <workspace>",
|
||||
Short: "schedule a workspace to automatically stop at a regular time",
|
||||
Long: autostopDescriptionLong,
|
||||
Example: "coder autostop enable my-workspace --minute 0 --hour 18 --days 1-5 -tz Europe/Dublin",
|
||||
}
|
||||
|
||||
autostopCmd.AddCommand(autostopShow())
|
||||
autostopCmd.AddCommand(autostopEnable())
|
||||
autostopCmd.AddCommand(autostopDisable())
|
||||
|
||||
return autostopCmd
|
||||
}
|
||||
|
||||
func autostopShow() *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
|
||||
}
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if workspace.AutostopSchedule == "" {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "not enabled\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
validSchedule, err := schedule.Weekly(workspace.AutostopSchedule)
|
||||
if err != nil {
|
||||
// This should never happen.
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "invalid autostop schedule %q for workspace %s: %s\n", workspace.AutostopSchedule, workspace.Name, err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
next := validSchedule.Next(time.Now())
|
||||
loc, _ := time.LoadLocation(validSchedule.Timezone())
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(),
|
||||
"schedule: %s\ntimezone: %s\nnext: %s\n",
|
||||
validSchedule.Cron(),
|
||||
validSchedule.Timezone(),
|
||||
next.In(loc),
|
||||
)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func autostopEnable() *cobra.Command {
|
||||
// yes some of these are technically numbers but the cron library will do that work
|
||||
var autostopMinute string
|
||||
var autostopHour string
|
||||
var autostopDayOfWeek string
|
||||
var autostopTimezone string
|
||||
cmd := &cobra.Command{
|
||||
Use: "enable <workspace_name> <schedule>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spec := fmt.Sprintf("CRON_TZ=%s %s %s * * %s", autostopTimezone, autostopMinute, autostopHour, autostopDayOfWeek)
|
||||
validSchedule, err := schedule.Weekly(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.UpdateWorkspaceAutostop(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
|
||||
Schedule: validSchedule.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will automatically stop at %s.\n\n", workspace.Name, validSchedule.Next(time.Now()))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&autostopMinute, "minute", "0", "autostop minute")
|
||||
cmd.Flags().StringVar(&autostopHour, "hour", "18", "autostop hour")
|
||||
cmd.Flags().StringVar(&autostopDayOfWeek, "days", "1-5", "autostop day(s) of week")
|
||||
tzEnv := os.Getenv("TZ")
|
||||
if tzEnv == "" {
|
||||
tzEnv = "UTC"
|
||||
}
|
||||
cmd.Flags().StringVar(&autostopTimezone, "tz", tzEnv, "autostop timezone")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func autostopDisable() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "disable <workspace_name>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, err := createClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.UpdateWorkspaceAutostop(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
|
||||
Schedule: "",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will no longer automatically stop.\n\n", workspace.Name)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
|
@ -49,7 +49,7 @@ func list() *cobra.Command {
|
|||
}
|
||||
|
||||
tableWriter := cliui.Table()
|
||||
header := table.Row{"workspace", "template", "status", "last built", "outdated", "autostart", "autostop"}
|
||||
header := table.Row{"workspace", "template", "status", "last built", "outdated", "autostart", "ttl"}
|
||||
tableWriter.AppendHeader(header)
|
||||
tableWriter.SortBy([]table.SortBy{{
|
||||
Name: "workspace",
|
||||
|
@ -116,10 +116,8 @@ func list() *cobra.Command {
|
|||
}
|
||||
|
||||
autostopDisplay := "-"
|
||||
if workspace.AutostopSchedule != "" {
|
||||
if sched, err := schedule.Weekly(workspace.AutostopSchedule); err == nil {
|
||||
autostopDisplay = sched.Cron()
|
||||
}
|
||||
if workspace.TTL != nil {
|
||||
autostopDisplay = workspace.TTL.String()
|
||||
}
|
||||
|
||||
user := usersByID[workspace.OwnerID]
|
||||
|
|
|
@ -62,7 +62,6 @@ func Root() *cobra.Command {
|
|||
|
||||
cmd.AddCommand(
|
||||
autostart(),
|
||||
autostop(),
|
||||
configSSH(),
|
||||
create(),
|
||||
delete(),
|
||||
|
@ -78,6 +77,7 @@ func Root() *cobra.Command {
|
|||
stop(),
|
||||
ssh(),
|
||||
templates(),
|
||||
ttl(),
|
||||
update(),
|
||||
users(),
|
||||
portForward(),
|
||||
|
|
10
cli/ssh.go
10
cli/ssh.go
|
@ -21,7 +21,6 @@ import (
|
|||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/autobuild/notify"
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
)
|
||||
|
@ -270,16 +269,11 @@ func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID u
|
|||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
if ws.AutostopSchedule == "" {
|
||||
if ws.TTL == nil || *ws.TTL == 0 {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
sched, err := schedule.Weekly(ws.AutostopSchedule)
|
||||
if err != nil {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
deadline = sched.Next(now)
|
||||
deadline = ws.LatestBuild.UpdatedAt.Add(*ws.TTL)
|
||||
callback = func() {
|
||||
ttl := deadline.Sub(now)
|
||||
var title, body string
|
||||
|
|
|
@ -0,0 +1,144 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"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)
|
||||
}
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current org: %w", err)
|
||||
}
|
||||
|
||||
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace: %w", err)
|
||||
}
|
||||
|
||||
if workspace.TTL == nil {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "not set\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", workspace.TTL)
|
||||
|
||||
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)
|
||||
}
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current org: %w", err)
|
||||
}
|
||||
|
||||
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, 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)
|
||||
}
|
||||
|
||||
err = client.UpdateWorkspaceTTL(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTL: &truncated,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace ttl: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current org: %w", err)
|
||||
}
|
||||
|
||||
workspace, err := client.WorkspaceByOwnerAndName(cmd.Context(), organization.ID, codersdk.Me, args[0])
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get workspace: %w", err)
|
||||
}
|
||||
|
||||
err = client.UpdateWorkspaceTTL(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTL: nil,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace ttl: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(cmd.OutOrStdout(), "ttl unset\n", workspace.Name)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
|
@ -3,9 +3,9 @@ package cli_test
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
|
@ -14,7 +14,7 @@ import (
|
|||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func TestAutostop(t *testing.T) {
|
||||
func TestTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ShowOK", func(t *testing.T) {
|
||||
|
@ -29,13 +29,13 @@ func TestAutostop(t *testing.T) {
|
|||
_ = 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{"autostop", "show", workspace.Name}
|
||||
sched = "CRON_TZ=Europe/Dublin 30 17 * * 1-5"
|
||||
cmdArgs = []string{"ttl", "show", workspace.Name}
|
||||
ttl = 8*time.Hour + 30*time.Minute + 30*time.Second
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
err := client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
|
||||
Schedule: sched,
|
||||
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTL: &ttl,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
@ -45,11 +45,10 @@ func TestAutostop(t *testing.T) {
|
|||
|
||||
err = cmd.Execute()
|
||||
require.NoError(t, err, "unexpected error")
|
||||
// CRON_TZ gets stripped
|
||||
require.Contains(t, stdoutBuf.String(), "schedule: 30 17 * * 1-5")
|
||||
require.Equal(t, ttl.Truncate(time.Minute).String(), strings.TrimSpace(stdoutBuf.String()))
|
||||
})
|
||||
|
||||
t.Run("EnableDisableOK", func(t *testing.T) {
|
||||
t.Run("SetUnsetOK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
|
@ -61,8 +60,8 @@ func TestAutostop(t *testing.T) {
|
|||
_ = 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{"autostop", "enable", workspace.Name, "--minute", "30", "--hour", "17", "--days", "1-5", "--tz", "Europe/Dublin"}
|
||||
sched = "CRON_TZ=Europe/Dublin 30 17 * * 1-5"
|
||||
ttl = 8*time.Hour + 30*time.Minute + 30*time.Second
|
||||
cmdArgs = []string{"ttl", "set", workspace.Name, ttl.String()}
|
||||
stdoutBuf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
|
@ -72,65 +71,28 @@ func TestAutostop(t *testing.T) {
|
|||
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err, "unexpected error")
|
||||
require.Contains(t, stdoutBuf.String(), "will automatically stop at", "unexpected output")
|
||||
|
||||
// Ensure autostop schedule updated
|
||||
// Ensure ttl updated
|
||||
updated, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err, "fetch updated workspace")
|
||||
require.Equal(t, sched, updated.AutostopSchedule, "expected autostop schedule to be set")
|
||||
require.Equal(t, ttl.Truncate(time.Minute), *updated.TTL)
|
||||
require.Contains(t, stdoutBuf.String(), "warning: ttl rounded down")
|
||||
|
||||
// Disable schedule
|
||||
cmd, root = clitest.New(t, "autostop", "disable", workspace.Name)
|
||||
// unset schedule
|
||||
cmd, root = clitest.New(t, "ttl", "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 stop", "unexpected output")
|
||||
|
||||
// Ensure autostop schedule updated
|
||||
// Ensure ttl updated
|
||||
updated, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err, "fetch updated workspace")
|
||||
require.Empty(t, updated.AutostopSchedule, "expected autostop schedule to not be set")
|
||||
require.Nil(t, updated.TTL, "expected ttl to not be set")
|
||||
})
|
||||
|
||||
t.Run("Enable_NotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
client = coderdtest.New(t, nil)
|
||||
_ = coderdtest.NewProvisionerDaemon(t, client)
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
)
|
||||
|
||||
cmd, root := clitest.New(t, "autostop", "enable", "doesnotexist")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
err := cmd.Execute()
|
||||
require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error")
|
||||
})
|
||||
|
||||
t.Run("Disable_NotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
client = coderdtest.New(t, nil)
|
||||
_ = coderdtest.NewProvisionerDaemon(t, client)
|
||||
user = coderdtest.CreateFirstUser(t, client)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
)
|
||||
|
||||
cmd, root := clitest.New(t, "autostop", "disable", "doesnotexist")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
err := cmd.Execute()
|
||||
require.ErrorContains(t, err, "status code 403: forbidden", "unexpected error")
|
||||
})
|
||||
|
||||
t.Run("Enable_DefaultSchedule", func(t *testing.T) {
|
||||
t.Run("ZeroInvalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
|
@ -142,24 +104,72 @@ func TestAutostop(t *testing.T) {
|
|||
_ = 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{}
|
||||
)
|
||||
|
||||
// check current TZ env var
|
||||
currTz := os.Getenv("TZ")
|
||||
if currTz == "" {
|
||||
currTz = "UTC"
|
||||
}
|
||||
expectedSchedule := fmt.Sprintf("CRON_TZ=%s 0 18 * * 1-5", currTz)
|
||||
|
||||
cmd, root := clitest.New(t, "autostop", "enable", workspace.Name)
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
cmd.SetOut(stdoutBuf)
|
||||
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err, "unexpected error")
|
||||
|
||||
// Ensure nothing happened
|
||||
// Ensure ttl updated
|
||||
updated, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err, "fetch updated workspace")
|
||||
require.Equal(t, expectedSchedule, updated.AutostopSchedule, "expected default autostop schedule")
|
||||
require.Equal(t, ttl.Truncate(time.Minute), *updated.TTL)
|
||||
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), *updated.TTL)
|
||||
})
|
||||
|
||||
t.Run("Set_NotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
client = coderdtest.New(t, nil)
|
||||
_ = coderdtest.NewProvisionerDaemon(t, client)
|
||||
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 403: forbidden", "unexpected error")
|
||||
})
|
||||
|
||||
t.Run("Unset_NotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
client = coderdtest.New(t, nil)
|
||||
_ = coderdtest.NewProvisionerDaemon(t, client)
|
||||
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 403: forbidden", "unexpected error")
|
||||
})
|
||||
}
|
|
@ -123,6 +123,22 @@ func convertDiffType(left, right any) (newLeft, newRight any, changed bool) {
|
|||
|
||||
return leftStr, rightStr, true
|
||||
|
||||
case sql.NullInt64:
|
||||
var leftInt64Ptr *int64
|
||||
var rightInt64Ptr *int64
|
||||
if !typed.Valid {
|
||||
leftInt64Ptr = nil
|
||||
} else {
|
||||
leftInt64Ptr = ptr(typed.Int64)
|
||||
}
|
||||
|
||||
rightInt64Ptr = ptr(right.(sql.NullInt64).Int64)
|
||||
if !right.(sql.NullInt64).Valid {
|
||||
rightInt64Ptr = nil
|
||||
}
|
||||
|
||||
return leftInt64Ptr, rightInt64Ptr, true
|
||||
|
||||
default:
|
||||
return left, right, false
|
||||
}
|
||||
|
@ -147,3 +163,7 @@ func derefPointer(ptr reflect.Value) reflect.Value {
|
|||
|
||||
return ptr
|
||||
}
|
||||
|
||||
func ptr[T any](x T) *T {
|
||||
return &x
|
||||
}
|
||||
|
|
|
@ -172,7 +172,7 @@ func TestDiff(t *testing.T) {
|
|||
TemplateID: uuid.UUID{3},
|
||||
Name: "rust workspace",
|
||||
AutostartSchedule: sql.NullString{String: "0 12 * * 1-5", Valid: true},
|
||||
AutostopSchedule: sql.NullString{String: "0 2 * * 2-6", Valid: true},
|
||||
Ttl: sql.NullInt64{Int64: int64(8 * time.Hour), Valid: true},
|
||||
},
|
||||
exp: audit.Map{
|
||||
"id": uuid.UUID{1}.String(),
|
||||
|
@ -180,7 +180,7 @@ func TestDiff(t *testing.T) {
|
|||
"template_id": uuid.UUID{3}.String(),
|
||||
"name": "rust workspace",
|
||||
"autostart_schedule": "0 12 * * 1-5",
|
||||
"autostop_schedule": "0 2 * * 2-6",
|
||||
"ttl": int64(28800000000000), // XXX: pq still does not support time.Duration
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -194,7 +194,7 @@ func TestDiff(t *testing.T) {
|
|||
TemplateID: uuid.UUID{3},
|
||||
Name: "rust workspace",
|
||||
AutostartSchedule: sql.NullString{},
|
||||
AutostopSchedule: sql.NullString{},
|
||||
Ttl: sql.NullInt64{},
|
||||
},
|
||||
exp: audit.Map{
|
||||
"id": uuid.UUID{1}.String(),
|
||||
|
|
|
@ -101,7 +101,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
|
|||
"deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired.
|
||||
"name": ActionTrack,
|
||||
"autostart_schedule": ActionTrack,
|
||||
"autostop_schedule": ActionTrack,
|
||||
"ttl": ActionTrack,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@ func (e *Executor) Run() {
|
|||
func (e *Executor) runOnce(t time.Time) error {
|
||||
currentTick := t.Truncate(time.Minute)
|
||||
return e.db.InTx(func(db database.Store) error {
|
||||
eligibleWorkspaces, err := db.GetWorkspacesAutostartAutostop(e.ctx)
|
||||
eligibleWorkspaces, err := db.GetWorkspacesAutostart(e.ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get eligible workspaces for autostart or autostop: %w", err)
|
||||
}
|
||||
|
@ -84,21 +84,25 @@ func (e *Executor) runOnce(t time.Time) error {
|
|||
}
|
||||
|
||||
var validTransition database.WorkspaceTransition
|
||||
var sched *schedule.Schedule
|
||||
var nextTransition time.Time
|
||||
switch priorHistory.Transition {
|
||||
case database.WorkspaceTransitionStart:
|
||||
validTransition = database.WorkspaceTransitionStop
|
||||
sched, err = schedule.Weekly(ws.AutostopSchedule.String)
|
||||
if err != nil {
|
||||
e.log.Warn(e.ctx, "workspace has invalid autostop schedule, skipping",
|
||||
if !ws.Ttl.Valid || ws.Ttl.Int64 == 0 {
|
||||
e.log.Debug(e.ctx, "invalid or zero ws ttl, skipping",
|
||||
slog.F("workspace_id", ws.ID),
|
||||
slog.F("autostart_schedule", ws.AutostopSchedule.String),
|
||||
slog.F("ttl", time.Duration(ws.Ttl.Int64)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
ttl := time.Duration(ws.Ttl.Int64)
|
||||
// Measure TTL from the time the workspace finished building.
|
||||
// Truncate to nearest minute for consistency with autostart
|
||||
// behavior, and add one minute for padding.
|
||||
nextTransition = priorHistory.UpdatedAt.Truncate(time.Minute).Add(ttl + time.Minute)
|
||||
case database.WorkspaceTransitionStop:
|
||||
validTransition = database.WorkspaceTransitionStart
|
||||
sched, err = schedule.Weekly(ws.AutostartSchedule.String)
|
||||
sched, err := schedule.Weekly(ws.AutostartSchedule.String)
|
||||
if err != nil {
|
||||
e.log.Warn(e.ctx, "workspace has invalid autostart schedule, skipping",
|
||||
slog.F("workspace_id", ws.ID),
|
||||
|
@ -106,6 +110,9 @@ func (e *Executor) runOnce(t time.Time) error {
|
|||
)
|
||||
continue
|
||||
}
|
||||
// Round down to the nearest minute, as this is the finest granularity cron supports.
|
||||
// Truncate is probably not necessary here, but doing it anyway to be sure.
|
||||
nextTransition = sched.Next(priorHistory.CreatedAt).Truncate(time.Minute)
|
||||
default:
|
||||
e.log.Debug(e.ctx, "last transition not valid for autostart or autostop",
|
||||
slog.F("workspace_id", ws.ID),
|
||||
|
@ -114,13 +121,10 @@ func (e *Executor) runOnce(t time.Time) error {
|
|||
continue
|
||||
}
|
||||
|
||||
// Round time down to the nearest minute, as this is the finest granularity cron supports.
|
||||
// Truncate is probably not necessary here, but doing it anyway to be sure.
|
||||
nextTransitionAt := sched.Next(priorHistory.CreatedAt).Truncate(time.Minute)
|
||||
if currentTick.Before(nextTransitionAt) {
|
||||
if currentTick.Before(nextTransition) {
|
||||
e.log.Debug(e.ctx, "skipping workspace: too early",
|
||||
slog.F("workspace_id", ws.ID),
|
||||
slog.F("next_transition_at", nextTransitionAt),
|
||||
slog.F("next_transition_at", nextTransition),
|
||||
slog.F("transition", validTransition),
|
||||
slog.F("current_tick", currentTick),
|
||||
)
|
||||
|
|
|
@ -194,27 +194,27 @@ func TestExecutorAutostopOK(t *testing.T) {
|
|||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
ttl = time.Minute
|
||||
)
|
||||
// Given: workspace is running
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
|
||||
|
||||
// Given: the workspace initially has autostop disabled
|
||||
require.Empty(t, workspace.AutostopSchedule)
|
||||
require.Nil(t, workspace.TTL)
|
||||
|
||||
// When: we enable workspace autostop
|
||||
sched, err := schedule.Weekly("* * * * *")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
|
||||
Schedule: sched.String(),
|
||||
require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTL: &ttl,
|
||||
}))
|
||||
|
||||
// When: the autobuild executor ticks
|
||||
// When: the autobuild executor ticks *after* the TTL:
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(time.Minute)
|
||||
tickCh <- time.Now().UTC().Add(ttl + time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: the workspace should be started
|
||||
// Then: the workspace should be stopped
|
||||
<-time.After(5 * time.Second)
|
||||
ws := mustWorkspace(t, client, workspace.ID)
|
||||
require.NotEqual(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected a workspace build to occur")
|
||||
|
@ -234,24 +234,24 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) {
|
|||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
ttl = time.Minute
|
||||
)
|
||||
|
||||
// Given: workspace is stopped
|
||||
workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
|
||||
|
||||
// Given: the workspace initially has autostop disabled
|
||||
require.Empty(t, workspace.AutostopSchedule)
|
||||
require.Nil(t, workspace.TTL)
|
||||
|
||||
// When: we enable workspace autostart
|
||||
sched, err := schedule.Weekly("* * * * *")
|
||||
// When: we set the TTL on the workspace
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
|
||||
Schedule: sched.String(),
|
||||
require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTL: &ttl,
|
||||
}))
|
||||
|
||||
// When: the autobuild executor ticks
|
||||
// When: the autobuild executor ticks past the TTL
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(time.Minute)
|
||||
tickCh <- time.Now().UTC().Add(ttl)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
|
@ -278,7 +278,7 @@ func TestExecutorAutostopNotEnabled(t *testing.T) {
|
|||
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
|
||||
|
||||
// Given: the workspace has autostop disabled
|
||||
require.Empty(t, workspace.AutostopSchedule)
|
||||
require.Empty(t, workspace.TTL)
|
||||
|
||||
// When: the autobuild executor ticks
|
||||
go func() {
|
||||
|
@ -308,12 +308,12 @@ func TestExecutorWorkspaceDeleted(t *testing.T) {
|
|||
)
|
||||
|
||||
// Given: the workspace initially has autostart disabled
|
||||
require.Empty(t, workspace.AutostopSchedule)
|
||||
require.Empty(t, workspace.AutostartSchedule)
|
||||
|
||||
// When: we enable workspace autostart
|
||||
sched, err := schedule.Weekly("* * * * *")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
|
||||
require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||
Schedule: sched.String(),
|
||||
}))
|
||||
|
||||
|
@ -333,7 +333,7 @@ func TestExecutorWorkspaceDeleted(t *testing.T) {
|
|||
require.Equal(t, codersdk.WorkspaceTransitionDelete, ws.LatestBuild.Transition, "expected workspace to be deleted")
|
||||
}
|
||||
|
||||
func TestExecutorWorkspaceTooEarly(t *testing.T) {
|
||||
func TestExecutorWorkspaceAutostartTooEarly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
|
@ -348,14 +348,14 @@ func TestExecutorWorkspaceTooEarly(t *testing.T) {
|
|||
)
|
||||
|
||||
// Given: the workspace initially has autostart disabled
|
||||
require.Empty(t, workspace.AutostopSchedule)
|
||||
require.Empty(t, workspace.AutostartSchedule)
|
||||
|
||||
// When: we enable workspace autostart with some time in the future
|
||||
futureTime := time.Now().Add(time.Hour)
|
||||
futureTimeCron := fmt.Sprintf("%d %d * * *", futureTime.Minute(), futureTime.Hour())
|
||||
sched, err := schedule.Weekly(futureTimeCron)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
|
||||
require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||
Schedule: sched.String(),
|
||||
}))
|
||||
|
||||
|
@ -372,6 +372,41 @@ func TestExecutorWorkspaceTooEarly(t *testing.T) {
|
|||
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
|
||||
}
|
||||
|
||||
func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
tickCh = make(chan time.Time)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
AutobuildTicker: tickCh,
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
ttl = time.Hour
|
||||
)
|
||||
|
||||
// Given: the workspace initially has TTL unset
|
||||
require.Nil(t, workspace.TTL)
|
||||
|
||||
// When: we set the TTL to some time in the distant future
|
||||
require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTL: &ttl,
|
||||
}))
|
||||
|
||||
// When: the autobuild executor ticks
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC()
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: nothing should happen
|
||||
<-time.After(5 * time.Second)
|
||||
ws := mustWorkspace(t, client, workspace.ID)
|
||||
require.Equal(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected no further workspace builds to occur")
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
|
||||
}
|
||||
|
||||
func TestExecutorAutostartMultipleOK(t *testing.T) {
|
||||
if os.Getenv("DB") == "" {
|
||||
t.Skip(`This test only really works when using a "real" database, similar to a HA setup`)
|
||||
|
|
|
@ -305,8 +305,8 @@ func New(options *Options) (http.Handler, func()) {
|
|||
r.Route("/autostart", func(r chi.Router) {
|
||||
r.Put("/", api.putWorkspaceAutostart)
|
||||
})
|
||||
r.Route("/autostop", func(r chi.Router) {
|
||||
r.Put("/", api.putWorkspaceAutostop)
|
||||
r.Route("/ttl", func(r chi.Router) {
|
||||
r.Put("/", api.putWorkspaceTTL)
|
||||
})
|
||||
r.Get("/watch", api.watchWorkspace)
|
||||
})
|
||||
|
|
|
@ -363,14 +363,14 @@ func (q *fakeQuerier) GetWorkspaceByOwnerIDAndName(_ context.Context, arg databa
|
|||
return database.Workspace{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) GetWorkspacesAutostartAutostop(_ context.Context) ([]database.Workspace, error) {
|
||||
func (q *fakeQuerier) GetWorkspacesAutostart(_ context.Context) ([]database.Workspace, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
workspaces := make([]database.Workspace, 0)
|
||||
for _, ws := range q.workspaces {
|
||||
if ws.AutostartSchedule.String != "" {
|
||||
workspaces = append(workspaces, ws)
|
||||
} else if ws.AutostopSchedule.String != "" {
|
||||
} else if ws.Ttl.Valid {
|
||||
workspaces = append(workspaces, ws)
|
||||
}
|
||||
}
|
||||
|
@ -1666,7 +1666,7 @@ func (q *fakeQuerier) UpdateWorkspaceAutostart(_ context.Context, arg database.U
|
|||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) UpdateWorkspaceAutostop(_ context.Context, arg database.UpdateWorkspaceAutostopParams) error {
|
||||
func (q *fakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateWorkspaceTTLParams) error {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
|
@ -1674,7 +1674,7 @@ func (q *fakeQuerier) UpdateWorkspaceAutostop(_ context.Context, arg database.Up
|
|||
if workspace.ID != arg.ID {
|
||||
continue
|
||||
}
|
||||
workspace.AutostopSchedule = arg.AutostopSchedule
|
||||
workspace.Ttl = arg.Ttl
|
||||
q.workspaces[index] = workspace
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -314,7 +314,7 @@ CREATE TABLE workspaces (
|
|||
deleted boolean DEFAULT false NOT NULL,
|
||||
name character varying(64) NOT NULL,
|
||||
autostart_schedule text,
|
||||
autostop_schedule text
|
||||
ttl bigint
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('public.licenses_id_seq'::regclass);
|
||||
|
@ -483,4 +483,3 @@ ALTER TABLE ONLY workspaces
|
|||
|
||||
ALTER TABLE ONLY workspaces
|
||||
ADD CONSTRAINT workspaces_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE RESTRICT;
|
||||
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE ONLY workspaces DROP COLUMN ttl;
|
||||
ALTER TABLE ONLY workspaces ADD COLUMN autostop_schedule text DEFAULT NULL;
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE ONLY workspaces DROP COLUMN autostop_schedule;
|
||||
ALTER TABLE ONLY workspaces ADD COLUMN ttl BIGINT DEFAULT NULL;
|
|
@ -471,7 +471,7 @@ type Workspace struct {
|
|||
Deleted bool `db:"deleted" json:"deleted"`
|
||||
Name string `db:"name" json:"name"`
|
||||
AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"`
|
||||
AutostopSchedule sql.NullString `db:"autostop_schedule" json:"autostop_schedule"`
|
||||
Ttl sql.NullInt64 `db:"ttl" json:"ttl"`
|
||||
}
|
||||
|
||||
type WorkspaceAgent struct {
|
||||
|
|
|
@ -70,7 +70,7 @@ type querier interface {
|
|||
GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error)
|
||||
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
|
||||
GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error)
|
||||
GetWorkspacesAutostartAutostop(ctx context.Context) ([]Workspace, error)
|
||||
GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error)
|
||||
GetWorkspacesByOrganizationIDs(ctx context.Context, arg GetWorkspacesByOrganizationIDsParams) ([]Workspace, error)
|
||||
GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error)
|
||||
GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error)
|
||||
|
@ -109,9 +109,9 @@ type querier interface {
|
|||
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
|
||||
UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error
|
||||
UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error
|
||||
UpdateWorkspaceAutostop(ctx context.Context, arg UpdateWorkspaceAutostopParams) error
|
||||
UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error
|
||||
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
|
||||
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error
|
||||
}
|
||||
|
||||
var _ querier = (*sqlQuerier)(nil)
|
||||
|
|
|
@ -3193,7 +3193,7 @@ func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWork
|
|||
|
||||
const getWorkspaceByID = `-- name: GetWorkspaceByID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
|
||||
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
|
||||
FROM
|
||||
workspaces
|
||||
WHERE
|
||||
|
@ -3215,14 +3215,14 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp
|
|||
&i.Deleted,
|
||||
&i.Name,
|
||||
&i.AutostartSchedule,
|
||||
&i.AutostopSchedule,
|
||||
&i.Ttl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one
|
||||
SELECT
|
||||
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
|
||||
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
|
||||
FROM
|
||||
workspaces
|
||||
WHERE
|
||||
|
@ -3250,7 +3250,7 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo
|
|||
&i.Deleted,
|
||||
&i.Name,
|
||||
&i.AutostartSchedule,
|
||||
&i.AutostopSchedule,
|
||||
&i.Ttl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -3295,23 +3295,23 @@ func (q *sqlQuerier) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, i
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const getWorkspacesAutostartAutostop = `-- name: GetWorkspacesAutostartAutostop :many
|
||||
const getWorkspacesAutostart = `-- name: GetWorkspacesAutostart :many
|
||||
SELECT
|
||||
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
|
||||
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
|
||||
FROM
|
||||
workspaces
|
||||
WHERE
|
||||
deleted = false
|
||||
AND
|
||||
(
|
||||
autostart_schedule <> ''
|
||||
(autostart_schedule IS NOT NULL AND autostart_schedule <> '')
|
||||
OR
|
||||
autostop_schedule <> ''
|
||||
(ttl IS NOT NULL AND ttl > 0)
|
||||
)
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Workspace, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getWorkspacesAutostartAutostop)
|
||||
func (q *sqlQuerier) GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getWorkspacesAutostart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -3329,7 +3329,7 @@ func (q *sqlQuerier) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Work
|
|||
&i.Deleted,
|
||||
&i.Name,
|
||||
&i.AutostartSchedule,
|
||||
&i.AutostopSchedule,
|
||||
&i.Ttl,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -3345,7 +3345,7 @@ func (q *sqlQuerier) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Work
|
|||
}
|
||||
|
||||
const getWorkspacesByOrganizationIDs = `-- name: GetWorkspacesByOrganizationIDs :many
|
||||
SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE organization_id = ANY($1 :: uuid [ ]) AND deleted = $2
|
||||
SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl FROM workspaces WHERE organization_id = ANY($1 :: uuid [ ]) AND deleted = $2
|
||||
`
|
||||
|
||||
type GetWorkspacesByOrganizationIDsParams struct {
|
||||
|
@ -3372,7 +3372,7 @@ func (q *sqlQuerier) GetWorkspacesByOrganizationIDs(ctx context.Context, arg Get
|
|||
&i.Deleted,
|
||||
&i.Name,
|
||||
&i.AutostartSchedule,
|
||||
&i.AutostopSchedule,
|
||||
&i.Ttl,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -3389,7 +3389,7 @@ func (q *sqlQuerier) GetWorkspacesByOrganizationIDs(ctx context.Context, arg Get
|
|||
|
||||
const getWorkspacesByTemplateID = `-- name: GetWorkspacesByTemplateID :many
|
||||
SELECT
|
||||
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
|
||||
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
|
||||
FROM
|
||||
workspaces
|
||||
WHERE
|
||||
|
@ -3421,7 +3421,7 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorks
|
|||
&i.Deleted,
|
||||
&i.Name,
|
||||
&i.AutostartSchedule,
|
||||
&i.AutostopSchedule,
|
||||
&i.Ttl,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -3438,7 +3438,7 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorks
|
|||
|
||||
const getWorkspacesWithFilter = `-- name: GetWorkspacesWithFilter :many
|
||||
SELECT
|
||||
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
|
||||
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
|
||||
FROM
|
||||
workspaces
|
||||
WHERE
|
||||
|
@ -3483,7 +3483,7 @@ func (q *sqlQuerier) GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspa
|
|||
&i.Deleted,
|
||||
&i.Name,
|
||||
&i.AutostartSchedule,
|
||||
&i.AutostopSchedule,
|
||||
&i.Ttl,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -3510,7 +3510,7 @@ INSERT INTO
|
|||
name
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
|
||||
($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
|
||||
`
|
||||
|
||||
type InsertWorkspaceParams struct {
|
||||
|
@ -3544,7 +3544,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar
|
|||
&i.Deleted,
|
||||
&i.Name,
|
||||
&i.AutostartSchedule,
|
||||
&i.AutostopSchedule,
|
||||
&i.Ttl,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -3568,25 +3568,6 @@ func (q *sqlQuerier) UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWor
|
|||
return err
|
||||
}
|
||||
|
||||
const updateWorkspaceAutostop = `-- name: UpdateWorkspaceAutostop :exec
|
||||
UPDATE
|
||||
workspaces
|
||||
SET
|
||||
autostop_schedule = $2
|
||||
WHERE
|
||||
id = $1
|
||||
`
|
||||
|
||||
type UpdateWorkspaceAutostopParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
AutostopSchedule sql.NullString `db:"autostop_schedule" json:"autostop_schedule"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateWorkspaceAutostop(ctx context.Context, arg UpdateWorkspaceAutostopParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateWorkspaceAutostop, arg.ID, arg.AutostopSchedule)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWorkspaceDeletedByID = `-- name: UpdateWorkspaceDeletedByID :exec
|
||||
UPDATE
|
||||
workspaces
|
||||
|
@ -3605,3 +3586,22 @@ func (q *sqlQuerier) UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateW
|
|||
_, err := q.db.ExecContext(ctx, updateWorkspaceDeletedByID, arg.ID, arg.Deleted)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec
|
||||
UPDATE
|
||||
workspaces
|
||||
SET
|
||||
ttl = $2
|
||||
WHERE
|
||||
id = $1
|
||||
`
|
||||
|
||||
type UpdateWorkspaceTTLParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Ttl sql.NullInt64 `db:"ttl" json:"ttl"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateWorkspaceTTL, arg.ID, arg.Ttl)
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -33,7 +33,7 @@ WHERE
|
|||
-- name: GetWorkspacesByOrganizationIDs :many
|
||||
SELECT * FROM workspaces WHERE organization_id = ANY(@ids :: uuid [ ]) AND deleted = @deleted;
|
||||
|
||||
-- name: GetWorkspacesAutostartAutostop :many
|
||||
-- name: GetWorkspacesAutostart :many
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
|
@ -42,9 +42,9 @@ WHERE
|
|||
deleted = false
|
||||
AND
|
||||
(
|
||||
autostart_schedule <> ''
|
||||
(autostart_schedule IS NOT NULL AND autostart_schedule <> '')
|
||||
OR
|
||||
autostop_schedule <> ''
|
||||
(ttl IS NOT NULL AND ttl > 0)
|
||||
);
|
||||
|
||||
-- name: GetWorkspacesByTemplateID :many
|
||||
|
@ -107,10 +107,10 @@ SET
|
|||
WHERE
|
||||
id = $1;
|
||||
|
||||
-- name: UpdateWorkspaceAutostop :exec
|
||||
-- name: UpdateWorkspaceTTL :exec
|
||||
UPDATE
|
||||
workspaces
|
||||
SET
|
||||
autostop_schedule = $2
|
||||
ttl = $2
|
||||
WHERE
|
||||
id = $1;
|
||||
|
|
|
@ -547,38 +547,32 @@ func (api *api) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func (api *api) putWorkspaceAutostop(rw http.ResponseWriter, r *http.Request) {
|
||||
func (api *api) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
|
||||
workspace := httpmw.WorkspaceParam(r)
|
||||
if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceWorkspace.
|
||||
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
|
||||
return
|
||||
}
|
||||
|
||||
var req codersdk.UpdateWorkspaceAutostopRequest
|
||||
var req codersdk.UpdateWorkspaceTTLRequest
|
||||
if !httpapi.Read(rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
var dbSched sql.NullString
|
||||
if req.Schedule != "" {
|
||||
validSched, err := schedule.Weekly(req.Schedule)
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
|
||||
Message: fmt.Sprintf("invalid autostop schedule: %s", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
dbSched.String = validSched.String()
|
||||
dbSched.Valid = true
|
||||
var dbTTL sql.NullInt64
|
||||
if req.TTL != nil && *req.TTL > 0 {
|
||||
truncated := req.TTL.Truncate(time.Minute)
|
||||
dbTTL.Int64 = int64(truncated)
|
||||
dbTTL.Valid = true
|
||||
}
|
||||
|
||||
err := api.Database.UpdateWorkspaceAutostop(r.Context(), database.UpdateWorkspaceAutostopParams{
|
||||
ID: workspace.ID,
|
||||
AutostopSchedule: dbSched,
|
||||
err := api.Database.UpdateWorkspaceTTL(r.Context(), database.UpdateWorkspaceTTLParams{
|
||||
ID: workspace.ID,
|
||||
Ttl: dbTTL,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
|
||||
Message: fmt.Sprintf("update workspace autostop schedule: %s", err),
|
||||
Message: fmt.Sprintf("update workspace ttl: %s", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@ -777,6 +771,14 @@ func convertWorkspace(workspace database.Workspace, workspaceBuild codersdk.Work
|
|||
Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(),
|
||||
Name: workspace.Name,
|
||||
AutostartSchedule: workspace.AutostartSchedule.String,
|
||||
AutostopSchedule: workspace.AutostopSchedule.String,
|
||||
TTL: convertSQLNullInt64(workspace.Ttl),
|
||||
}
|
||||
}
|
||||
|
||||
func convertSQLNullInt64(i sql.NullInt64) *time.Duration {
|
||||
if !i.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (*time.Duration)(&i.Int64)
|
||||
}
|
||||
|
|
|
@ -551,69 +551,21 @@ func TestWorkspaceUpdateAutostart(t *testing.T) {
|
|||
|
||||
func TestWorkspaceUpdateAutostop(t *testing.T) {
|
||||
t.Parallel()
|
||||
var dublinLoc = mustLocation(t, "Europe/Dublin")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
schedule string
|
||||
expectedError string
|
||||
at time.Time
|
||||
expectedNext time.Time
|
||||
expectedInterval time.Duration
|
||||
name string
|
||||
ttl *time.Duration
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "disable autostop",
|
||||
schedule: "",
|
||||
name: "disable ttl",
|
||||
ttl: nil,
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "friday to monday",
|
||||
schedule: "CRON_TZ=Europe/Dublin 30 17 * * 1-5",
|
||||
expectedError: "",
|
||||
at: time.Date(2022, 5, 6, 17, 31, 0, 0, dublinLoc),
|
||||
expectedNext: time.Date(2022, 5, 9, 17, 30, 0, 0, dublinLoc),
|
||||
expectedInterval: 71*time.Hour + 59*time.Minute,
|
||||
},
|
||||
{
|
||||
name: "monday to tuesday",
|
||||
schedule: "CRON_TZ=Europe/Dublin 30 17 * * 1-5",
|
||||
expectedError: "",
|
||||
at: time.Date(2022, 5, 9, 17, 31, 0, 0, dublinLoc),
|
||||
expectedNext: time.Date(2022, 5, 10, 17, 30, 0, 0, dublinLoc),
|
||||
expectedInterval: 23*time.Hour + 59*time.Minute,
|
||||
},
|
||||
{
|
||||
// DST in Ireland began on Mar 27 in 2022 at 0100. Forward 1 hour.
|
||||
name: "DST start",
|
||||
schedule: "CRON_TZ=Europe/Dublin 30 17 * * *",
|
||||
expectedError: "",
|
||||
at: time.Date(2022, 3, 26, 17, 31, 0, 0, dublinLoc),
|
||||
expectedNext: time.Date(2022, 3, 27, 17, 30, 0, 0, dublinLoc),
|
||||
expectedInterval: 22*time.Hour + 59*time.Minute,
|
||||
},
|
||||
{
|
||||
// DST in Ireland ends on Oct 30 in 2022 at 0200. Back 1 hour.
|
||||
name: "DST end",
|
||||
schedule: "CRON_TZ=Europe/Dublin 30 17 * * *",
|
||||
expectedError: "",
|
||||
at: time.Date(2022, 10, 29, 17, 31, 0, 0, dublinLoc),
|
||||
expectedNext: time.Date(2022, 10, 30, 17, 30, 0, 0, dublinLoc),
|
||||
expectedInterval: 24*time.Hour + 59*time.Minute,
|
||||
},
|
||||
{
|
||||
name: "invalid location",
|
||||
schedule: "CRON_TZ=Imaginary/Place 30 17 * * 1-5",
|
||||
expectedError: "status code 500: invalid autostop schedule: parse schedule: provided bad location Imaginary/Place: unknown time zone Imaginary/Place",
|
||||
},
|
||||
{
|
||||
name: "invalid schedule",
|
||||
schedule: "asdf asdf asdf ",
|
||||
expectedError: `status code 500: invalid autostop schedule: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix`,
|
||||
},
|
||||
{
|
||||
name: "only 3 values",
|
||||
schedule: "CRON_TZ=Europe/Dublin 30 9 *",
|
||||
expectedError: `status code 500: invalid autostop schedule: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix`,
|
||||
name: "enable ttl",
|
||||
ttl: ptr(time.Hour),
|
||||
expectedError: "",
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -633,10 +585,10 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
|
|||
)
|
||||
|
||||
// ensure test invariant: new workspaces have no autostop schedule.
|
||||
require.Empty(t, workspace.AutostopSchedule, "expected newly-minted workspace to have no autstop schedule")
|
||||
require.Nil(t, workspace.TTL, "expected newly-minted workspace to have no TTL")
|
||||
|
||||
err := client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
|
||||
Schedule: testCase.schedule,
|
||||
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTL: testCase.ttl,
|
||||
})
|
||||
|
||||
if testCase.expectedError != "" {
|
||||
|
@ -649,18 +601,7 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
|
|||
updated, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err, "fetch updated workspace")
|
||||
|
||||
require.Equal(t, testCase.schedule, updated.AutostopSchedule, "expected autostop schedule to equal requested")
|
||||
|
||||
if testCase.schedule == "" {
|
||||
return
|
||||
}
|
||||
sched, err := schedule.Weekly(updated.AutostopSchedule)
|
||||
require.NoError(t, err, "parse returned schedule")
|
||||
|
||||
next := sched.Next(testCase.at)
|
||||
require.Equal(t, testCase.expectedNext, next, "unexpected next scheduled autostop time")
|
||||
interval := next.Sub(testCase.at)
|
||||
require.Equal(t, testCase.expectedInterval, interval, "unexpected interval")
|
||||
require.Equal(t, testCase.ttl, updated.TTL, "expected autostop ttl to equal requested")
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -670,12 +611,12 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
|
|||
client = coderdtest.New(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
wsid = uuid.New()
|
||||
req = codersdk.UpdateWorkspaceAutostopRequest{
|
||||
Schedule: "9 30 1-5",
|
||||
req = codersdk.UpdateWorkspaceTTLRequest{
|
||||
TTL: ptr(time.Hour),
|
||||
}
|
||||
)
|
||||
|
||||
err := client.UpdateWorkspaceAutostop(ctx, wsid, req)
|
||||
err := client.UpdateWorkspaceTTL(ctx, wsid, req)
|
||||
require.IsType(t, err, &codersdk.Error{}, "expected codersdk.Error")
|
||||
coderSDKErr, _ := err.(*codersdk.Error) //nolint:errorlint
|
||||
require.Equal(t, coderSDKErr.StatusCode(), 404, "expected status code 404")
|
||||
|
@ -683,15 +624,6 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func mustLocation(t *testing.T, location string) *time.Location {
|
||||
loc, err := time.LoadLocation(location)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load location %s: %s", location, err.Error())
|
||||
}
|
||||
|
||||
return loc
|
||||
}
|
||||
|
||||
func TestWorkspaceWatcher(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
|
@ -715,3 +647,17 @@ func TestWorkspaceWatcher(t *testing.T) {
|
|||
cancel()
|
||||
require.EqualValues(t, codersdk.Workspace{}, <-wc)
|
||||
}
|
||||
|
||||
func mustLocation(t *testing.T, location string) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation(location)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load location %s: %s", location, err.Error())
|
||||
}
|
||||
|
||||
return loc
|
||||
}
|
||||
|
||||
func ptr[T any](x T) *T {
|
||||
return &x
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ type Workspace struct {
|
|||
Outdated bool `json:"outdated"`
|
||||
Name string `json:"name"`
|
||||
AutostartSchedule string `json:"autostart_schedule"`
|
||||
AutostopSchedule string `json:"autostop_schedule"`
|
||||
TTL *time.Duration `json:"ttl"`
|
||||
}
|
||||
|
||||
// CreateWorkspaceBuildRequest provides options to update the latest workspace build.
|
||||
|
@ -157,18 +157,18 @@ func (c *Client) UpdateWorkspaceAutostart(ctx context.Context, id uuid.UUID, req
|
|||
return nil
|
||||
}
|
||||
|
||||
// UpdateWorkspaceAutostopRequest is a request to update a workspace's autostop schedule.
|
||||
type UpdateWorkspaceAutostopRequest struct {
|
||||
Schedule string `json:"schedule"`
|
||||
// UpdateWorkspaceTTLRequest is a request to update a workspace's TTL.
|
||||
type UpdateWorkspaceTTLRequest struct {
|
||||
TTL *time.Duration `json:"ttl"`
|
||||
}
|
||||
|
||||
// UpdateWorkspaceAutostop sets the autostop schedule for workspace by id.
|
||||
// If the provided schedule is empty, autostop is disabled for the workspace.
|
||||
func (c *Client) UpdateWorkspaceAutostop(ctx context.Context, id uuid.UUID, req UpdateWorkspaceAutostopRequest) error {
|
||||
path := fmt.Sprintf("/api/v2/workspaces/%s/autostop", id.String())
|
||||
// UpdateWorkspaceTTL sets the ttl for workspace by id.
|
||||
// If the provided duration is nil, autostop is disabled for the workspace.
|
||||
func (c *Client) UpdateWorkspaceTTL(ctx context.Context, id uuid.UUID, req UpdateWorkspaceTTLRequest) error {
|
||||
path := fmt.Sprintf("/api/v2/workspaces/%s/ttl", id.String())
|
||||
res, err := c.Request(ctx, http.MethodPut, path, req)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace autostop: %w", err)
|
||||
return xerrors.Errorf("update workspace ttl: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
|
|
|
@ -197,10 +197,10 @@ export const putWorkspaceAutostart = async (
|
|||
|
||||
export const putWorkspaceAutostop = async (
|
||||
workspaceID: string,
|
||||
autostop: TypesGen.UpdateWorkspaceAutostopRequest,
|
||||
ttl: TypesGen.UpdateWorkspaceTTLRequest,
|
||||
): Promise<void> => {
|
||||
const payload = JSON.stringify(autostop)
|
||||
await axios.put(`/api/v2/workspaces/${workspaceID}/autostop`, payload, {
|
||||
const payload = JSON.stringify(ttl)
|
||||
await axios.put(`/api/v2/workspaces/${workspaceID}/ttl`, payload, {
|
||||
headers: { ...CONTENT_TYPE_JSON },
|
||||
})
|
||||
}
|
||||
|
|
|
@ -291,8 +291,9 @@ export interface UpdateWorkspaceAutostartRequest {
|
|||
}
|
||||
|
||||
// From codersdk/workspaces.go:161:6
|
||||
export interface UpdateWorkspaceAutostopRequest {
|
||||
readonly schedule: string
|
||||
export interface UpdateWorkspaceTTLRequest {
|
||||
// This is likely an enum in an external package ("time.Duration")
|
||||
readonly ttl?: number
|
||||
}
|
||||
|
||||
// From codersdk/files.go:16:6
|
||||
|
@ -358,7 +359,8 @@ export interface Workspace {
|
|||
readonly outdated: boolean
|
||||
readonly name: string
|
||||
readonly autostart_schedule: string
|
||||
readonly autostop_schedule: string
|
||||
// This is likely an enum in an external package ("time.Duration")
|
||||
readonly ttl?: number
|
||||
}
|
||||
|
||||
// From codersdk/workspaceresources.go:31:6
|
||||
|
|
|
@ -50,7 +50,7 @@ export const Workspace: React.FC<WorkspaceProps> = ({
|
|||
<WorkspaceSection title="Applications">
|
||||
<Placeholder />
|
||||
</WorkspaceSection>
|
||||
<WorkspaceSchedule autostart={workspace.autostart_schedule} autostop={workspace.autostop_schedule} />
|
||||
<WorkspaceSchedule workspace={workspace} />
|
||||
<WorkspaceSection title="Dev URLs">
|
||||
<Placeholder />
|
||||
</WorkspaceSection>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Story } from "@storybook/react"
|
||||
import dayjs from "dayjs"
|
||||
import React from "react"
|
||||
import { MockWorkspaceAutostartEnabled } from "../../testHelpers/renderHelpers"
|
||||
import * as Mocks from "../../testHelpers/renderHelpers"
|
||||
import { WorkspaceSchedule, WorkspaceScheduleProps } from "./WorkspaceSchedule"
|
||||
|
||||
export default {
|
||||
|
@ -10,8 +11,66 @@ export default {
|
|||
|
||||
const Template: Story<WorkspaceScheduleProps> = (args) => <WorkspaceSchedule {...args} />
|
||||
|
||||
export const Example = Template.bind({})
|
||||
Example.args = {
|
||||
autostart: MockWorkspaceAutostartEnabled.schedule,
|
||||
autostop: "",
|
||||
export const NoTTL = Template.bind({})
|
||||
NoTTL.args = {
|
||||
workspace: {
|
||||
...Mocks.MockWorkspace,
|
||||
ttl: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
export const ShutdownSoon = Template.bind({})
|
||||
ShutdownSoon.args = {
|
||||
workspace: {
|
||||
...Mocks.MockWorkspace,
|
||||
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
transition: "start",
|
||||
updated_at: dayjs().subtract(1, "hour").toString(), // 1 hour ago
|
||||
},
|
||||
ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours
|
||||
},
|
||||
}
|
||||
|
||||
export const ShutdownLong = Template.bind({})
|
||||
ShutdownLong.args = {
|
||||
workspace: {
|
||||
...Mocks.MockWorkspace,
|
||||
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
transition: "start",
|
||||
updated_at: dayjs().toString(),
|
||||
},
|
||||
ttl: 7 * 24 * 60 * 60 * 1000 * 1_000_000, // 7 days
|
||||
},
|
||||
}
|
||||
|
||||
export const WorkspaceOffShort = Template.bind({})
|
||||
WorkspaceOffShort.args = {
|
||||
workspace: {
|
||||
...Mocks.MockWorkspace,
|
||||
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
transition: "stop",
|
||||
updated_at: dayjs().subtract(2, "days").toString(),
|
||||
},
|
||||
ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours
|
||||
},
|
||||
}
|
||||
|
||||
export const WorkspaceOffLong = Template.bind({})
|
||||
WorkspaceOffLong.args = {
|
||||
workspace: {
|
||||
...Mocks.MockWorkspace,
|
||||
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
transition: "stop",
|
||||
updated_at: dayjs().subtract(2, "days").toString(),
|
||||
},
|
||||
ttl: 2 * 365 * 24 * 60 * 60 * 1000 * 1_000_000, // 2 years
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
import Box from "@material-ui/core/Box"
|
||||
import Typography from "@material-ui/core/Typography"
|
||||
import cronstrue from "cronstrue"
|
||||
import dayjs from "dayjs"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import React from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { extractTimezone, stripTimezone } from "../../util/schedule"
|
||||
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
|
||||
|
||||
dayjs.extend(duration)
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const Language = {
|
||||
autoStartLabel: (schedule: string): string => {
|
||||
const prefix = "Workspace start"
|
||||
const prefix = "Start"
|
||||
|
||||
if (schedule) {
|
||||
return `${prefix} (${extractTimezone(schedule)})`
|
||||
|
@ -15,26 +22,37 @@ const Language = {
|
|||
return prefix
|
||||
}
|
||||
},
|
||||
autoStopLabel: (schedule: string): string => {
|
||||
const prefix = "Workspace shutdown"
|
||||
|
||||
if (schedule) {
|
||||
return `${prefix} (${extractTimezone(schedule)})`
|
||||
} else {
|
||||
return prefix
|
||||
}
|
||||
},
|
||||
cronHumanDisplay: (schedule: string): string => {
|
||||
autoStartDisplay: (schedule: string): string => {
|
||||
if (schedule) {
|
||||
return cronstrue.toString(stripTimezone(schedule), { throwExceptionOnParseError: false })
|
||||
}
|
||||
return "Manual"
|
||||
},
|
||||
autoStopLabel: "Shutdown",
|
||||
autoStopDisplay: (workspace: TypesGen.Workspace): string => {
|
||||
const latest = workspace.latest_build
|
||||
|
||||
if (!workspace.ttl || workspace.ttl < 1) {
|
||||
return "Manual"
|
||||
}
|
||||
|
||||
if (latest.transition === "start") {
|
||||
const now = dayjs()
|
||||
const updatedAt = dayjs(latest.updated_at)
|
||||
const deadline = updatedAt.add(workspace.ttl / 1_000_000, "ms")
|
||||
if (now.isAfter(deadline)) {
|
||||
return "workspace is shutting down now"
|
||||
}
|
||||
return now.to(deadline)
|
||||
}
|
||||
|
||||
const duration = dayjs.duration(workspace.ttl / 1_000_000, "milliseconds")
|
||||
return `${duration.humanize()} after start`
|
||||
},
|
||||
}
|
||||
|
||||
export interface WorkspaceScheduleProps {
|
||||
autostart: string
|
||||
autostop: string
|
||||
workspace: TypesGen.Workspace
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -42,17 +60,17 @@ export interface WorkspaceScheduleProps {
|
|||
*
|
||||
* @remarks Visual Component
|
||||
*/
|
||||
export const WorkspaceSchedule: React.FC<WorkspaceScheduleProps> = ({ autostart, autostop }) => {
|
||||
export const WorkspaceSchedule: React.FC<WorkspaceScheduleProps> = ({ workspace }) => {
|
||||
return (
|
||||
<WorkspaceSection title="Workspace schedule">
|
||||
<Box mt={2}>
|
||||
<Typography variant="h6">{Language.autoStartLabel(autostart)}</Typography>
|
||||
<Typography>{Language.cronHumanDisplay(autostart)}</Typography>
|
||||
<Typography variant="h6">{Language.autoStartLabel(workspace.autostart_schedule)}</Typography>
|
||||
<Typography>{Language.autoStartDisplay(workspace.autostart_schedule)}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box mt={2}>
|
||||
<Typography variant="h6">{Language.autoStopLabel(autostop)}</Typography>
|
||||
<Typography>{Language.cronHumanDisplay(autostop)}</Typography>
|
||||
<Typography variant="h6">{Language.autoStopLabel}</Typography>
|
||||
<Typography data-chromatic="ignore">{Language.autoStopDisplay(workspace)}</Typography>
|
||||
</Box>
|
||||
</WorkspaceSection>
|
||||
)
|
||||
|
|
|
@ -100,15 +100,6 @@ export const MockWorkspaceAutostartEnabled: TypesGen.UpdateWorkspaceAutostartReq
|
|||
schedule: "CRON_TZ=Canada/Eastern 30 9 * * 1-5",
|
||||
}
|
||||
|
||||
export const MockWorkspaceAutostopDisabled: TypesGen.UpdateWorkspaceAutostartRequest = {
|
||||
schedule: "",
|
||||
}
|
||||
|
||||
export const MockWorkspaceAutostopEnabled: TypesGen.UpdateWorkspaceAutostartRequest = {
|
||||
// Runs at 9:30pm Monday through Friday using America/Toronto
|
||||
schedule: "CRON_TZ=America/Toronto 30 21 * * 1-5",
|
||||
}
|
||||
|
||||
export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
|
||||
build_number: 1,
|
||||
created_at: "2022-05-17T17:39:01.382927298Z",
|
||||
|
@ -147,7 +138,7 @@ export const MockWorkspace: TypesGen.Workspace = {
|
|||
owner_id: MockUser.id,
|
||||
owner_name: MockUser.username,
|
||||
autostart_schedule: MockWorkspaceAutostartEnabled.schedule,
|
||||
autostop_schedule: MockWorkspaceAutostopEnabled.schedule,
|
||||
ttl: 2 * 60 * 60 * 1000 * 1_000_000, // 2 hours as nanoseconds
|
||||
latest_build: MockWorkspaceBuild,
|
||||
}
|
||||
|
||||
|
|
|
@ -100,7 +100,7 @@ export const handlers = [
|
|||
rest.put("/api/v2/workspaces/:workspaceId/autostart", async (req, res, ctx) => {
|
||||
return res(ctx.status(200))
|
||||
}),
|
||||
rest.put("/api/v2/workspaces/:workspaceId/autostop", async (req, res, ctx) => {
|
||||
rest.put("/api/v2/workspaces/:workspaceId/ttl", async (req, res, ctx) => {
|
||||
return res(ctx.status(200))
|
||||
}),
|
||||
rest.post("/api/v2/workspaces/:workspaceId/builds", async (req, res, ctx) => {
|
||||
|
|
Loading…
Reference in New Issue