coderd: autostart: codersdk, http api, database plumbing (#879)

* feat: add columns autostart_schedule, autostop_schedule to database schema
* feat: database: add UpdateWorkspaceAutostart and UpdateWorkspaceAutostop methods
* feat: add AutostartSchedule/AutostopSchedule to api workspace struct
* feat: codersdk: implement update workspace autostart and autostop methods
* chore: add unit tests for workspace autostarat and autostop methods
This commit is contained in:
Cian Johnston 2022-04-07 10:03:35 +01:00 committed by GitHub
parent c1ff537beb
commit 23f989127d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 537 additions and 43 deletions

View File

@ -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(

View File

@ -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

View File

@ -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);

View File

@ -0,0 +1,3 @@
ALTER TABLE ONLY workspaces
DROP COLUMN IF EXISTS autostart_schedule,
DROP COLUMN IF EXISTS autostop_schedule;

View File

@ -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;

View File

@ -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 {

View File

@ -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
}

View File

@ -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

View File

@ -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;

View File

@ -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 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,
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,
AutostartSchedule: workspace.AutostartSchedule.String,
AutostopSchedule: workspace.AutostopSchedule.String,
}
}

View File

@ -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
}

View File

@ -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
}