mirror of https://github.com/coder/coder.git
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:
parent
c1ff537beb
commit
23f989127d
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE ONLY workspaces
|
||||
DROP COLUMN IF EXISTS autostart_schedule,
|
||||
DROP COLUMN IF EXISTS autostop_schedule;
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue