Skip to content

Commit 02ad60f

Browse files
authored
fix: allow setting workspace deadline as early as now plus 30 minutes (coder#2328)
This PR makes the following changes: - coderd: /api/v2/workspaces/:workspace/extend now accepts any time at least 30 minutes in the future. - coder bump command also allows the above. Some small copy changes to command. - coder bump now actually enforces template-level maxima.
1 parent 4734636 commit 02ad60f

File tree

4 files changed

+88
-40
lines changed

4 files changed

+88
-40
lines changed

cli/bump.go

+17-8
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,26 @@ import (
88
"github.com/spf13/cobra"
99
"golang.org/x/xerrors"
1010

11+
"github.com/coder/coder/coderd/util/tz"
1112
"github.com/coder/coder/codersdk"
1213
)
1314

1415
const (
15-
bumpDescriptionLong = `To extend the autostop deadline for a workspace.`
16+
bumpDescriptionShort = `Shut your workspace down after a given duration has passed.`
17+
bumpDescriptionLong = `Modify the time at which your workspace will shut down automatically.
18+
* Provide a duration from now (for example, 1h30m).
19+
* The minimum duration is 30 minutes.
20+
* If the workspace template restricts the maximum runtime of a workspace, this will be enforced here.
21+
* If the workspace does not already have a shutdown scheduled, this does nothing.
22+
`
1623
)
1724

1825
func bump() *cobra.Command {
1926
bumpCmd := &cobra.Command{
2027
Args: cobra.RangeArgs(1, 2),
2128
Annotations: workspaceCommand,
22-
Use: "bump <workspace-name> <duration>",
23-
Short: "Extend the autostop deadline for a workspace.",
29+
Use: "bump <workspace-name> <duration from now>",
30+
Short: bumpDescriptionShort,
2431
Long: bumpDescriptionLong,
2532
Example: "coder bump my-workspace 90m",
2633
RunE: func(cmd *cobra.Command, args []string) error {
@@ -39,17 +46,20 @@ func bump() *cobra.Command {
3946
return xerrors.Errorf("get workspace: %w", err)
4047
}
4148

42-
newDeadline := time.Now().Add(bumpDuration)
49+
loc, err := tz.TimezoneIANA()
50+
if err != nil {
51+
loc = time.UTC // best effort
52+
}
4353

44-
if newDeadline.Before(workspace.LatestBuild.Deadline) {
54+
if bumpDuration < 29*time.Minute {
4555
_, _ = fmt.Fprintf(
4656
cmd.OutOrStdout(),
47-
"The proposed deadline is %s before the current deadline.\n",
48-
workspace.LatestBuild.Deadline.Sub(newDeadline).Round(time.Minute),
57+
"Please specify a duration of at least 30 minutes.\n",
4958
)
5059
return nil
5160
}
5261

62+
newDeadline := time.Now().In(loc).Add(bumpDuration)
5363
if err := client.PutExtendWorkspace(cmd.Context(), workspace.ID, codersdk.PutExtendWorkspaceRequest{
5464
Deadline: newDeadline,
5565
}); err != nil {
@@ -62,7 +72,6 @@ func bump() *cobra.Command {
6272
newDeadline.Format(timeFormat),
6373
newDeadline.Format(dateFormat),
6474
)
65-
6675
return nil
6776
},
6877
}

cli/bump_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ func TestBump(t *testing.T) {
124124
workspace, err = client.Workspace(ctx, workspace.ID)
125125
require.NoError(t, err)
126126

127-
// TODO(cian): need to stop and start the workspace as we do not update the deadline yet
128-
// see: https://github.com/coder/coder/issues/1783
127+
// NOTE(cian): need to stop and start the workspace as we do not update the deadline
128+
// see: https://github.com/coder/coder/issues/2224
129129
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
130130
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart)
131131

coderd/workspaces.go

+38-15
Original file line numberDiff line numberDiff line change
@@ -575,21 +575,47 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
575575
resp := httpapi.Response{}
576576

577577
err := api.Database.InTx(func(s database.Store) error {
578+
template, err := s.GetTemplateByID(r.Context(), workspace.TemplateID)
579+
if err != nil {
580+
code = http.StatusInternalServerError
581+
resp.Message = "Error fetching workspace template!"
582+
return xerrors.Errorf("get workspace template: %w", err)
583+
}
584+
578585
build, err := s.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
579586
if err != nil {
580587
code = http.StatusInternalServerError
581-
resp.Message = "Workspace not found."
588+
resp.Message = "Error fetching workspace build."
582589
return xerrors.Errorf("get latest workspace build: %w", err)
583590
}
584591

592+
job, err := s.GetProvisionerJobByID(r.Context(), build.JobID)
593+
if err != nil {
594+
code = http.StatusInternalServerError
595+
resp.Message = "Error fetching workspace provisioner job."
596+
return xerrors.Errorf("get provisioner job: %w", err)
597+
}
598+
585599
if build.Transition != database.WorkspaceTransitionStart {
586600
code = http.StatusConflict
587601
resp.Message = "Workspace must be started, current status: " + string(build.Transition)
588602
return xerrors.Errorf("workspace must be started, current status: %s", build.Transition)
589603
}
590604

605+
if !job.CompletedAt.Valid {
606+
code = http.StatusConflict
607+
resp.Message = "Workspace is still building!"
608+
return xerrors.Errorf("workspace is still building")
609+
}
610+
611+
if build.Deadline.IsZero() {
612+
code = http.StatusConflict
613+
resp.Message = "Workspace shutdown is manual."
614+
return xerrors.Errorf("workspace shutdown is manual")
615+
}
616+
591617
newDeadline := req.Deadline.UTC()
592-
if err := validWorkspaceDeadline(build.Deadline, newDeadline); err != nil {
618+
if err := validWorkspaceDeadline(job.CompletedAt.Time, newDeadline, time.Duration(template.MaxTtl)); err != nil {
593619
code = http.StatusBadRequest
594620
resp.Message = "Bad extend workspace request."
595621
resp.Validations = append(resp.Validations, httpapi.Error{Field: "deadline", Detail: err.Error()})
@@ -878,23 +904,20 @@ func validWorkspaceTTLMillis(millis *int64, max time.Duration) (sql.NullInt64, e
878904
}, nil
879905
}
880906

881-
func validWorkspaceDeadline(old, new time.Time) error {
882-
if old.IsZero() {
883-
return xerrors.New("nothing to do: no existing deadline set")
884-
}
885-
886-
now := time.Now()
887-
if new.Before(now) {
888-
return xerrors.New("new deadline must be in the future")
907+
func validWorkspaceDeadline(startedAt, newDeadline time.Time, max time.Duration) error {
908+
soon := time.Now().Add(29 * time.Minute)
909+
if newDeadline.Before(soon) {
910+
return xerrors.New("new deadline must be at least 30 minutes in the future")
889911
}
890912

891-
delta := new.Sub(old)
892-
if delta < time.Minute {
893-
return xerrors.New("minimum extension is one minute")
913+
// No idea how this could happen.
914+
if newDeadline.Before(startedAt) {
915+
return xerrors.Errorf("new deadline must be before workspace start time")
894916
}
895917

896-
if delta > 24*time.Hour {
897-
return xerrors.New("maximum extension is 24 hours")
918+
delta := newDeadline.Sub(startedAt)
919+
if delta > max {
920+
return xerrors.New("new deadline is greater than template allows")
898921
}
899922

900923
return nil

coderd/workspaces_test.go

+31-15
Original file line numberDiff line numberDiff line change
@@ -974,22 +974,23 @@ func TestWorkspaceUpdateTTL(t *testing.T) {
974974
func TestWorkspaceExtend(t *testing.T) {
975975
t.Parallel()
976976
var (
977+
ttl = 8 * time.Hour
978+
newDeadline = time.Now().Add(ttl + time.Hour).UTC()
977979
ctx = context.Background()
978980
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
979981
user = coderdtest.CreateFirstUser(t, client)
980982
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
981983
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
982-
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
983-
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
984-
extend = 90 * time.Minute
985-
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
986-
oldDeadline = time.Now().Add(time.Duration(*workspace.TTLMillis) * time.Millisecond).UTC()
987-
newDeadline = time.Now().Add(time.Duration(*workspace.TTLMillis)*time.Millisecond + extend).UTC()
984+
template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
985+
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
986+
cwr.TTLMillis = ptr.Ref(ttl.Milliseconds())
987+
})
988+
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
988989
)
989990

990991
workspace, err := client.Workspace(ctx, workspace.ID)
991992
require.NoError(t, err, "fetch provisioned workspace")
992-
require.InDelta(t, oldDeadline.Unix(), workspace.LatestBuild.Deadline.Unix(), 60)
993+
oldDeadline := workspace.LatestBuild.Deadline
993994

994995
// Updating the deadline should succeed
995996
req := codersdk.PutExtendWorkspaceRequest{
@@ -1001,30 +1002,45 @@ func TestWorkspaceExtend(t *testing.T) {
10011002
// Ensure deadline set correctly
10021003
updated, err := client.Workspace(ctx, workspace.ID)
10031004
require.NoError(t, err, "failed to fetch updated workspace")
1004-
require.InDelta(t, newDeadline.Unix(), updated.LatestBuild.Deadline.Unix(), 60)
1005+
require.WithinDuration(t, newDeadline, updated.LatestBuild.Deadline, time.Minute)
10051006

10061007
// Zero time should fail
10071008
err = client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
10081009
Deadline: time.Time{},
10091010
})
10101011
require.ErrorContains(t, err, "deadline: Validation failed for tag \"required\" with value: \"0001-01-01 00:00:00 +0000 UTC\"", "setting an empty deadline on a workspace should fail")
10111012

1012-
// Updating with an earlier time should also fail
1013+
// Updating with a deadline 29 minutes in the future should fail
1014+
deadlineTooSoon := time.Now().Add(29 * time.Minute)
1015+
err = client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
1016+
Deadline: deadlineTooSoon,
1017+
})
1018+
require.ErrorContains(t, err, "new deadline must be at least 30 minutes in the future", "setting a deadline less than 30 minutes in the future should fail")
1019+
1020+
// And with a deadline greater than the template max_ttl should also fail
1021+
deadlineExceedsMaxTTL := time.Now().Add(time.Duration(template.MaxTTLMillis) * time.Millisecond).Add(time.Minute)
1022+
err = client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
1023+
Deadline: deadlineExceedsMaxTTL,
1024+
})
1025+
require.ErrorContains(t, err, "new deadline is greater than template allows", "setting a deadline greater than that allowed by the template should fail")
1026+
1027+
// Updating with a deadline 30 minutes in the future should succeed
1028+
deadlineJustSoonEnough := time.Now().Add(30 * time.Minute)
10131029
err = client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
1014-
Deadline: oldDeadline,
1030+
Deadline: deadlineJustSoonEnough,
10151031
})
1016-
require.ErrorContains(t, err, "deadline: minimum extension is one minute", "setting an earlier deadline should fail")
1032+
require.NoError(t, err, "setting a deadline at least 30 minutes in the future should succeed")
10171033

1018-
// Updating with a time far in the future should also fail
1034+
// Updating with a deadline an hour before the previous deadline should succeed
10191035
err = client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
1020-
Deadline: oldDeadline.AddDate(1, 0, 0),
1036+
Deadline: oldDeadline.Add(-time.Hour),
10211037
})
1022-
require.ErrorContains(t, err, "deadline: maximum extension is 24 hours", "setting an earlier deadline should fail")
1038+
require.NoError(t, err, "setting an earlier deadline should not fail")
10231039

10241040
// Ensure deadline still set correctly
10251041
updated, err = client.Workspace(ctx, workspace.ID)
10261042
require.NoError(t, err, "failed to fetch updated workspace")
1027-
require.InDelta(t, newDeadline.Unix(), updated.LatestBuild.Deadline.Unix(), 60)
1043+
require.WithinDuration(t, oldDeadline.Add(-time.Hour), updated.LatestBuild.Deadline, time.Minute)
10281044
}
10291045

10301046
func TestWorkspaceWatcher(t *testing.T) {

0 commit comments

Comments
 (0)