From 14384448baf4340b40e59746902163df50d42ae8 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 4 Apr 2022 13:26:08 +0000 Subject: [PATCH 01/16] feat: add columns autostart_schedule, autostop_schedule to database schema --- coderd/database/dump.sql | 4 +++- .../000005_workspace_autostartstop.down.sql | 3 +++ .../000005_workspace_autostartstop.up.sql | 3 +++ coderd/database/models.go | 16 ++++++++------- coderd/database/queries.sql.go | 20 ++++++++++++++----- 5 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 coderd/database/migrations/000005_workspace_autostartstop.down.sql create mode 100644 coderd/database/migrations/000005_workspace_autostartstop.up.sql diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 2103e7317a626..1d6cab77e0073 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -280,7 +280,9 @@ CREATE TABLE workspaces ( owner_id uuid NOT NULL, template_id uuid NOT NULL, deleted boolean DEFAULT false NOT NULL, - name character varying(64) NOT NULL + name character varying(64) NOT NULL, + autostart_schedule text, + autostop_schedule text ); ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('public.licenses_id_seq'::regclass); diff --git a/coderd/database/migrations/000005_workspace_autostartstop.down.sql b/coderd/database/migrations/000005_workspace_autostartstop.down.sql new file mode 100644 index 0000000000000..bb975bb8485c1 --- /dev/null +++ b/coderd/database/migrations/000005_workspace_autostartstop.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE ONLY workspaces + DROP COLUMN autostart_schedule, + DROP COLUMN autostop_schedule; diff --git a/coderd/database/migrations/000005_workspace_autostartstop.up.sql b/coderd/database/migrations/000005_workspace_autostartstop.up.sql new file mode 100644 index 0000000000000..ab817d3585231 --- /dev/null +++ b/coderd/database/migrations/000005_workspace_autostartstop.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE ONLY workspaces + ADD COLUMN autostart_schedule text DEFAULT NULL, + ADD COLUMN autostop_schedule text DEFAULT NULL; diff --git a/coderd/database/models.go b/coderd/database/models.go index 21c609d3fa7c4..f8ab1959a046c 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -388,13 +388,15 @@ type User struct { } type Workspace struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - Deleted bool `db:"deleted" json:"deleted"` - Name string `db:"name" json:"name"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Deleted bool `db:"deleted" json:"deleted"` + Name string `db:"name" json:"name"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` + AutostopSchedule sql.NullString `db:"autostop_schedule" json:"autostop_schedule"` } type WorkspaceAgent struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 84a470f02fdfd..1cf70c996c213 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2523,7 +2523,7 @@ func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWork const getWorkspaceByID = `-- name: GetWorkspaceByID :one SELECT - id, created_at, updated_at, owner_id, template_id, deleted, name + id, created_at, updated_at, owner_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE @@ -2543,13 +2543,15 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp &i.TemplateID, &i.Deleted, &i.Name, + &i.AutostartSchedule, + &i.AutostopSchedule, ) return i, err } const getWorkspaceByUserIDAndName = `-- name: GetWorkspaceByUserIDAndName :one SELECT - id, created_at, updated_at, owner_id, template_id, deleted, name + id, created_at, updated_at, owner_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE @@ -2575,6 +2577,8 @@ func (q *sqlQuerier) GetWorkspaceByUserIDAndName(ctx context.Context, arg GetWor &i.TemplateID, &i.Deleted, &i.Name, + &i.AutostartSchedule, + &i.AutostopSchedule, ) return i, err } @@ -2622,7 +2626,7 @@ func (q *sqlQuerier) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, i const getWorkspacesByTemplateID = `-- name: GetWorkspacesByTemplateID :many SELECT - id, created_at, updated_at, owner_id, template_id, deleted, name + id, created_at, updated_at, owner_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE @@ -2652,6 +2656,8 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorks &i.TemplateID, &i.Deleted, &i.Name, + &i.AutostartSchedule, + &i.AutostopSchedule, ); err != nil { return nil, err } @@ -2668,7 +2674,7 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorks const getWorkspacesByUserID = `-- name: GetWorkspacesByUserID :many SELECT - id, created_at, updated_at, owner_id, template_id, deleted, name + id, created_at, updated_at, owner_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE @@ -2698,6 +2704,8 @@ func (q *sqlQuerier) GetWorkspacesByUserID(ctx context.Context, arg GetWorkspace &i.TemplateID, &i.Deleted, &i.Name, + &i.AutostartSchedule, + &i.AutostopSchedule, ); err != nil { return nil, err } @@ -2723,7 +2731,7 @@ INSERT INTO name ) VALUES - ($1, $2, $3, $4, $5, $6) RETURNING id, created_at, updated_at, owner_id, template_id, deleted, name + ($1, $2, $3, $4, $5, $6) RETURNING id, created_at, updated_at, owner_id, template_id, deleted, name, autostart_schedule, autostop_schedule ` type InsertWorkspaceParams struct { @@ -2753,6 +2761,8 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar &i.TemplateID, &i.Deleted, &i.Name, + &i.AutostartSchedule, + &i.AutostopSchedule, ) return i, err } From 400a7872e0dc67c58a837decbe6e3c9bb66b3586 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 4 Apr 2022 13:41:59 +0000 Subject: [PATCH 02/16] feat: database: add UpdateWorkspaceAutostart and UpdateWorkspaceAutostop methods --- coderd/database/queries.sql.go | 38 ++++++++++++++++++++++++++ coderd/database/queries/workspaces.sql | 16 +++++++++++ 2 files changed, 54 insertions(+) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1cf70c996c213..f0d620d2e64bf 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2767,6 +2767,44 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar return i, err } +const updateWorkspaceAutostart = `-- name: UpdateWorkspaceAutostart :exec +UPDATE + workspaces +SET + autostart_schedule = $2 +WHERE + id = $1 +` + +type UpdateWorkspaceAutostartParams struct { + ID uuid.UUID `db:"id" json:"id"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` +} + +func (q *sqlQuerier) UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceAutostart, arg.ID, arg.AutostartSchedule) + return err +} + +const updateWorkspaceAutostop = `-- name: UpdateWorkspaceAutostop :exec +UPDATE + workspaces +SET + autostop_schedule = $2 +WHERE + id = $1 +` + +type UpdateWorkspaceAutostopParams struct { + ID uuid.UUID `db:"id" json:"id"` + AutostopSchedule sql.NullString `db:"autostop_schedule" json:"autostop_schedule"` +} + +func (q *sqlQuerier) UpdateWorkspaceAutostop(ctx context.Context, arg UpdateWorkspaceAutostopParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceAutostop, arg.ID, arg.AutostopSchedule) + return err +} + const updateWorkspaceDeletedByID = `-- name: UpdateWorkspaceDeletedByID :exec UPDATE workspaces diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 9bf7c99a4c77d..37f26cf79a306 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -68,3 +68,19 @@ SET deleted = $2 WHERE id = $1; + +-- name: UpdateWorkspaceAutostart :exec +UPDATE + workspaces +SET + autostart_schedule = $2 +WHERE + id = $1; + +-- name: UpdateWorkspaceAutostop :exec +UPDATE + workspaces +SET + autostop_schedule = $2 +WHERE + id = $1; From c581615cedd9c3bda54a3b187f82d68445f3e5eb Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 5 Apr 2022 11:21:49 +0000 Subject: [PATCH 03/16] fix: sqlc generate --- coderd/database/querier.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 75b6613e0b5a8..9da31284a5c49 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -81,6 +81,8 @@ type querier interface { UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error + UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error + UpdateWorkspaceAutostop(ctx context.Context, arg UpdateWorkspaceAutostopParams) error UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error } From 9a3b18681fd6bc3e55288ca310f419e7016dfc3b Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 5 Apr 2022 11:38:05 +0000 Subject: [PATCH 04/16] fix: databasefake: implement now-missing AutoStop/AutoStart methods --- coderd/database/databasefake/databasefake.go | 52 ++++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index b21256e24bff9..342945a4fb36d 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -27,7 +27,7 @@ func New() database.Store { provisionerDaemons: make([]database.ProvisionerDaemon, 0), provisionerJobs: make([]database.ProvisionerJob, 0), provisionerJobLog: make([]database.ProvisionerJobLog, 0), - workspace: make([]database.Workspace, 0), + workspaces: make([]database.Workspace, 0), provisionerJobResource: make([]database.WorkspaceResource, 0), workspaceBuild: make([]database.WorkspaceBuild, 0), provisionerJobAgent: make([]database.WorkspaceAgent, 0), @@ -56,7 +56,7 @@ type fakeQuerier struct { provisionerJobAgent []database.WorkspaceAgent provisionerJobResource []database.WorkspaceResource provisionerJobLog []database.ProvisionerJobLog - workspace []database.Workspace + workspaces []database.Workspace workspaceBuild []database.WorkspaceBuild GitSSHKey []database.GitSSHKey } @@ -169,7 +169,7 @@ func (q *fakeQuerier) GetWorkspacesByTemplateID(_ context.Context, arg database. defer q.mutex.RUnlock() workspaces := make([]database.Workspace, 0) - for _, workspace := range q.workspace { + for _, workspace := range q.workspaces { if workspace.TemplateID.String() != arg.TemplateID.String() { continue } @@ -188,7 +188,7 @@ func (q *fakeQuerier) GetWorkspaceByID(_ context.Context, id uuid.UUID) (databas q.mutex.RLock() defer q.mutex.RUnlock() - for _, workspace := range q.workspace { + for _, workspace := range q.workspaces { if workspace.ID.String() == id.String() { return workspace, nil } @@ -200,7 +200,7 @@ func (q *fakeQuerier) GetWorkspaceByUserIDAndName(_ context.Context, arg databas q.mutex.RLock() defer q.mutex.RUnlock() - for _, workspace := range q.workspace { + for _, workspace := range q.workspaces { if workspace.OwnerID != arg.OwnerID { continue } @@ -222,7 +222,7 @@ func (q *fakeQuerier) GetWorkspaceOwnerCountsByTemplateIDs(_ context.Context, te counts := map[uuid.UUID]map[uuid.UUID]struct{}{} for _, templateID := range templateIDs { found := false - for _, workspace := range q.workspace { + for _, workspace := range q.workspaces { if workspace.TemplateID != templateID { continue } @@ -350,7 +350,7 @@ func (q *fakeQuerier) GetWorkspacesByUserID(_ context.Context, req database.GetW defer q.mutex.RUnlock() workspaces := make([]database.Workspace, 0) - for _, workspace := range q.workspace { + for _, workspace := range q.workspaces { if workspace.OwnerID != req.OwnerID { continue } @@ -1040,7 +1040,7 @@ func (q *fakeQuerier) InsertWorkspace(_ context.Context, arg database.InsertWork TemplateID: arg.TemplateID, Name: arg.Name, } - q.workspace = append(q.workspace, workspace) + q.workspaces = append(q.workspaces, workspace) return workspace, nil } @@ -1210,6 +1210,38 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCompleteByID(_ context.Context, ar return sql.ErrNoRows } +func (q *fakeQuerier) UpdateWorkspaceAutostart(ctx context.Context, arg database.UpdateWorkspaceAutostartParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, workspace := range q.workspaces { + if workspace.ID.String() != arg.ID.String() { + continue + } + workspace.AutostartSchedule = arg.AutostartSchedule + q.workspaces[index] = workspace + return nil + } + + return sql.ErrNoRows +} + +func (q *fakeQuerier) UpdateWorkspaceAutostop(ctx context.Context, arg database.UpdateWorkspaceAutostopParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, workspace := range q.workspaces { + if workspace.ID.String() != arg.ID.String() { + continue + } + workspace.AutostopSchedule = arg.AutostopSchedule + q.workspaces[index] = workspace + return nil + } + + return sql.ErrNoRows +} + func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.UpdateWorkspaceBuildByIDParams) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -1231,12 +1263,12 @@ func (q *fakeQuerier) UpdateWorkspaceDeletedByID(_ context.Context, arg database q.mutex.Lock() defer q.mutex.Unlock() - for index, workspace := range q.workspace { + for index, workspace := range q.workspaces { if workspace.ID.String() != arg.ID.String() { continue } workspace.Deleted = arg.Deleted - q.workspace[index] = workspace + q.workspaces[index] = workspace return nil } return sql.ErrNoRows From 6fa386d4fbdd4c5278bb896f9c7a0ce7e77cdcee Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 5 Apr 2022 12:04:40 +0000 Subject: [PATCH 05/16] feat: add AutostartSchedule/AutostopSchedule to api workspace struct --- coderd/workspaces.go | 20 +++++++++++--------- codersdk/workspaces.go | 20 +++++++++++--------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 3af777335071a..d4493048e4a29 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -292,14 +292,16 @@ func (api *api) workspaceBuildByName(rw http.ResponseWriter, r *http.Request) { func convertWorkspace(workspace database.Workspace, workspaceBuild codersdk.WorkspaceBuild, template database.Template) codersdk.Workspace { return codersdk.Workspace{ - ID: workspace.ID, - CreatedAt: workspace.CreatedAt, - UpdatedAt: workspace.UpdatedAt, - OwnerID: workspace.OwnerID, - TemplateID: workspace.TemplateID, - LatestBuild: workspaceBuild, - TemplateName: template.Name, - Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(), - Name: workspace.Name, + ID: workspace.ID, + CreatedAt: workspace.CreatedAt, + UpdatedAt: workspace.UpdatedAt, + OwnerID: workspace.OwnerID, + TemplateID: workspace.TemplateID, + LatestBuild: workspaceBuild, + TemplateName: template.Name, + Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(), + Name: workspace.Name, + AutostartSchedule: workspace.AutostartSchedule.String, + AutostopSchedule: workspace.AutostopSchedule.String, } } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 8a7abc4bedcf4..9f42c40738e80 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -15,15 +15,17 @@ import ( // Workspace is a per-user deployment of a template. It tracks // template versions, and can be updated. type Workspace struct { - ID uuid.UUID `json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - OwnerID uuid.UUID `json:"owner_id"` - TemplateID uuid.UUID `json:"template_id"` - TemplateName string `json:"template_name"` - LatestBuild WorkspaceBuild `json:"latest_build"` - Outdated bool `json:"outdated"` - Name string `json:"name"` + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + OwnerID uuid.UUID `json:"owner_id"` + TemplateID uuid.UUID `json:"template_id"` + TemplateName string `json:"template_name"` + LatestBuild WorkspaceBuild `json:"latest_build"` + Outdated bool `json:"outdated"` + Name string `json:"name"` + AutostartSchedule string `json:"autostart_schedule"` + AutostopSchedule string `json:"autostop_schedule"` } // CreateWorkspaceBuildRequest provides options to update the latest workspace build. From f6895b7b7ffd2c58f8f0d758cbe8dee562f4fe9f Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 5 Apr 2022 14:46:41 +0000 Subject: [PATCH 06/16] feat: implement update workspace autostart --- coderd/coderd.go | 3 +++ coderd/workspaces.go | 26 +++++++++++++++++++++++ coderd/workspaces_test.go | 43 +++++++++++++++++++++++++++++++++++++++ codersdk/workspaces.go | 21 +++++++++++++++++++ 4 files changed, 93 insertions(+) diff --git a/coderd/coderd.go b/coderd/coderd.go index 7dcc8041db9d4..e7689c5548338 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -184,6 +184,9 @@ func New(options *Options) (http.Handler, func()) { r.Post("/", api.postWorkspaceBuilds) r.Get("/{workspacebuildname}", api.workspaceBuildByName) }) + r.Route("/autostart", func(r chi.Router) { + r.Put("/", api.putWorkspaceAutostart) + }) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { r.Use( diff --git a/coderd/workspaces.go b/coderd/workspaces.go index d4493048e4a29..42634c2daa5af 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -290,6 +290,32 @@ func (api *api) workspaceBuildByName(rw http.ResponseWriter, r *http.Request) { render.JSON(rw, r, convertWorkspaceBuild(workspaceBuild, convertProvisionerJob(job))) } +func (api *api) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { + var spec codersdk.UpdateWorkspaceAutostartRequest + if !httpapi.Read(rw, r, &spec) { + return + } + + workspace := httpmw.WorkspaceParam(r) + err := api.Database.UpdateWorkspaceAutostart(r.Context(), database.UpdateWorkspaceAutostartParams{ + ID: workspace.ID, + AutostartSchedule: sql.NullString{ + String: spec.Schedule, + Valid: true, + }, + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("update workspace autostart schedule: %s", err), + }) + return + } + + return +} + +// TODO(cian): api.updateWorkspaceAutostop + func convertWorkspace(workspace database.Workspace, workspaceBuild codersdk.WorkspaceBuild, template database.Template) codersdk.Workspace { return codersdk.Workspace{ ID: workspace.ID, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 8568bd4e97c48..d54951ba291f2 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4,10 +4,12 @@ import ( "context" "net/http" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/coder/coder/coderd/autostart/schedule" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" @@ -182,3 +184,44 @@ func TestWorkspaceBuildByName(t *testing.T) { require.NoError(t, err) }) } + +func TestWorkspaceUpdateAutostart(t *testing.T) { + // fri -> monday + // TODO(cian): mon -> tue + // TODO(cian): CST -> CDT + // TODO(cian): CDT -> CST + t.Parallel() + var ( + ctx = context.Background() + client = coderdtest.New(t, nil) + _ = coderdtest.NewProvisionerDaemon(t, client) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitProjectVersionJob(t, client, version.ID) + project = coderdtest.CreateProject(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) + ) + + require.Empty(t, workspace.AutostartSchedule, "expected newly-minted workspace to have no autostart schedule") + + schedSpec := "CRON_TZ=Europe/Dublin 30 9 1-5" + err := client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ + Schedule: schedSpec, + }) + require.NoError(t, err, "expected no error") + + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + + require.Equal(t, schedSpec, updated.AutostartSchedule, "expected autostart schedule to equal") + + sched, err := schedule.Weekly(updated.AutostartSchedule) + require.NoError(t, err, "parse returned schedule") + + dublinLoc, err := time.LoadLocation("Europe/Dublin") + require.NoError(t, err, "failed to load timezone location") + + timeAt := time.Date(2022, 5, 6, 9, 31, 0, 0, dublinLoc) + expectedNext := time.Date(2022, 5, 9, 9, 30, 0, 0, dublinLoc) + require.Equal(t, expectedNext, sched.Next(timeAt), "unexpected next scheduled autostart time") +} diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 9f42c40738e80..dd44de3bc6afb 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" ) @@ -88,3 +89,23 @@ func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID, var workspaceBuild WorkspaceBuild return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild) } + +type UpdateWorkspaceAutostartRequest struct { + Schedule string +} + +func (c *Client) UpdateWorkspaceAutostart(ctx context.Context, id uuid.UUID, req UpdateWorkspaceAutostartRequest) error { + path := fmt.Sprintf("/api/v2/workspaces/%s/autostart", id.String()) + res, err := c.request(ctx, http.MethodPut, path, req) + if err != nil { + return xerrors.Errorf("update workspace autostart: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return readBodyAsError(res) + } + // TODO(cian): should we return the updated schedule? + return nil +} + +// TODO(cian): client.UpdateWorkspaceAutostop From 1884766a6ddc5a3109cd9d228b07f9e7913b76ab Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 5 Apr 2022 16:51:10 +0000 Subject: [PATCH 07/16] refactor: make test table-driven --- coderd/workspaces_test.go | 90 +++++++++++++++++++++++++-------------- 1 file changed, 59 insertions(+), 31 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index d54951ba291f2..b277d1fa9df9c 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -190,38 +190,66 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { // TODO(cian): mon -> tue // TODO(cian): CST -> CDT // TODO(cian): CDT -> CST - t.Parallel() - var ( - ctx = context.Background() - client = coderdtest.New(t, nil) - _ = coderdtest.NewProvisionerDaemon(t, client) - user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitProjectVersionJob(t, client, version.ID) - project = coderdtest.CreateProject(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) - ) - - require.Empty(t, workspace.AutostartSchedule, "expected newly-minted workspace to have no autostart schedule") - - schedSpec := "CRON_TZ=Europe/Dublin 30 9 1-5" - err := client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ - Schedule: schedSpec, - }) - require.NoError(t, err, "expected no error") - - updated, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err, "fetch updated workspace") - require.Equal(t, schedSpec, updated.AutostartSchedule, "expected autostart schedule to equal") - - sched, err := schedule.Weekly(updated.AutostartSchedule) - require.NoError(t, err, "parse returned schedule") + var dublinLoc = mustLocation(t, "Europe/Dublin") + + testCases := []struct { + name string + schedule string + expectedError string + at time.Time + expectedNext time.Time + }{ + { + name: "friday to monday", + schedule: "CRON_TZ=Europe/Dublin 30 9 1-5", + expectedError: "", + at: time.Date(2022, 5, 6, 9, 31, 0, 0, dublinLoc), + expectedNext: time.Date(2022, 5, 9, 9, 30, 0, 0, dublinLoc), + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + var ( + ctx = context.Background() + client = coderdtest.New(t, nil) + _ = coderdtest.NewProvisionerDaemon(t, client) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitProjectVersionJob(t, client, version.ID) + project = coderdtest.CreateProject(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) + ) + + // ensure test invariant: new workspaces have no autostart schedule. + require.Empty(t, workspace.AutostartSchedule, "expected newly-minted workspace to have no autostart schedule") + + err := client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ + Schedule: testCase.schedule, + }) + require.NoError(t, err, "expected no error setting workspace autostart schedule") + + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + + require.Equal(t, testCase.schedule, updated.AutostartSchedule, "expected autostart schedule to equal requested") + + sched, err := schedule.Weekly(updated.AutostartSchedule) + require.NoError(t, err, "parse returned schedule") + + next := sched.Next(testCase.at) + require.Equal(t, testCase.expectedNext, next, "unexpected next scheduled autostart time") + }) + } +} - dublinLoc, err := time.LoadLocation("Europe/Dublin") - require.NoError(t, err, "failed to load timezone location") +func mustLocation(t *testing.T, location string) *time.Location { + loc, err := time.LoadLocation(location) + if err != nil { + t.Errorf("failed to load location %s: %s", location, err.Error()) + } - timeAt := time.Date(2022, 5, 6, 9, 31, 0, 0, dublinLoc) - expectedNext := time.Date(2022, 5, 9, 9, 30, 0, 0, dublinLoc) - require.Equal(t, expectedNext, sched.Next(timeAt), "unexpected next scheduled autostart time") + return loc } From 9854f9b6ddf479ac7ecfa84a2bbdc7bda304b55d Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 5 Apr 2022 19:25:43 +0000 Subject: [PATCH 08/16] fix: autostart: add more test cases --- coderd/workspaces.go | 25 ++++++++++++++++++------- coderd/workspaces_test.go | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 42634c2daa5af..503ecfff4be1e 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -13,6 +13,7 @@ import ( "github.com/moby/moby/pkg/namesgenerator" "golang.org/x/xerrors" + "github.com/coder/coder/coderd/autostart/schedule" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" @@ -291,18 +292,28 @@ func (api *api) workspaceBuildByName(rw http.ResponseWriter, r *http.Request) { } func (api *api) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { - var spec codersdk.UpdateWorkspaceAutostartRequest - if !httpapi.Read(rw, r, &spec) { + var req codersdk.UpdateWorkspaceAutostartRequest + 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 autostart schedule: %s", err), + }) + return + } + dbSched.String = validSched.String() + dbSched.Valid = true + } + workspace := httpmw.WorkspaceParam(r) err := api.Database.UpdateWorkspaceAutostart(r.Context(), database.UpdateWorkspaceAutostartParams{ - ID: workspace.ID, - AutostartSchedule: sql.NullString{ - String: spec.Schedule, - Valid: true, - }, + ID: workspace.ID, + AutostartSchedule: dbSched, }) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index b277d1fa9df9c..3ac8f202f548b 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -186,8 +186,6 @@ func TestWorkspaceBuildByName(t *testing.T) { } func TestWorkspaceUpdateAutostart(t *testing.T) { - // fri -> monday - // TODO(cian): mon -> tue // TODO(cian): CST -> CDT // TODO(cian): CDT -> CST @@ -200,6 +198,11 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { at time.Time expectedNext time.Time }{ + { + name: "disable autostart", + schedule: "", + expectedError: "", + }, { name: "friday to monday", schedule: "CRON_TZ=Europe/Dublin 30 9 1-5", @@ -207,6 +210,23 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { at: time.Date(2022, 5, 6, 9, 31, 0, 0, dublinLoc), expectedNext: time.Date(2022, 5, 9, 9, 30, 0, 0, dublinLoc), }, + { + name: "monday to tuesday", + schedule: "CRON_TZ=Europe/Dublin 30 9 1-5", + expectedError: "", + at: time.Date(2022, 5, 9, 9, 31, 0, 0, dublinLoc), + expectedNext: time.Date(2022, 5, 10, 9, 30, 0, 0, dublinLoc), + }, + { + name: "invalid location", + schedule: "CRON_TZ=Imaginary/Place 30 9 1-5", + expectedError: "status code 500: invalid autostart 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 autostart schedule: parse schedule: failed to parse int from asdf: strconv.Atoi: parsing "asdf": invalid syntax`, + }, } for _, testCase := range testCases { @@ -229,6 +249,12 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { err := client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ Schedule: testCase.schedule, }) + + if testCase.expectedError != "" { + require.EqualError(t, err, testCase.expectedError, "unexpected error when setting workspace autostart schedule") + return + } + require.NoError(t, err, "expected no error setting workspace autostart schedule") updated, err := client.Workspace(ctx, workspace.ID) @@ -236,6 +262,9 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { require.Equal(t, testCase.schedule, updated.AutostartSchedule, "expected autostart schedule to equal requested") + if testCase.schedule == "" { + return + } sched, err := schedule.Weekly(updated.AutostartSchedule) require.NoError(t, err, "parse returned schedule") From 270af35bc9fc5afb4223d6b99d4a66e3195b40ae Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 5 Apr 2022 19:44:26 +0000 Subject: [PATCH 09/16] fix: autostart: ensure we handle DST changes properly - add unit tests to and from DST - ensure expected interval between autostarts --- coderd/workspaces_test.go | 56 ++++++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 3ac8f202f548b..b1ccae566fa5e 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -186,17 +186,15 @@ func TestWorkspaceBuildByName(t *testing.T) { } func TestWorkspaceUpdateAutostart(t *testing.T) { - // TODO(cian): CST -> CDT - // TODO(cian): CDT -> CST - var dublinLoc = mustLocation(t, "Europe/Dublin") testCases := []struct { - name string - schedule string - expectedError string - at time.Time - expectedNext time.Time + name string + schedule string + expectedError string + at time.Time + expectedNext time.Time + expectedInterval time.Duration }{ { name: "disable autostart", @@ -204,18 +202,38 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { expectedError: "", }, { - name: "friday to monday", - schedule: "CRON_TZ=Europe/Dublin 30 9 1-5", - expectedError: "", - at: time.Date(2022, 5, 6, 9, 31, 0, 0, dublinLoc), - expectedNext: time.Date(2022, 5, 9, 9, 30, 0, 0, dublinLoc), + name: "friday to monday", + schedule: "CRON_TZ=Europe/Dublin 30 9 1-5", + expectedError: "", + at: time.Date(2022, 5, 6, 9, 31, 0, 0, dublinLoc), + expectedNext: time.Date(2022, 5, 9, 9, 30, 0, 0, dublinLoc), + expectedInterval: 71*time.Hour + 59*time.Minute, }, { - name: "monday to tuesday", - schedule: "CRON_TZ=Europe/Dublin 30 9 1-5", - expectedError: "", - at: time.Date(2022, 5, 9, 9, 31, 0, 0, dublinLoc), - expectedNext: time.Date(2022, 5, 10, 9, 30, 0, 0, dublinLoc), + name: "monday to tuesday", + schedule: "CRON_TZ=Europe/Dublin 30 9 1-5", + expectedError: "", + at: time.Date(2022, 5, 9, 9, 31, 0, 0, dublinLoc), + expectedNext: time.Date(2022, 5, 10, 9, 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 9 *", + expectedError: "", + at: time.Date(2022, 3, 26, 9, 31, 0, 0, dublinLoc), + expectedNext: time.Date(2022, 3, 27, 9, 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 9 *", + expectedError: "", + at: time.Date(2022, 10, 29, 9, 31, 0, 0, dublinLoc), + expectedNext: time.Date(2022, 10, 30, 9, 30, 0, 0, dublinLoc), + expectedInterval: 24*time.Hour + 59*time.Minute, }, { name: "invalid location", @@ -270,6 +288,8 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { next := sched.Next(testCase.at) require.Equal(t, testCase.expectedNext, next, "unexpected next scheduled autostart time") + interval := next.Sub(testCase.at) + require.Equal(t, testCase.expectedInterval, interval, "unexpected interval") }) } } From ff80571eed293b2c414e351459b0808d0aa7ccbd Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 5 Apr 2022 20:38:30 +0000 Subject: [PATCH 10/16] fix: coderd: add test for nonexistent workspace --- coderd/workspaces_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index b1ccae566fa5e..8d95abd99d548 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "context" + "fmt" "net/http" "testing" "time" @@ -292,6 +293,21 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { 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.UpdateWorkspaceAutostartRequest{ + Schedule: "9 30 1-5", + } + ) + + err := client.UpdateWorkspaceAutostart(ctx, wsid, req) + require.EqualError(t, err, fmt.Sprintf("status code 404: workspace %q does not exist", wsid), "unexpected error") + }) } func mustLocation(t *testing.T, location string) *time.Location { From 3b4664cc8e8bf6a0e2e447cfa6770f2aba2c58d0 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 5 Apr 2022 20:41:45 +0000 Subject: [PATCH 11/16] fix: codersdk: add documentation for UpdateWorkspaceAutostartRequest / UpdateWorkspaceAutostart --- codersdk/workspaces.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index dd44de3bc6afb..6da25624c9fb4 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -90,10 +90,13 @@ func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID, return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild) } +// UpdateWorkspaceAutostartRequest is a request to update a workspace's autostart schedule. type UpdateWorkspaceAutostartRequest struct { Schedule string } +// UpdateWorkspaceAutostart sets the autostart schedule for workspace by id. +// If the provided schedule is empty, autostart is disabled for the workspace. func (c *Client) UpdateWorkspaceAutostart(ctx context.Context, id uuid.UUID, req UpdateWorkspaceAutostartRequest) error { path := fmt.Sprintf("/api/v2/workspaces/%s/autostart", id.String()) res, err := c.request(ctx, http.MethodPut, path, req) From ccd67cce52ff2cd90be4004491f8399ead1b7b7e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 5 Apr 2022 20:51:11 +0000 Subject: [PATCH 12/16] fix: appease the linter gods --- coderd/database/databasefake/databasefake.go | 4 ++-- coderd/workspaces.go | 2 -- coderd/workspaces_test.go | 2 ++ 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 342945a4fb36d..6ec73168912cd 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1210,7 +1210,7 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCompleteByID(_ context.Context, ar return sql.ErrNoRows } -func (q *fakeQuerier) UpdateWorkspaceAutostart(ctx context.Context, arg database.UpdateWorkspaceAutostartParams) error { +func (q *fakeQuerier) UpdateWorkspaceAutostart(_ context.Context, arg database.UpdateWorkspaceAutostartParams) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -1226,7 +1226,7 @@ func (q *fakeQuerier) UpdateWorkspaceAutostart(ctx context.Context, arg database return sql.ErrNoRows } -func (q *fakeQuerier) UpdateWorkspaceAutostop(ctx context.Context, arg database.UpdateWorkspaceAutostopParams) error { +func (q *fakeQuerier) UpdateWorkspaceAutostop(_ context.Context, arg database.UpdateWorkspaceAutostopParams) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 503ecfff4be1e..5a4ba696bf7ba 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -321,8 +321,6 @@ func (api *api) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { }) return } - - return } // TODO(cian): api.updateWorkspaceAutostop diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 8d95abd99d548..7f0b248c4b19c 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -187,6 +187,7 @@ func TestWorkspaceBuildByName(t *testing.T) { } func TestWorkspaceUpdateAutostart(t *testing.T) { + t.Parallel() var dublinLoc = mustLocation(t, "Europe/Dublin") testCases := []struct { @@ -251,6 +252,7 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { 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) From 316501dc2f14243f879bcac640770c70cb15ca39 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 6 Apr 2022 09:37:23 +0000 Subject: [PATCH 13/16] fix: address PR comments --- .../migrations/000005_workspace_autostartstop.down.sql | 4 ++-- .../migrations/000005_workspace_autostartstop.up.sql | 4 ++-- coderd/workspaces_test.go | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/coderd/database/migrations/000005_workspace_autostartstop.down.sql b/coderd/database/migrations/000005_workspace_autostartstop.down.sql index bb975bb8485c1..e2eea6cd4c50b 100644 --- a/coderd/database/migrations/000005_workspace_autostartstop.down.sql +++ b/coderd/database/migrations/000005_workspace_autostartstop.down.sql @@ -1,3 +1,3 @@ ALTER TABLE ONLY workspaces - DROP COLUMN autostart_schedule, - DROP COLUMN autostop_schedule; + DROP COLUMN IF EXISTS autostart_schedule, + DROP COLUMN IF EXISTS autostop_schedule; diff --git a/coderd/database/migrations/000005_workspace_autostartstop.up.sql b/coderd/database/migrations/000005_workspace_autostartstop.up.sql index ab817d3585231..19c126b768fc1 100644 --- a/coderd/database/migrations/000005_workspace_autostartstop.up.sql +++ b/coderd/database/migrations/000005_workspace_autostartstop.up.sql @@ -1,3 +1,3 @@ ALTER TABLE ONLY workspaces - ADD COLUMN autostart_schedule text DEFAULT NULL, - ADD COLUMN autostop_schedule text DEFAULT NULL; + ADD COLUMN IF NOT EXISTS autostart_schedule text DEFAULT NULL, + ADD COLUMN IF NOT EXISTS autostop_schedule text DEFAULT NULL; diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 7f0b248c4b19c..5eb02b6415126 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -308,7 +308,10 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { ) err := client.UpdateWorkspaceAutostart(ctx, wsid, req) - require.EqualError(t, err, fmt.Sprintf("status code 404: workspace %q does not exist", wsid), "unexpected error") + require.IsType(t, err, &codersdk.Error{}, "expected codersdk.Error") + coderSDKErr, _ := err.(*codersdk.Error) + 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") }) } From 3037d98896fabf20b43d56db7c0a43e139ae05dc Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 6 Apr 2022 20:06:26 +0000 Subject: [PATCH 14/16] fix: workspaces_test.go: missed project -> template after rebase --- coderd/workspaces_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 5eb02b6415126..ff9415ea84e4c 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -258,9 +258,9 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { client = coderdtest.New(t, nil) _ = coderdtest.NewProvisionerDaemon(t, client) user = coderdtest.CreateFirstUser(t, client) - version = coderdtest.CreateProjectVersion(t, client, user.OrganizationID, nil) - _ = coderdtest.AwaitProjectVersionJob(t, client, version.ID) - project = coderdtest.CreateProject(t, client, user.OrganizationID, version.ID) + 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) ) From 4a7e6ebea76342fd85bd5dbff026cdc7c2043848 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 6 Apr 2022 20:12:10 +0000 Subject: [PATCH 15/16] fix: bump migration --- ...startstop.down.sql => 000006_workspace_autostartstop.down.sql} | 0 ...autostartstop.up.sql => 000006_workspace_autostartstop.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000005_workspace_autostartstop.down.sql => 000006_workspace_autostartstop.down.sql} (100%) rename coderd/database/migrations/{000005_workspace_autostartstop.up.sql => 000006_workspace_autostartstop.up.sql} (100%) diff --git a/coderd/database/migrations/000005_workspace_autostartstop.down.sql b/coderd/database/migrations/000006_workspace_autostartstop.down.sql similarity index 100% rename from coderd/database/migrations/000005_workspace_autostartstop.down.sql rename to coderd/database/migrations/000006_workspace_autostartstop.down.sql diff --git a/coderd/database/migrations/000005_workspace_autostartstop.up.sql b/coderd/database/migrations/000006_workspace_autostartstop.up.sql similarity index 100% rename from coderd/database/migrations/000005_workspace_autostartstop.up.sql rename to coderd/database/migrations/000006_workspace_autostartstop.up.sql From be0e6f3acc901a8bec107cb8477fa04b88b25407 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 6 Apr 2022 20:26:18 +0000 Subject: [PATCH 16/16] feat: implement autostop database/http/api plumbing --- coderd/coderd.go | 3 + coderd/workspaces.go | 32 +++++++++- coderd/workspaces_test.go | 131 +++++++++++++++++++++++++++++++++++++- codersdk/workspaces.go | 21 +++++- 4 files changed, 183 insertions(+), 4 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index e7689c5548338..ebf8b810ea560 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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( diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 5a4ba696bf7ba..2266202d937cb 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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{ diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index ff9415ea84e4c..8032656c62a25 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -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") }) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 6da25624c9fb4..b066b41285ca7 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -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 +}