Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit a8ae9b3

Browse files
authored
feat: enforce upper bounds on workspace TTL and Deadline (#1902)
* Enforces upper bound for workspace TTL * Enforces upper bound for workspace deadline
1 parent 17a57a4 commit a8ae9b3

File tree

2 files changed

+143
-36
lines changed

2 files changed

+143
-36
lines changed

coderd/workspaces.go

+75-32
Original file line numberDiff line numberDiff line change
@@ -347,10 +347,18 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
347347
dbAutostartSchedule.String = *createWorkspace.AutostartSchedule
348348
}
349349

350-
var dbTTL sql.NullInt64
351-
if createWorkspace.TTL != nil && *createWorkspace.TTL > 0 {
352-
dbTTL.Valid = true
353-
dbTTL.Int64 = int64(*createWorkspace.TTL)
350+
dbTTL, err := validWorkspaceTTL(createWorkspace.TTL)
351+
if err != nil {
352+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
353+
Message: "validate workspace ttl",
354+
Errors: []httpapi.Error{
355+
{
356+
Field: "ttl",
357+
Detail: err.Error(),
358+
},
359+
},
360+
})
361+
return
354362
}
355363

356364
workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(r.Context(), database.GetWorkspaceByOwnerIDAndNameParams{
@@ -559,14 +567,21 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
559567
return
560568
}
561569

562-
var dbTTL sql.NullInt64
563-
if req.TTL != nil && *req.TTL > 0 {
564-
truncated := req.TTL.Truncate(time.Minute)
565-
dbTTL.Int64 = int64(truncated)
566-
dbTTL.Valid = true
570+
dbTTL, err := validWorkspaceTTL(req.TTL)
571+
if err != nil {
572+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
573+
Message: "validate workspace ttl",
574+
Errors: []httpapi.Error{
575+
{
576+
Field: "ttl",
577+
Detail: err.Error(),
578+
},
579+
},
580+
})
581+
return
567582
}
568583

569-
err := api.Database.UpdateWorkspaceTTL(r.Context(), database.UpdateWorkspaceTTLParams{
584+
err = api.Database.UpdateWorkspaceTTL(r.Context(), database.UpdateWorkspaceTTLParams{
570585
ID: workspace.ID,
571586
Ttl: dbTTL,
572587
})
@@ -590,36 +605,29 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
590605
return
591606
}
592607

593-
var code = http.StatusOK
608+
code := http.StatusOK
609+
resp := httpapi.Response{}
594610

595611
err := api.Database.InTx(func(s database.Store) error {
596612
build, err := s.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
597613
if err != nil {
598614
code = http.StatusInternalServerError
615+
resp.Message = "workspace not found"
599616
return xerrors.Errorf("get latest workspace build: %w", err)
600617
}
601618

602619
if build.Transition != database.WorkspaceTransitionStart {
603620
code = http.StatusConflict
621+
resp.Message = "workspace must be started, current status: " + string(build.Transition)
604622
return xerrors.Errorf("workspace must be started, current status: %s", build.Transition)
605623
}
606624

607625
newDeadline := req.Deadline.UTC()
608-
if newDeadline.IsZero() {
609-
// This should not be possible because the struct validation field enforces a non-zero value.
610-
code = http.StatusBadRequest
611-
return xerrors.New("new deadline cannot be zero")
612-
}
613-
614-
if newDeadline.Before(build.Deadline) || newDeadline.Before(time.Now()) {
626+
if err := validWorkspaceDeadline(build.Deadline, newDeadline); err != nil {
615627
code = http.StatusBadRequest
616-
return xerrors.Errorf("new deadline %q must be after existing deadline %q", newDeadline.Format(time.RFC3339), build.Deadline.Format(time.RFC3339))
617-
}
618-
619-
// Disallow updates within less than one minute
620-
if withinDuration(newDeadline, build.Deadline, time.Minute) {
621-
code = http.StatusNotModified
622-
return nil
628+
resp.Message = "bad extend workspace request"
629+
resp.Errors = append(resp.Errors, httpapi.Error{Field: "deadline", Detail: err.Error()})
630+
return err
623631
}
624632

625633
if err := s.UpdateWorkspaceBuildByID(r.Context(), database.UpdateWorkspaceBuildByIDParams{
@@ -628,15 +636,17 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
628636
ProvisionerState: build.ProvisionerState,
629637
Deadline: newDeadline,
630638
}); err != nil {
639+
code = http.StatusInternalServerError
640+
resp.Message = "failed to extend workspace deadline"
631641
return xerrors.Errorf("update workspace build: %w", err)
632642
}
643+
resp.Message = "deadline updated to " + newDeadline.Format(time.RFC3339)
633644

634645
return nil
635646
})
636647

637-
var resp = httpapi.Response{}
638648
if err != nil {
639-
resp.Message = err.Error()
649+
api.Logger.Info(r.Context(), "extending workspace", slog.Error(err))
640650
}
641651
httpapi.Write(rw, code, resp)
642652
}
@@ -850,11 +860,44 @@ func convertSQLNullInt64(i sql.NullInt64) *time.Duration {
850860
return (*time.Duration)(&i.Int64)
851861
}
852862

853-
func withinDuration(t1, t2 time.Time, d time.Duration) bool {
854-
dt := t1.Sub(t2)
855-
if dt < -d || dt > d {
856-
return false
863+
func validWorkspaceTTL(ttl *time.Duration) (sql.NullInt64, error) {
864+
if ttl == nil {
865+
return sql.NullInt64{}, nil
866+
}
867+
868+
truncated := ttl.Truncate(time.Minute)
869+
if truncated < time.Minute {
870+
return sql.NullInt64{}, xerrors.New("ttl must be at least one minute")
871+
}
872+
873+
if truncated > 24*7*time.Hour {
874+
return sql.NullInt64{}, xerrors.New("ttl must be less than 7 days")
875+
}
876+
877+
return sql.NullInt64{
878+
Valid: true,
879+
Int64: int64(truncated),
880+
}, nil
881+
}
882+
883+
func validWorkspaceDeadline(old, new time.Time) error {
884+
if old.IsZero() {
885+
return xerrors.New("nothing to do: no existing deadline set")
886+
}
887+
888+
now := time.Now()
889+
if new.Before(now) {
890+
return xerrors.New("new deadline must be in the future")
891+
}
892+
893+
delta := new.Sub(old)
894+
if delta < time.Minute {
895+
return xerrors.New("minimum extension is one minute")
896+
}
897+
898+
if delta > 24*time.Hour {
899+
return xerrors.New("maximum extension is 24 hours")
857900
}
858901

859-
return true
902+
return nil
860903
}

coderd/workspaces_test.go

+68-4
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,49 @@ func TestPostWorkspacesByOrganization(t *testing.T) {
164164
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
165165
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
166166
})
167+
168+
t.Run("InvalidTTL", func(t *testing.T) {
169+
t.Parallel()
170+
t.Run("BelowMin", func(t *testing.T) {
171+
t.Parallel()
172+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
173+
user := coderdtest.CreateFirstUser(t, client)
174+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
175+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
176+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
177+
req := codersdk.CreateWorkspaceRequest{
178+
TemplateID: template.ID,
179+
Name: "testing",
180+
AutostartSchedule: ptr("CRON_TZ=US/Central * * * * *"),
181+
TTL: ptr(59 * time.Second),
182+
}
183+
_, err := client.CreateWorkspace(context.Background(), template.OrganizationID, req)
184+
require.Error(t, err)
185+
var apiErr *codersdk.Error
186+
require.ErrorAs(t, err, &apiErr)
187+
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
188+
})
189+
190+
t.Run("AboveMax", func(t *testing.T) {
191+
t.Parallel()
192+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
193+
user := coderdtest.CreateFirstUser(t, client)
194+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
195+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
196+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
197+
req := codersdk.CreateWorkspaceRequest{
198+
TemplateID: template.ID,
199+
Name: "testing",
200+
AutostartSchedule: ptr("CRON_TZ=US/Central * * * * *"),
201+
TTL: ptr(24*7*time.Hour + time.Minute),
202+
}
203+
_, err := client.CreateWorkspace(context.Background(), template.OrganizationID, req)
204+
require.Error(t, err)
205+
var apiErr *codersdk.Error
206+
require.ErrorAs(t, err, &apiErr)
207+
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
208+
})
209+
})
167210
}
168211

169212
func TestWorkspacesByOrganization(t *testing.T) {
@@ -552,10 +595,25 @@ func TestWorkspaceUpdateTTL(t *testing.T) {
552595
expectedError: "",
553596
},
554597
{
555-
name: "enable ttl",
556-
ttl: ptr(time.Hour),
598+
name: "below minimum ttl",
599+
ttl: ptr(30 * time.Second),
600+
expectedError: "ttl must be at least one minute",
601+
},
602+
{
603+
name: "minimum ttl",
604+
ttl: ptr(time.Minute),
605+
expectedError: "",
606+
},
607+
{
608+
name: "maximum ttl",
609+
ttl: ptr(24 * 7 * time.Hour),
557610
expectedError: "",
558611
},
612+
{
613+
name: "above maximum ttl",
614+
ttl: ptr(24*7*time.Hour + time.Minute),
615+
expectedError: "ttl must be less than 7 days",
616+
},
559617
}
560618

561619
for _, testCase := range testCases {
@@ -583,7 +641,7 @@ func TestWorkspaceUpdateTTL(t *testing.T) {
583641
})
584642

585643
if testCase.expectedError != "" {
586-
require.EqualError(t, err, testCase.expectedError, "unexpected error when setting workspace autostop schedule")
644+
require.ErrorContains(t, err, testCase.expectedError, "unexpected error when setting workspace autostop schedule")
587645
return
588646
}
589647

@@ -657,7 +715,13 @@ func TestWorkspaceExtend(t *testing.T) {
657715
err = client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
658716
Deadline: oldDeadline,
659717
})
660-
require.ErrorContains(t, err, "must be after existing deadline", "setting an earlier deadline should fail")
718+
require.ErrorContains(t, err, "deadline: minimum extension is one minute", "setting an earlier deadline should fail")
719+
720+
// Updating with a time far in the future should also fail
721+
err = client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
722+
Deadline: oldDeadline.AddDate(1, 0, 0),
723+
})
724+
require.ErrorContains(t, err, "deadline: maximum extension is 24 hours", "setting an earlier deadline should fail")
661725

662726
// Ensure deadline still set correctly
663727
updated, err = client.Workspace(ctx, workspace.ID)

0 commit comments

Comments
 (0)