feat: allow bumping workspace deadline (#1828)

* Adds a `bump` command to extend workspace build deadline
 * Reduces WARN-level logging spam from autobuild executor
 * Modifies `cli/ssh` notifications to read from workspace build deadline and to notify relative time instead (sidestepping the problem of figuring out a user's timezone across multiple OSes)
 * Shows workspace extension time in `coder list` output e.g.
    ```
    WORKSPACE        TEMPLATE  STATUS   LAST BUILT  OUTDATED  AUTOSTART        TTL        
    developer/test1  docker    Running  4m          false     0 9 * * MON-FRI  15m (+5m)  
    ```
This commit is contained in:
Cian Johnston
2022-05-27 20:04:33 +01:00
committed by GitHub
parent bde3779fec
commit ff542afe87
8 changed files with 383 additions and 38 deletions

93
cli/bump.go Normal file
View File

@ -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 <workspace-name> [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
}

218
cli/bump_test.go Normal file
View File

@ -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 <workspace>`
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 <number without units>`
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 <not a number>`
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)
})
}

View File

@ -86,28 +86,6 @@ func list() *cobra.Command {
} }
duration := time.Now().UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second) 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 := "-" autostartDisplay := "-"
if workspace.AutostartSchedule != "" { if workspace.AutostartSchedule != "" {
if sched, err := schedule.Weekly(workspace.AutostartSchedule); err == nil { if sched, err := schedule.Weekly(workspace.AutostartSchedule); err == nil {
@ -117,7 +95,10 @@ func list() *cobra.Command {
autostopDisplay := "-" autostopDisplay := "-"
if workspace.TTL != nil { 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] user := usersByID[workspace.OwnerID]
@ -125,7 +106,7 @@ func list() *cobra.Command {
user.Username + "/" + workspace.Name, user.Username + "/" + workspace.Name,
workspace.TemplateName, workspace.TemplateName,
status, status,
durationDisplay, durationDisplay(duration),
workspace.Outdated, workspace.Outdated,
autostartDisplay, autostartDisplay,
autostopDisplay, autostopDisplay,
@ -139,3 +120,47 @@ func list() *cobra.Command {
"Specify a column to filter in the table.") "Specify a column to filter in the table.")
return cmd 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
}

View File

@ -67,6 +67,7 @@ func Root() *cobra.Command {
cmd.AddCommand( cmd.AddCommand(
autostart(), autostart(),
bump(),
configSSH(), configSSH(),
create(), create(),
delete(), delete(),

View File

@ -289,17 +289,14 @@ func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID u
return time.Time{}, nil return time.Time{}, nil
} }
deadline = ws.LatestBuild.UpdatedAt.Add(*ws.TTL) deadline = ws.LatestBuild.Deadline
callback = func() { callback = func() {
ttl := deadline.Sub(now) ttl := deadline.Sub(now)
var title, body string var title, body string
if ttl > time.Minute { 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( body = fmt.Sprintf(
`Your Coder workspace %s is scheduled to stop at %s.`, `Your Coder workspace %s is scheduled to stop in %.0f mins`, ws.Name, ttl.Minutes())
ws.Name,
deadline.Format(time.Kitchen),
)
} else { } else {
title = fmt.Sprintf("Workspace %s stopping!", ws.Name) title = fmt.Sprintf("Workspace %s stopping!", ws.Name)
body = fmt.Sprintf("Your Coder workspace %s is stopping any time now!", ws.Name) body = fmt.Sprintf("Your Coder workspace %s is stopping any time now!", ws.Name)

View File

@ -88,7 +88,7 @@ func (e *Executor) runOnce(t time.Time) error {
} }
if !priorJob.CompletedAt.Valid || priorJob.Error.String != "" { 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("workspace_id", ws.ID),
slog.F("error", priorJob.Error.String), slog.F("error", priorJob.Error.String),
) )
@ -107,13 +107,14 @@ func (e *Executor) runOnce(t time.Time) error {
) )
continue continue
} }
// Truncate to nearest minute for consistency with autostart behavior // For stopping, do not truncate. This is inconsistent with autostart, but
nextTransition = priorHistory.Deadline.Truncate(time.Minute) // it ensures we will not stop too early.
nextTransition = priorHistory.Deadline
case database.WorkspaceTransitionStop: case database.WorkspaceTransitionStop:
validTransition = database.WorkspaceTransitionStart validTransition = database.WorkspaceTransitionStart
sched, err := schedule.Weekly(ws.AutostartSchedule.String) sched, err := schedule.Weekly(ws.AutostartSchedule.String)
if err != nil { 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("workspace_id", ws.ID),
slog.F("autostart_schedule", ws.AutostartSchedule.String), slog.F("autostart_schedule", ws.AutostartSchedule.String),
) )

View File

@ -550,7 +550,7 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr
workspace, err := db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID) workspace, err := db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
if err == nil { if err == nil {
if workspace.Ttl.Valid { if workspace.Ttl.Valid {
workspaceDeadline = now.Add(time.Duration(workspace.Ttl.Int64)).Truncate(time.Minute) workspaceDeadline = now.Add(time.Duration(workspace.Ttl.Int64))
} }
} else { } else {
// Huh? Did the workspace get deleted? // Huh? Did the workspace get deleted?

View File

@ -604,7 +604,7 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
return xerrors.Errorf("workspace must be started, current status: %s", build.Transition) 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() { if newDeadline.IsZero() {
// This should not be possible because the struct validation field enforces a non-zero value. // This should not be possible because the struct validation field enforces a non-zero value.
code = http.StatusBadRequest code = http.StatusBadRequest
@ -616,8 +616,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)) 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 // Disallow updates within less than one minute
if newDeadline == build.Deadline { if withinDuration(newDeadline, build.Deadline, time.Minute) {
code = http.StatusNotModified code = http.StatusNotModified
return nil return nil
} }
@ -786,6 +786,7 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data
InitiatorID: workspaceBuild.InitiatorID, InitiatorID: workspaceBuild.InitiatorID,
ProvisionerState: workspaceBuild.ProvisionerState, ProvisionerState: workspaceBuild.ProvisionerState,
JobID: workspaceBuild.JobID, JobID: workspaceBuild.JobID,
Deadline: workspaceBuild.Deadline,
} }
} }
templateByID := map[uuid.UUID]database.Template{} templateByID := map[uuid.UUID]database.Template{}
@ -848,3 +849,12 @@ func convertSQLNullInt64(i sql.NullInt64) *time.Duration {
return (*time.Duration)(&i.Int64) 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
}