From d72c45e48314aaf7b33fd9c203a3983a4a788500 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 19 May 2022 20:09:27 +0100 Subject: [PATCH] refactor: workspace autostop_schedule -> ttl (#1578) Co-authored-by: G r e y --- agent/usershell/usershell_darwin.go | 2 +- cli/autostop.go | 167 ------------------ cli/list.go | 8 +- cli/root.go | 2 +- cli/ssh.go | 10 +- cli/ttl.go | 144 +++++++++++++++ cli/{autostop_test.go => ttl_test.go} | 144 ++++++++------- coderd/audit/diff.go | 20 +++ coderd/audit/diff_test.go | 6 +- coderd/audit/table.go | 2 +- .../autobuild/executor/lifecycle_executor.go | 28 +-- .../executor/lifecycle_executor_test.go | 75 +++++--- coderd/coderd.go | 4 +- coderd/database/databasefake/databasefake.go | 8 +- coderd/database/dump.sql | 3 +- .../migrations/000013_autostop_ttl.down.sql | 2 + .../migrations/000013_autostop_ttl.up.sql | 2 + coderd/database/models.go | 2 +- coderd/database/querier.go | 4 +- coderd/database/queries.sql.go | 76 ++++---- coderd/database/queries/workspaces.sql | 10 +- coderd/workspaces.go | 38 ++-- coderd/workspaces_test.go | 112 +++--------- codersdk/workspaces.go | 18 +- site/src/api/api.ts | 6 +- site/src/api/typesGenerated.ts | 8 +- site/src/components/Workspace/Workspace.tsx | 2 +- .../WorkspaceSchedule.stories.tsx | 69 +++++++- .../WorkspaceSchedule/WorkspaceSchedule.tsx | 54 ++++-- site/src/testHelpers/entities.ts | 11 +- site/src/testHelpers/handlers.ts | 2 +- 31 files changed, 549 insertions(+), 490 deletions(-) delete mode 100644 cli/autostop.go create mode 100644 cli/ttl.go rename cli/{autostop_test.go => ttl_test.go} (66%) create mode 100644 coderd/database/migrations/000013_autostop_ttl.down.sql create mode 100644 coderd/database/migrations/000013_autostop_ttl.up.sql diff --git a/agent/usershell/usershell_darwin.go b/agent/usershell/usershell_darwin.go index ea0fbedfdb..532474f628 100644 --- a/agent/usershell/usershell_darwin.go +++ b/agent/usershell/usershell_darwin.go @@ -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 } diff --git a/cli/autostop.go b/cli/autostop.go deleted file mode 100644 index 5805b4516e..0000000000 --- a/cli/autostop.go +++ /dev/null @@ -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 ", - 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 ", - 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 ", - 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 ", - 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 - }, - } -} diff --git a/cli/list.go b/cli/list.go index 4d4b95fc6b..9abc68e9df 100644 --- a/cli/list.go +++ b/cli/list.go @@ -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] diff --git a/cli/root.go b/cli/root.go index 424ec54155..c2c9a9d47c 100644 --- a/cli/root.go +++ b/cli/root.go @@ -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(), diff --git a/cli/ssh.go b/cli/ssh.go index 119d284466..cb924ceed2 100644 --- a/cli/ssh.go +++ b/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 diff --git a/cli/ttl.go b/cli/ttl.go new file mode 100644 index 0000000000..29417ff943 --- /dev/null +++ b/cli/ttl.go @@ -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 ", + 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 ", + 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 ", + 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 + }, + } +} diff --git a/cli/autostop_test.go b/cli/ttl_test.go similarity index 66% rename from cli/autostop_test.go rename to cli/ttl_test.go index 14447ac037..46c48b8770 100644 --- a/cli/autostop_test.go +++ b/cli/ttl_test.go @@ -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") }) } diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 1a844f2551..eb500891b9 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -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 +} diff --git a/coderd/audit/diff_test.go b/coderd/audit/diff_test.go index 21fa6499a4..50bffa3b0d 100644 --- a/coderd/audit/diff_test.go +++ b/coderd/audit/diff_test.go @@ -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(), diff --git a/coderd/audit/table.go b/coderd/audit/table.go index f7edadbcf2..0a2f9c1795 100644 --- a/coderd/audit/table.go +++ b/coderd/audit/table.go @@ -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, }, }) diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index f402e7cedc..a6c004fa2d 100644 --- a/coderd/autobuild/executor/lifecycle_executor.go +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -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), ) diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go index 96a7660e4f..a0aa20dc3b 100644 --- a/coderd/autobuild/executor/lifecycle_executor_test.go +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -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`) diff --git a/coderd/coderd.go b/coderd/coderd.go index 3680040d05..a7e1ea2021 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) }) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index e876a54fda..1a714b90b3 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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 } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f1041e3519..40362564c1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -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; - diff --git a/coderd/database/migrations/000013_autostop_ttl.down.sql b/coderd/database/migrations/000013_autostop_ttl.down.sql new file mode 100644 index 0000000000..c4bfbd3b49 --- /dev/null +++ b/coderd/database/migrations/000013_autostop_ttl.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE ONLY workspaces DROP COLUMN ttl; +ALTER TABLE ONLY workspaces ADD COLUMN autostop_schedule text DEFAULT NULL; diff --git a/coderd/database/migrations/000013_autostop_ttl.up.sql b/coderd/database/migrations/000013_autostop_ttl.up.sql new file mode 100644 index 0000000000..d63b4431f2 --- /dev/null +++ b/coderd/database/migrations/000013_autostop_ttl.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE ONLY workspaces DROP COLUMN autostop_schedule; +ALTER TABLE ONLY workspaces ADD COLUMN ttl BIGINT DEFAULT NULL; diff --git a/coderd/database/models.go b/coderd/database/models.go index a705a02410..3fcf947bf4 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -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 { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 387a2c9a06..db061cfcb3 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5985afbf33..607ae0af8a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 +} diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index eb87ad9a51..5e8cdaa107 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -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; diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 471560c4d9..b9e7be07da 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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) +} diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index af43c6efdf..a11e6c2fc3 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -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= 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= 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 +} diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index d9080d876d..a8b39d4dc1 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -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 { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b2e47b628f..5f5786e9e4 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -197,10 +197,10 @@ export const putWorkspaceAutostart = async ( export const putWorkspaceAutostop = async ( workspaceID: string, - autostop: TypesGen.UpdateWorkspaceAutostopRequest, + ttl: TypesGen.UpdateWorkspaceTTLRequest, ): Promise => { - 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 }, }) } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3a64d14942..9a2c746757 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 5f94b74946..82fb546bc5 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -50,7 +50,7 @@ export const Workspace: React.FC = ({ - + diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx index c250580ae2..8c64ba563b 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx @@ -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 = (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 + }, } diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx index 240f1b7357..71d4bf6cd5 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx @@ -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 = ({ autostart, autostop }) => { +export const WorkspaceSchedule: React.FC = ({ workspace }) => { return ( - {Language.autoStartLabel(autostart)} - {Language.cronHumanDisplay(autostart)} + {Language.autoStartLabel(workspace.autostart_schedule)} + {Language.autoStartDisplay(workspace.autostart_schedule)} - {Language.autoStopLabel(autostop)} - {Language.cronHumanDisplay(autostop)} + {Language.autoStopLabel} + {Language.autoStopDisplay(workspace)} ) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index e34b268f22..fc67c169c1 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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, } diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index be6f201a3c..293b117077 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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) => {