diff --git a/coderd/coderd.go b/coderd/coderd.go index 7dcc8041db9d4..ebf8b810ea560 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -184,6 +184,12 @@ 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("/autostop", func(r chi.Router) { + r.Put("/", api.putWorkspaceAutostop) + }) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { r.Use( diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index b21256e24bff9..6ec73168912cd 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(_ 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(_ 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 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/000006_workspace_autostartstop.down.sql b/coderd/database/migrations/000006_workspace_autostartstop.down.sql new file mode 100644 index 0000000000000..e2eea6cd4c50b --- /dev/null +++ b/coderd/database/migrations/000006_workspace_autostartstop.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE ONLY workspaces + DROP COLUMN IF EXISTS autostart_schedule, + DROP COLUMN IF EXISTS autostop_schedule; diff --git a/coderd/database/migrations/000006_workspace_autostartstop.up.sql b/coderd/database/migrations/000006_workspace_autostartstop.up.sql new file mode 100644 index 0000000000000..19c126b768fc1 --- /dev/null +++ b/coderd/database/migrations/000006_workspace_autostartstop.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE ONLY workspaces + ADD COLUMN IF NOT EXISTS autostart_schedule text DEFAULT NULL, + ADD COLUMN IF NOT EXISTS 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/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 } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 84a470f02fdfd..f0d620d2e64bf 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,10 +2761,50 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar &i.TemplateID, &i.Deleted, &i.Name, + &i.AutostartSchedule, + &i.AutostopSchedule, ) 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; diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 3af777335071a..2266202d937cb 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" @@ -290,16 +291,82 @@ 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 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: dbSched, + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("update workspace autostart schedule: %s", err), + }) + return + } +} + +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{ - 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/coderd/workspaces_test.go b/coderd/workspaces_test.go index 8568bd4e97c48..8032656c62a25 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2,12 +2,15 @@ package coderd_test import ( "context" + "fmt" "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 +185,270 @@ func TestWorkspaceBuildByName(t *testing.T) { require.NoError(t, err) }) } + +func TestWorkspaceUpdateAutostart(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 autostart", + schedule: "", + 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), + 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), + 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", + 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 { + 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 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, + }) + + 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) + require.NoError(t, err, "fetch updated workspace") + + 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") + + 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") + }) + } + + 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.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") + }) +} + +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") + }) +} + +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()) + } + + return loc +} diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 8a7abc4bedcf4..b066b41285ca7 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" ) @@ -15,15 +16,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. @@ -86,3 +89,43 @@ func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID, var workspaceBuild WorkspaceBuild 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) + if err != nil { + return xerrors.Errorf("update workspace autostart: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return readBodyAsError(res) + } + return nil +} + +// 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 +}