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

Skip to content

feat: enforce min and max bounds on workspace TTL and Deadline #1902

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 75 additions & 32 deletions coderd/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,10 +347,18 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
dbAutostartSchedule.String = *createWorkspace.AutostartSchedule
}

var dbTTL sql.NullInt64
if createWorkspace.TTL != nil && *createWorkspace.TTL > 0 {
dbTTL.Valid = true
dbTTL.Int64 = int64(*createWorkspace.TTL)
dbTTL, err := validWorkspaceTTL(createWorkspace.TTL)
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: "validate workspace ttl",
Errors: []httpapi.Error{
{
Field: "ttl",
Detail: err.Error(),
},
},
})
return
}

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

var dbTTL sql.NullInt64
if req.TTL != nil && *req.TTL > 0 {
truncated := req.TTL.Truncate(time.Minute)
dbTTL.Int64 = int64(truncated)
dbTTL.Valid = true
dbTTL, err := validWorkspaceTTL(req.TTL)
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: "validate workspace ttl",
Errors: []httpapi.Error{
{
Field: "ttl",
Detail: err.Error(),
},
},
})
return
}

err := api.Database.UpdateWorkspaceTTL(r.Context(), database.UpdateWorkspaceTTLParams{
err = api.Database.UpdateWorkspaceTTL(r.Context(), database.UpdateWorkspaceTTLParams{
ID: workspace.ID,
Ttl: dbTTL,
})
Expand All @@ -590,36 +605,29 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
return
}

var code = http.StatusOK
code := http.StatusOK
resp := httpapi.Response{}

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

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

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
return xerrors.New("new deadline cannot be zero")
}

if newDeadline.Before(build.Deadline) || newDeadline.Before(time.Now()) {
if err := validWorkspaceDeadline(build.Deadline, newDeadline); err != nil {
code = http.StatusBadRequest
return xerrors.Errorf("new deadline %q must be after existing deadline %q", newDeadline.Format(time.RFC3339), build.Deadline.Format(time.RFC3339))
}

// Disallow updates within less than one minute
if withinDuration(newDeadline, build.Deadline, time.Minute) {
code = http.StatusNotModified
return nil
resp.Message = "bad extend workspace request"
resp.Errors = append(resp.Errors, httpapi.Error{Field: "deadline", Detail: err.Error()})
return err
}

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

return nil
})

var resp = httpapi.Response{}
if err != nil {
resp.Message = err.Error()
api.Logger.Info(r.Context(), "extending workspace", slog.Error(err))
}
httpapi.Write(rw, code, resp)
}
Expand Down Expand Up @@ -850,11 +860,44 @@ 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
func validWorkspaceTTL(ttl *time.Duration) (sql.NullInt64, error) {
if ttl == nil {
return sql.NullInt64{}, nil
}

truncated := ttl.Truncate(time.Minute)
if truncated < time.Minute {
return sql.NullInt64{}, xerrors.New("ttl must be at least one minute")
}

if truncated > 24*7*time.Hour {
return sql.NullInt64{}, xerrors.New("ttl must be less than 7 days")
}

return sql.NullInt64{
Valid: true,
Int64: int64(truncated),
}, nil
}

func validWorkspaceDeadline(old, new time.Time) error {
if old.IsZero() {
return xerrors.New("nothing to do: no existing deadline set")
}

now := time.Now()
if new.Before(now) {
return xerrors.New("new deadline must be in the future")
}

delta := new.Sub(old)
if delta < time.Minute {
return xerrors.New("minimum extension is one minute")
}

if delta > 24*time.Hour {
return xerrors.New("maximum extension is 24 hours")
}

return true
return nil
}
72 changes: 68 additions & 4 deletions coderd/workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,49 @@ func TestPostWorkspacesByOrganization(t *testing.T) {
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
})

t.Run("InvalidTTL", func(t *testing.T) {
t.Parallel()
t.Run("BelowMin", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
req := codersdk.CreateWorkspaceRequest{
TemplateID: template.ID,
Name: "testing",
AutostartSchedule: ptr("CRON_TZ=US/Central * * * * *"),
TTL: ptr(59 * time.Second),
}
_, err := client.CreateWorkspace(context.Background(), template.OrganizationID, req)
require.Error(t, err)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
})

t.Run("AboveMax", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
req := codersdk.CreateWorkspaceRequest{
TemplateID: template.ID,
Name: "testing",
AutostartSchedule: ptr("CRON_TZ=US/Central * * * * *"),
TTL: ptr(24*7*time.Hour + time.Minute),
}
_, err := client.CreateWorkspace(context.Background(), template.OrganizationID, req)
require.Error(t, err)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
})
})
}

func TestWorkspacesByOrganization(t *testing.T) {
Expand Down Expand Up @@ -552,10 +595,25 @@ func TestWorkspaceUpdateTTL(t *testing.T) {
expectedError: "",
},
{
name: "enable ttl",
ttl: ptr(time.Hour),
name: "below minimum ttl",
ttl: ptr(30 * time.Second),
expectedError: "ttl must be at least one minute",
},
{
name: "minimum ttl",
ttl: ptr(time.Minute),
expectedError: "",
},
{
name: "maximum ttl",
ttl: ptr(24 * 7 * time.Hour),
expectedError: "",
},
{
name: "above maximum ttl",
ttl: ptr(24*7*time.Hour + time.Minute),
expectedError: "ttl must be less than 7 days",
},
}

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

if testCase.expectedError != "" {
require.EqualError(t, err, testCase.expectedError, "unexpected error when setting workspace autostop schedule")
require.ErrorContains(t, err, testCase.expectedError, "unexpected error when setting workspace autostop schedule")
return
}

Expand Down Expand Up @@ -657,7 +715,13 @@ func TestWorkspaceExtend(t *testing.T) {
err = client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
Deadline: oldDeadline,
})
require.ErrorContains(t, err, "must be after existing deadline", "setting an earlier deadline should fail")
require.ErrorContains(t, err, "deadline: minimum extension is one minute", "setting an earlier deadline should fail")

// Updating with a time far in the future should also fail
err = client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
Deadline: oldDeadline.AddDate(1, 0, 0),
})
require.ErrorContains(t, err, "deadline: maximum extension is 24 hours", "setting an earlier deadline should fail")

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