diff --git a/cli/bump.go b/cli/bump.go new file mode 100644 index 0000000000000..f539c8a1eabad --- /dev/null +++ b/cli/bump.go @@ -0,0 +1,93 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/codersdk" +) + +const ( + bumpDescriptionLong = `To extend the autostop deadline for a workspace. +If no unit is specified in the duration, we assume minutes.` + defaultBumpDuration = 90 * time.Minute +) + +func bump() *cobra.Command { + bumpCmd := &cobra.Command{ + Args: cobra.RangeArgs(1, 2), + Annotations: workspaceCommand, + Use: "bump [duration]", + Short: "Extend the autostop deadline for a workspace.", + Long: bumpDescriptionLong, + Example: "coder bump my-workspace 90m", + RunE: func(cmd *cobra.Command, args []string) error { + bumpDuration := defaultBumpDuration + if len(args) > 1 { + d, err := tryParseDuration(args[1]) + if err != nil { + return err + } + bumpDuration = d + } + + if bumpDuration < time.Minute { + return xerrors.New("minimum bump duration is 1 minute") + } + + 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.LatestBuild.Deadline.IsZero() { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "no deadline set\n") + return nil + } + + newDeadline := workspace.LatestBuild.Deadline.Add(bumpDuration) + if err := client.PutExtendWorkspace(cmd.Context(), workspace.ID, codersdk.PutExtendWorkspaceRequest{ + Deadline: newDeadline, + }); err != nil { + return err + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Workspace %q will now stop at %s\n", workspace.Name, newDeadline.Format(time.RFC3339)) + + return nil + }, + } + + return bumpCmd +} + +func tryParseDuration(raw string) (time.Duration, error) { + // If the user input a raw number, assume minutes + if isDigit(raw) { + raw = raw + "m" + } + d, err := time.ParseDuration(raw) + if err != nil { + return 0, err + } + return d, nil +} + +func isDigit(s string) bool { + return strings.IndexFunc(s, func(c rune) bool { + return c < '0' || c > '9' + }) == -1 +} diff --git a/cli/bump_test.go b/cli/bump_test.go new file mode 100644 index 0000000000000..dd54eccbe770c --- /dev/null +++ b/cli/bump_test.go @@ -0,0 +1,218 @@ +package cli_test + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" +) + +func TestBump(t *testing.T) { + t.Parallel() + + t.Run("BumpOKDefault", func(t *testing.T) { + t.Parallel() + + // Given: we have a workspace + var ( + err error + ctx = context.Background() + client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + cmdArgs = []string{"bump", workspace.Name} + stdoutBuf = &bytes.Buffer{} + ) + + // Given: we wait for the workspace to be built + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + expectedDeadline := workspace.LatestBuild.Deadline.Add(90 * time.Minute) + + // Assert test invariant: workspace build has a deadline set equal to now plus ttl + require.WithinDuration(t, workspace.LatestBuild.Deadline, time.Now().Add(*workspace.TTL), time.Minute) + require.NoError(t, err) + + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + // When: we execute `coder bump ` + err = cmd.ExecuteContext(ctx) + require.NoError(t, err, "unexpected error") + + // Then: the deadline of the latest build is updated + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline, time.Minute) + }) + + t.Run("BumpSpecificDuration", func(t *testing.T) { + t.Parallel() + + // Given: we have a workspace + var ( + err error + ctx = context.Background() + client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + cmdArgs = []string{"bump", workspace.Name, "30"} + stdoutBuf = &bytes.Buffer{} + ) + + // Given: we wait for the workspace to be built + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + expectedDeadline := workspace.LatestBuild.Deadline.Add(30 * time.Minute) + + // Assert test invariant: workspace build has a deadline set equal to now plus ttl + require.WithinDuration(t, workspace.LatestBuild.Deadline, time.Now().Add(*workspace.TTL), time.Minute) + require.NoError(t, err) + + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + // When: we execute `coder bump workspace ` + err = cmd.ExecuteContext(ctx) + require.NoError(t, err) + + // Then: the deadline of the latest build is updated assuming the units are minutes + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline, time.Minute) + }) + + t.Run("BumpInvalidDuration", func(t *testing.T) { + t.Parallel() + + // Given: we have a workspace + var ( + err error + ctx = context.Background() + client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + cmdArgs = []string{"bump", workspace.Name, "kwyjibo"} + stdoutBuf = &bytes.Buffer{} + ) + + // Given: we wait for the workspace to be built + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + + // Assert test invariant: workspace build has a deadline set equal to now plus ttl + require.WithinDuration(t, workspace.LatestBuild.Deadline, time.Now().Add(*workspace.TTL), time.Minute) + require.NoError(t, err) + + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + // When: we execute `coder bump workspace ` + err = cmd.ExecuteContext(ctx) + // Then: the command fails + require.ErrorContains(t, err, "invalid duration") + }) + + t.Run("BumpNoDeadline", func(t *testing.T) { + t.Parallel() + + // Given: we have a workspace with no deadline set + var ( + err error + ctx = context.Background() + client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TTL = nil + }) + cmdArgs = []string{"bump", workspace.Name} + stdoutBuf = &bytes.Buffer{} + ) + + // Given: we wait for the workspace to build + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + + // Assert test invariant: workspace has no TTL set + require.Zero(t, workspace.LatestBuild.Deadline) + require.NoError(t, err) + + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + // When: we execute `coder bump workspace`` + err = cmd.ExecuteContext(ctx) + require.NoError(t, err) + + // Then: nothing happens and the deadline remains unset + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Zero(t, updated.LatestBuild.Deadline) + }) + + t.Run("BumpMinimumDuration", func(t *testing.T) { + t.Parallel() + + // Given: we have a workspace with no deadline set + var ( + err error + ctx = context.Background() + client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + cmdArgs = []string{"bump", workspace.Name, "59s"} + stdoutBuf = &bytes.Buffer{} + ) + + // Given: we wait for the workspace to build + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + + // Assert test invariant: workspace build has a deadline set equal to now plus ttl + require.WithinDuration(t, workspace.LatestBuild.Deadline, time.Now().Add(*workspace.TTL), time.Minute) + require.NoError(t, err) + + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + // When: we execute `coder bump workspace 59s` + err = cmd.ExecuteContext(ctx) + require.ErrorContains(t, err, "minimum bump duration is 1 minute") + + // Then: an error is reported and the deadline remains as before + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.WithinDuration(t, workspace.LatestBuild.Deadline, updated.LatestBuild.Deadline, time.Minute) + }) +} diff --git a/cli/list.go b/cli/list.go index 9abc68e9dff5c..ad22692a5ece9 100644 --- a/cli/list.go +++ b/cli/list.go @@ -86,28 +86,6 @@ func list() *cobra.Command { } duration := time.Now().UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second) - if duration > time.Hour { - duration = duration.Truncate(time.Hour) - } - if duration > time.Minute { - duration = duration.Truncate(time.Minute) - } - days := 0 - for duration.Hours() > 24 { - days++ - duration -= 24 * time.Hour - } - durationDisplay := duration.String() - if days > 0 { - durationDisplay = fmt.Sprintf("%dd%s", days, durationDisplay) - } - if strings.HasSuffix(durationDisplay, "m0s") { - durationDisplay = durationDisplay[:len(durationDisplay)-2] - } - if strings.HasSuffix(durationDisplay, "h0m") { - durationDisplay = durationDisplay[:len(durationDisplay)-2] - } - autostartDisplay := "-" if workspace.AutostartSchedule != "" { if sched, err := schedule.Weekly(workspace.AutostartSchedule); err == nil { @@ -117,7 +95,10 @@ func list() *cobra.Command { autostopDisplay := "-" if workspace.TTL != nil { - autostopDisplay = workspace.TTL.String() + autostopDisplay = durationDisplay(*workspace.TTL) + if has, ext := hasExtension(workspace); has { + autostopDisplay += fmt.Sprintf(" (+%s)", durationDisplay(ext.Round(time.Minute))) + } } user := usersByID[workspace.OwnerID] @@ -125,7 +106,7 @@ func list() *cobra.Command { user.Username + "/" + workspace.Name, workspace.TemplateName, status, - durationDisplay, + durationDisplay(duration), workspace.Outdated, autostartDisplay, autostopDisplay, @@ -139,3 +120,47 @@ func list() *cobra.Command { "Specify a column to filter in the table.") return cmd } + +func hasExtension(ws codersdk.Workspace) (bool, time.Duration) { + if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart { + return false, 0 + } + if ws.LatestBuild.Deadline.IsZero() { + return false, 0 + } + if ws.TTL == nil { + return false, 0 + } + delta := ws.LatestBuild.Deadline.Add(-*ws.TTL).Sub(ws.LatestBuild.CreatedAt) + if delta < time.Minute { + return false, 0 + } + + return true, delta +} + +func durationDisplay(d time.Duration) string { + duration := d + if duration > time.Hour { + duration = duration.Truncate(time.Hour) + } + if duration > time.Minute { + duration = duration.Truncate(time.Minute) + } + days := 0 + for duration.Hours() > 24 { + days++ + duration -= 24 * time.Hour + } + durationDisplay := duration.String() + if days > 0 { + durationDisplay = fmt.Sprintf("%dd%s", days, durationDisplay) + } + if strings.HasSuffix(durationDisplay, "m0s") { + durationDisplay = durationDisplay[:len(durationDisplay)-2] + } + if strings.HasSuffix(durationDisplay, "h0m") { + durationDisplay = durationDisplay[:len(durationDisplay)-2] + } + return durationDisplay +} diff --git a/cli/root.go b/cli/root.go index 2d75b3410ac94..96aca0c3267e3 100644 --- a/cli/root.go +++ b/cli/root.go @@ -67,6 +67,7 @@ func Root() *cobra.Command { cmd.AddCommand( autostart(), + bump(), configSSH(), create(), delete(), diff --git a/cli/ssh.go b/cli/ssh.go index 85c3d9fd9b7b9..a808764c5bc86 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -289,17 +289,14 @@ func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID u return time.Time{}, nil } - deadline = ws.LatestBuild.UpdatedAt.Add(*ws.TTL) + deadline = ws.LatestBuild.Deadline callback = func() { ttl := deadline.Sub(now) var title, body string if ttl > time.Minute { - title = fmt.Sprintf(`Workspace %s stopping in %.0f mins`, ws.Name, ttl.Minutes()) + title = fmt.Sprintf(`Workspace %s stopping soon`, ws.Name) body = fmt.Sprintf( - `Your Coder workspace %s is scheduled to stop at %s.`, - ws.Name, - deadline.Format(time.Kitchen), - ) + `Your Coder workspace %s is scheduled to stop in %.0f mins`, ws.Name, ttl.Minutes()) } else { title = fmt.Sprintf("Workspace %s stopping!", ws.Name) body = fmt.Sprintf("Your Coder workspace %s is stopping any time now!", ws.Name) diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index b031d322116ba..831085049024a 100644 --- a/coderd/autobuild/executor/lifecycle_executor.go +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -88,7 +88,7 @@ func (e *Executor) runOnce(t time.Time) error { } if !priorJob.CompletedAt.Valid || priorJob.Error.String != "" { - e.log.Warn(e.ctx, "last workspace build did not complete successfully, skipping", + e.log.Debug(e.ctx, "last workspace build did not complete successfully, skipping", slog.F("workspace_id", ws.ID), slog.F("error", priorJob.Error.String), ) @@ -107,13 +107,14 @@ func (e *Executor) runOnce(t time.Time) error { ) continue } - // Truncate to nearest minute for consistency with autostart behavior - nextTransition = priorHistory.Deadline.Truncate(time.Minute) + // For stopping, do not truncate. This is inconsistent with autostart, but + // it ensures we will not stop too early. + nextTransition = priorHistory.Deadline case database.WorkspaceTransitionStop: validTransition = database.WorkspaceTransitionStart sched, err := schedule.Weekly(ws.AutostartSchedule.String) if err != nil { - e.log.Warn(e.ctx, "workspace has invalid autostart schedule, skipping", + e.log.Debug(e.ctx, "workspace has invalid autostart schedule, skipping", slog.F("workspace_id", ws.ID), slog.F("autostart_schedule", ws.AutostartSchedule.String), ) diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 023c238335a2b..260374ed46a89 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -550,7 +550,7 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr workspace, err := db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID) if err == nil { if workspace.Ttl.Valid { - workspaceDeadline = now.Add(time.Duration(workspace.Ttl.Int64)).Truncate(time.Minute) + workspaceDeadline = now.Add(time.Duration(workspace.Ttl.Int64)) } } else { // Huh? Did the workspace get deleted? diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 1b841414cf531..22c375e8cfc37 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -597,7 +597,7 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("workspace must be started, current status: %s", build.Transition) } - newDeadline := req.Deadline.Truncate(time.Minute).UTC() + newDeadline := req.Deadline.UTC() if newDeadline.IsZero() { // This should not be possible because the struct validation field enforces a non-zero value. code = http.StatusBadRequest @@ -609,8 +609,8 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("new deadline %q must be after existing deadline %q", newDeadline.Format(time.RFC3339), build.Deadline.Format(time.RFC3339)) } - // both newDeadline and build.Deadline are truncated to time.Minute - if newDeadline == build.Deadline { + // Disallow updates within less than one minute + if withinDuration(newDeadline, build.Deadline, time.Minute) { code = http.StatusNotModified return nil } @@ -776,6 +776,7 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data InitiatorID: workspaceBuild.InitiatorID, ProvisionerState: workspaceBuild.ProvisionerState, JobID: workspaceBuild.JobID, + Deadline: workspaceBuild.Deadline, } } templateByID := map[uuid.UUID]database.Template{} @@ -838,3 +839,12 @@ func convertSQLNullInt64(i sql.NullInt64) *time.Duration { return (*time.Duration)(&i.Int64) } + +func withinDuration(t1, t2 time.Time, d time.Duration) bool { + dt := t1.Sub(t2) + if dt < -d || dt > d { + return false + } + + return true +}