From 39a53c2ed51d47662cb5e998d28fe3e5c97ed442 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 09:59:46 +0000 Subject: [PATCH 01/14] feat: cli: add coder bump command for autostop extension --- .vscode/settings.json | 2 +- cli/bump.go | 89 +++++++++++++++++++++ cli/bump_test.go | 178 ++++++++++++++++++++++++++++++++++++++++++ cli/root.go | 1 + 4 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 cli/bump.go create mode 100644 cli/bump_test.go diff --git a/.vscode/settings.json b/.vscode/settings.json index e14c039bb7457..003d4d47c2cf2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -97,7 +97,7 @@ "go.lintTool": "golangci-lint", "go.lintFlags": ["--fast"], "go.lintOnSave": "package", - "go.coverOnSave": true, + "go.coverOnSave": false, // The codersdk is used by coderd another other packages extensively. // To reduce redundancy in tests, it's covered by other packages. "go.testFlags": ["-short", "-coverpkg=./.,github.com/coder/coder/codersdk"], diff --git a/cli/bump.go b/cli/bump.go new file mode 100644 index 0000000000000..7e319e3044375 --- /dev/null +++ b/cli/bump.go @@ -0,0 +1,89 @@ +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 workspace [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 + } + 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..f8cd950a7a532 --- /dev/null +++ b/cli/bump_test.go @@ -0,0 +1,178 @@ +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, "unexpected error") + + // 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) + + // Then: nothing happens and the deadline remains unset + require.NoError(t, err) + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Zero(t, updated.LatestBuild.Deadline) + }) +} 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(), From f764e617e08934d5428a19e3e68432ee9570b097 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 10:06:11 +0000 Subject: [PATCH 02/14] fix: cli/ssh: read autostop deadline from latestbuild.Deadline instead of from workspace TTL --- cli/ssh.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/ssh.go b/cli/ssh.go index 85c3d9fd9b7b9..8ba9442141828 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -289,7 +289,7 @@ 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 From 2a74312ab0a03d1b04e46b49068561713182a9fd Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 10:06:48 +0000 Subject: [PATCH 03/14] fix: autobuild/executor: reduce logging --- coderd/autobuild/executor/lifecycle_executor.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index b031d322116ba..7cc7ea46fe41a 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), ) @@ -113,7 +113,7 @@ func (e *Executor) runOnce(t time.Time) error { 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), ) From 6f2bef5643733256d2f1dc1f504e71a37eece1b8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 10:24:14 +0000 Subject: [PATCH 04/14] cli/bump: ensure minimum duration --- cli/bump.go | 5 +++++ cli/bump_test.go | 42 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/cli/bump.go b/cli/bump.go index 7e319e3044375..9f2e217327a5b 100644 --- a/cli/bump.go +++ b/cli/bump.go @@ -35,6 +35,11 @@ func bump() *cobra.Command { } 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) diff --git a/cli/bump_test.go b/cli/bump_test.go index f8cd950a7a532..6c0eff9e8f763 100644 --- a/cli/bump_test.go +++ b/cli/bump_test.go @@ -90,7 +90,7 @@ func TestBump(t *testing.T) { // When: we execute `coder bump workspace ` err = cmd.ExecuteContext(ctx) - require.NoError(t, err, "unexpected error") + 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) @@ -175,4 +175,44 @@ func TestBump(t *testing.T) { 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) + + // Then: an error is reported and the deadline remains as before + require.ErrorContains(t, err, "minimum bump duration is 1 minute") + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.WithinDuration(t, workspace.LatestBuild.Deadline, updated.LatestBuild.Deadline, time.Minute) + }) } From dcd57cfb6091cc47254122df85a8408d744c9b9b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 10:26:45 +0000 Subject: [PATCH 05/14] cli/ssh: notify relative time instead --- cli/ssh.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/ssh.go b/cli/ssh.go index 8ba9442141828..dbb7c1f54a31c 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -294,11 +294,11 @@ func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID u 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.`, + `Your Coder workspace %s is scheduled to stop in %s`, ws.Name, - deadline.Format(time.Kitchen), + deadline.Sub(now), ) } else { title = fmt.Sprintf("Workspace %s stopping!", ws.Name) From c31b9ddb415fba10eb50f05541664623fffae5b4 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 10:28:41 +0000 Subject: [PATCH 06/14] fixup! cli/ssh: notify relative time instead --- cli/ssh.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cli/ssh.go b/cli/ssh.go index dbb7c1f54a31c..9ae6708d9b1d8 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -296,10 +296,7 @@ func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID u if ttl > time.Minute { title = fmt.Sprintf(`Workspace %s stopping soon`, ws.Name) body = fmt.Sprintf( - `Your Coder workspace %s is scheduled to stop in %s`, - ws.Name, - deadline.Sub(now), - ) + `Your Coder workspace %s is scheduled to stop in %s`, ws.Name, ttl) } else { title = fmt.Sprintf("Workspace %s stopping!", ws.Name) body = fmt.Sprintf("Your Coder workspace %s is stopping any time now!", ws.Name) From 23247d41ddf1e6df4c7af5946381d7a4b30a3bea Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 11:52:52 +0100 Subject: [PATCH 07/14] revert accidental change --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 003d4d47c2cf2..e14c039bb7457 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -97,7 +97,7 @@ "go.lintTool": "golangci-lint", "go.lintFlags": ["--fast"], "go.lintOnSave": "package", - "go.coverOnSave": false, + "go.coverOnSave": true, // The codersdk is used by coderd another other packages extensively. // To reduce redundancy in tests, it's covered by other packages. "go.testFlags": ["-short", "-coverpkg=./.,github.com/coder/coder/codersdk"], From 41306aa28cb24d529f03138cb08289ffecf21e38 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 11:53:33 +0100 Subject: [PATCH 08/14] Update cli/bump.go --- cli/bump.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/bump.go b/cli/bump.go index 9f2e217327a5b..9a5f4061da30d 100644 --- a/cli/bump.go +++ b/cli/bump.go @@ -13,7 +13,7 @@ import ( const ( bumpDescriptionLong = `To extend the autostop deadline for a workspace. - If no unit is specified in the duration, we assume minutes. +If no unit is specified in the duration, we assume minutes. ` defaultBumpDuration = 90 * time.Minute ) From d9a795f982368f2a8a134c0e15b18928566a8157 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 12:17:12 +0100 Subject: [PATCH 09/14] Update cli/ssh.go --- cli/ssh.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/ssh.go b/cli/ssh.go index 9ae6708d9b1d8..a808764c5bc86 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -296,7 +296,7 @@ func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID u if ttl > time.Minute { title = fmt.Sprintf(`Workspace %s stopping soon`, ws.Name) body = fmt.Sprintf( - `Your Coder workspace %s is scheduled to stop in %s`, ws.Name, ttl) + `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) From 3ecb32356768c3e3281e11766aeada1c82c1de8d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 15:36:15 +0100 Subject: [PATCH 10/14] fix: coderd: add missing deadline field --- coderd/workspaces.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 1b841414cf531..861cf5869b115 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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{} From 4b868d236781b76e40aedcb3086f571fe4e8e5c9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 15:37:14 +0100 Subject: [PATCH 11/14] fix: cli/list: show extension in list output --- cli/list.go | 73 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/cli/list.go b/cli/list.go index 9abc68e9dff5c..7c5b0356fdde0 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)) + } } 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.UpdatedAt).Round(time.Minute) + if delta <= 0 { + 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 +} From cfeb27463e7a752e34e8df85d52eb3c9ae1f72a5 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 16:59:19 +0100 Subject: [PATCH 12/14] fix: less rounding of deadlines --- cli/list.go | 6 +++--- coderd/autobuild/executor/lifecycle_executor.go | 5 +++-- coderd/provisionerdaemons.go | 2 +- coderd/workspaces.go | 15 ++++++++++++--- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/cli/list.go b/cli/list.go index 7c5b0356fdde0..ad22692a5ece9 100644 --- a/cli/list.go +++ b/cli/list.go @@ -97,7 +97,7 @@ func list() *cobra.Command { if workspace.TTL != nil { autostopDisplay = durationDisplay(*workspace.TTL) if has, ext := hasExtension(workspace); has { - autostopDisplay += fmt.Sprintf(" (+%s)", durationDisplay(ext)) + autostopDisplay += fmt.Sprintf(" (+%s)", durationDisplay(ext.Round(time.Minute))) } } @@ -131,8 +131,8 @@ func hasExtension(ws codersdk.Workspace) (bool, time.Duration) { if ws.TTL == nil { return false, 0 } - delta := ws.LatestBuild.Deadline.Add(-*ws.TTL).Sub(ws.LatestBuild.UpdatedAt).Round(time.Minute) - if delta <= 0 { + delta := ws.LatestBuild.Deadline.Add(-*ws.TTL).Sub(ws.LatestBuild.CreatedAt) + if delta < time.Minute { return false, 0 } diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index 7cc7ea46fe41a..831085049024a 100644 --- a/coderd/autobuild/executor/lifecycle_executor.go +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -107,8 +107,9 @@ 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) 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 861cf5869b115..e91c973f82324 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 than one minute + if withinDuration(newDeadline, build.Deadline, time.Minute) { code = http.StatusNotModified return nil } @@ -839,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 +} From b5be82ce2b7d0b9bfc03bdc9ce3d9dc82ffca517 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 17:48:25 +0100 Subject: [PATCH 13/14] Apply suggestions from code review Co-authored-by: Steven Masley --- cli/bump.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/bump.go b/cli/bump.go index 9a5f4061da30d..f539c8a1eabad 100644 --- a/cli/bump.go +++ b/cli/bump.go @@ -13,8 +13,7 @@ import ( const ( bumpDescriptionLong = `To extend the autostop deadline for a workspace. -If no unit is specified in the duration, we assume minutes. - ` +If no unit is specified in the duration, we assume minutes.` defaultBumpDuration = 90 * time.Minute ) @@ -22,7 +21,7 @@ func bump() *cobra.Command { bumpCmd := &cobra.Command{ Args: cobra.RangeArgs(1, 2), Annotations: workspaceCommand, - Use: "bump workspace [duration]", + Use: "bump [duration]", Short: "Extend the autostop deadline for a workspace.", Long: bumpDescriptionLong, Example: "coder bump my-workspace 90m", From 9a45186d5ea79095f66a2ee74b3b7877d94d3135 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 27 May 2022 17:50:03 +0100 Subject: [PATCH 14/14] Apply suggestions from code review Co-authored-by: Mathias Fredriksson --- cli/bump_test.go | 4 ++-- coderd/workspaces.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/bump_test.go b/cli/bump_test.go index 6c0eff9e8f763..dd54eccbe770c 100644 --- a/cli/bump_test.go +++ b/cli/bump_test.go @@ -168,9 +168,9 @@ func TestBump(t *testing.T) { // When: we execute `coder bump workspace`` err = cmd.ExecuteContext(ctx) + require.NoError(t, err) // Then: nothing happens and the deadline remains unset - require.NoError(t, err) updated, err := client.Workspace(ctx, workspace.ID) require.NoError(t, err) require.Zero(t, updated.LatestBuild.Deadline) @@ -208,9 +208,9 @@ func TestBump(t *testing.T) { // 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 - require.ErrorContains(t, err, "minimum bump duration is 1 minute") 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/coderd/workspaces.go b/coderd/workspaces.go index e91c973f82324..22c375e8cfc37 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -609,7 +609,7 @@ 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)) } - // Disallow updates within than one minute + // Disallow updates within less than one minute if withinDuration(newDeadline, build.Deadline, time.Minute) { code = http.StatusNotModified return nil