Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
feat: implement autostop database/http/api plumbing
  • Loading branch information
johnstcn committed Apr 6, 2022
commit be0e6f3acc901a8bec107cb8477fa04b88b25407
3 changes: 3 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ func New(options *Options) (http.Handler, func()) {
r.Route("/autostart", func(r chi.Router) {
r.Put("/", api.putWorkspaceAutostart)
})
r.Route("/autostop", func(r chi.Router) {
r.Put("/", api.putWorkspaceAutostop)
})
})
r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) {
r.Use(
Expand Down
32 changes: 31 additions & 1 deletion coderd/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,37 @@ func (api *api) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
}
}

// TODO(cian): api.updateWorkspaceAutostop
func (api *api) putWorkspaceAutostop(rw http.ResponseWriter, r *http.Request) {
var req codersdk.UpdateWorkspaceAutostopRequest
if !httpapi.Read(rw, r, &req) {
return
}

var dbSched sql.NullString
if req.Schedule != "" {
validSched, err := schedule.Weekly(req.Schedule)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("invalid autostop schedule: %s", err),
})
return
}
dbSched.String = validSched.String()
dbSched.Valid = true
}

workspace := httpmw.WorkspaceParam(r)
err := api.Database.UpdateWorkspaceAutostop(r.Context(), database.UpdateWorkspaceAutostopParams{
ID: workspace.ID,
AutostopSchedule: dbSched,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("update workspace autostop schedule: %s", err),
})
return
}
}

func convertWorkspace(workspace database.Workspace, workspaceBuild codersdk.WorkspaceBuild, template database.Template) codersdk.Workspace {
return codersdk.Workspace{
Expand Down
131 changes: 130 additions & 1 deletion coderd/workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,136 @@ func TestWorkspaceUpdateAutostart(t *testing.T) {

err := client.UpdateWorkspaceAutostart(ctx, wsid, req)
require.IsType(t, err, &codersdk.Error{}, "expected codersdk.Error")
coderSDKErr, _ := err.(*codersdk.Error)
coderSDKErr, _ := err.(*codersdk.Error) //nolint:errorlint
require.Equal(t, coderSDKErr.StatusCode(), 404, "expected status code 404")
require.Equal(t, fmt.Sprintf("workspace %q does not exist", wsid), coderSDKErr.Message, "unexpected response code")
})
}

func TestWorkspaceUpdateAutostop(t *testing.T) {
t.Parallel()
var dublinLoc = mustLocation(t, "Europe/Dublin")

testCases := []struct {
name string
schedule string
expectedError string
at time.Time
expectedNext time.Time
expectedInterval time.Duration
}{
{
name: "disable autostop",
schedule: "",
expectedError: "",
},
{
name: "friday to monday",
schedule: "CRON_TZ=Europe/Dublin 30 17 1-5",
expectedError: "",
at: time.Date(2022, 5, 6, 17, 31, 0, 0, dublinLoc),
expectedNext: time.Date(2022, 5, 9, 17, 30, 0, 0, dublinLoc),
expectedInterval: 71*time.Hour + 59*time.Minute,
},
{
name: "monday to tuesday",
schedule: "CRON_TZ=Europe/Dublin 30 17 1-5",
expectedError: "",
at: time.Date(2022, 5, 9, 17, 31, 0, 0, dublinLoc),
expectedNext: time.Date(2022, 5, 10, 17, 30, 0, 0, dublinLoc),
expectedInterval: 23*time.Hour + 59*time.Minute,
},
{
// DST in Ireland began on Mar 27 in 2022 at 0100. Forward 1 hour.
name: "DST start",
schedule: "CRON_TZ=Europe/Dublin 30 17 *",
expectedError: "",
at: time.Date(2022, 3, 26, 17, 31, 0, 0, dublinLoc),
expectedNext: time.Date(2022, 3, 27, 17, 30, 0, 0, dublinLoc),
expectedInterval: 22*time.Hour + 59*time.Minute,
},
{
// DST in Ireland ends on Oct 30 in 2022 at 0200. Back 1 hour.
name: "DST end",
schedule: "CRON_TZ=Europe/Dublin 30 17 *",
expectedError: "",
at: time.Date(2022, 10, 29, 17, 31, 0, 0, dublinLoc),
expectedNext: time.Date(2022, 10, 30, 17, 30, 0, 0, dublinLoc),
expectedInterval: 24*time.Hour + 59*time.Minute,
},
{
name: "invalid location",
schedule: "CRON_TZ=Imaginary/Place 30 17 1-5",
expectedError: "status code 500: invalid autostop schedule: parse schedule: provided bad location Imaginary/Place: unknown time zone Imaginary/Place",
},
{
name: "invalid schedule",
schedule: "asdf asdf asdf ",
expectedError: `status code 500: invalid autostop schedule: parse schedule: failed to parse int from asdf: strconv.Atoi: parsing "asdf": invalid syntax`,
},
}

for _, testCase := range testCases {
testCase := testCase
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
var (
ctx = context.Background()
client = coderdtest.New(t, nil)
_ = coderdtest.NewProvisionerDaemon(t, client)
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, codersdk.Me, project.ID)
)

// ensure test invariant: new workspaces have no autostop schedule.
require.Empty(t, workspace.AutostopSchedule, "expected newly-minted workspace to have no autstop schedule")

err := client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
Schedule: testCase.schedule,
})

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

require.NoError(t, err, "expected no error setting workspace autostop schedule")

updated, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err, "fetch updated workspace")

require.Equal(t, testCase.schedule, updated.AutostopSchedule, "expected autostop schedule to equal requested")

if testCase.schedule == "" {
return
}
sched, err := schedule.Weekly(updated.AutostopSchedule)
require.NoError(t, err, "parse returned schedule")

next := sched.Next(testCase.at)
require.Equal(t, testCase.expectedNext, next, "unexpected next scheduled autostop time")
interval := next.Sub(testCase.at)
require.Equal(t, testCase.expectedInterval, interval, "unexpected interval")
})
}

t.Run("NotFound", func(t *testing.T) {
var (
ctx = context.Background()
client = coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
wsid = uuid.New()
req = codersdk.UpdateWorkspaceAutostopRequest{
Schedule: "9 30 1-5",
}
)

err := client.UpdateWorkspaceAutostop(ctx, wsid, req)
require.IsType(t, err, &codersdk.Error{}, "expected codersdk.Error")
coderSDKErr, _ := err.(*codersdk.Error) //nolint:errorlint
require.Equal(t, coderSDKErr.StatusCode(), 404, "expected status code 404")
require.Equal(t, fmt.Sprintf("workspace %q does not exist", wsid), coderSDKErr.Message, "unexpected response code")
})
Expand Down
21 changes: 19 additions & 2 deletions codersdk/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,25 @@ func (c *Client) UpdateWorkspaceAutostart(ctx context.Context, id uuid.UUID, req
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
}
// TODO(cian): should we return the updated schedule?
return nil
}

// TODO(cian): client.UpdateWorkspaceAutostop
// UpdateWorkspaceAutostopRequest is a request to update a workspace's autostop schedule.
type UpdateWorkspaceAutostopRequest struct {
Schedule string
}

// UpdateWorkspaceAutostop sets the autostop schedule for workspace by id.
// If the provided schedule is empty, autostop is disabled for the workspace.
func (c *Client) UpdateWorkspaceAutostop(ctx context.Context, id uuid.UUID, req UpdateWorkspaceAutostopRequest) error {
path := fmt.Sprintf("/api/v2/workspaces/%s/autostop", id.String())
res, err := c.request(ctx, http.MethodPut, path, req)
if err != nil {
return xerrors.Errorf("update workspace autostop: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
}
return nil
}