refactor: workspace autostop_schedule -> ttl (#1578)

Co-authored-by: G r e y <grey@coder.com>
This commit is contained in:
Cian Johnston
2022-05-19 20:09:27 +01:00
committed by GitHub
parent 6c1117094d
commit d72c45e483
31 changed files with 549 additions and 490 deletions

View File

@ -123,6 +123,22 @@ func convertDiffType(left, right any) (newLeft, newRight any, changed bool) {
return leftStr, rightStr, true
case sql.NullInt64:
var leftInt64Ptr *int64
var rightInt64Ptr *int64
if !typed.Valid {
leftInt64Ptr = nil
} else {
leftInt64Ptr = ptr(typed.Int64)
}
rightInt64Ptr = ptr(right.(sql.NullInt64).Int64)
if !right.(sql.NullInt64).Valid {
rightInt64Ptr = nil
}
return leftInt64Ptr, rightInt64Ptr, true
default:
return left, right, false
}
@ -147,3 +163,7 @@ func derefPointer(ptr reflect.Value) reflect.Value {
return ptr
}
func ptr[T any](x T) *T {
return &x
}

View File

@ -172,7 +172,7 @@ func TestDiff(t *testing.T) {
TemplateID: uuid.UUID{3},
Name: "rust workspace",
AutostartSchedule: sql.NullString{String: "0 12 * * 1-5", Valid: true},
AutostopSchedule: sql.NullString{String: "0 2 * * 2-6", Valid: true},
Ttl: sql.NullInt64{Int64: int64(8 * time.Hour), Valid: true},
},
exp: audit.Map{
"id": uuid.UUID{1}.String(),
@ -180,7 +180,7 @@ func TestDiff(t *testing.T) {
"template_id": uuid.UUID{3}.String(),
"name": "rust workspace",
"autostart_schedule": "0 12 * * 1-5",
"autostop_schedule": "0 2 * * 2-6",
"ttl": int64(28800000000000), // XXX: pq still does not support time.Duration
},
},
{
@ -194,7 +194,7 @@ func TestDiff(t *testing.T) {
TemplateID: uuid.UUID{3},
Name: "rust workspace",
AutostartSchedule: sql.NullString{},
AutostopSchedule: sql.NullString{},
Ttl: sql.NullInt64{},
},
exp: audit.Map{
"id": uuid.UUID{1}.String(),

View File

@ -101,7 +101,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
"deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired.
"name": ActionTrack,
"autostart_schedule": ActionTrack,
"autostop_schedule": ActionTrack,
"ttl": ActionTrack,
},
})

View File

@ -50,7 +50,7 @@ func (e *Executor) Run() {
func (e *Executor) runOnce(t time.Time) error {
currentTick := t.Truncate(time.Minute)
return e.db.InTx(func(db database.Store) error {
eligibleWorkspaces, err := db.GetWorkspacesAutostartAutostop(e.ctx)
eligibleWorkspaces, err := db.GetWorkspacesAutostart(e.ctx)
if err != nil {
return xerrors.Errorf("get eligible workspaces for autostart or autostop: %w", err)
}
@ -84,21 +84,25 @@ func (e *Executor) runOnce(t time.Time) error {
}
var validTransition database.WorkspaceTransition
var sched *schedule.Schedule
var nextTransition time.Time
switch priorHistory.Transition {
case database.WorkspaceTransitionStart:
validTransition = database.WorkspaceTransitionStop
sched, err = schedule.Weekly(ws.AutostopSchedule.String)
if err != nil {
e.log.Warn(e.ctx, "workspace has invalid autostop schedule, skipping",
if !ws.Ttl.Valid || ws.Ttl.Int64 == 0 {
e.log.Debug(e.ctx, "invalid or zero ws ttl, skipping",
slog.F("workspace_id", ws.ID),
slog.F("autostart_schedule", ws.AutostopSchedule.String),
slog.F("ttl", time.Duration(ws.Ttl.Int64)),
)
continue
}
ttl := time.Duration(ws.Ttl.Int64)
// Measure TTL from the time the workspace finished building.
// Truncate to nearest minute for consistency with autostart
// behavior, and add one minute for padding.
nextTransition = priorHistory.UpdatedAt.Truncate(time.Minute).Add(ttl + time.Minute)
case database.WorkspaceTransitionStop:
validTransition = database.WorkspaceTransitionStart
sched, err = schedule.Weekly(ws.AutostartSchedule.String)
sched, err := schedule.Weekly(ws.AutostartSchedule.String)
if err != nil {
e.log.Warn(e.ctx, "workspace has invalid autostart schedule, skipping",
slog.F("workspace_id", ws.ID),
@ -106,6 +110,9 @@ func (e *Executor) runOnce(t time.Time) error {
)
continue
}
// Round down to the nearest minute, as this is the finest granularity cron supports.
// Truncate is probably not necessary here, but doing it anyway to be sure.
nextTransition = sched.Next(priorHistory.CreatedAt).Truncate(time.Minute)
default:
e.log.Debug(e.ctx, "last transition not valid for autostart or autostop",
slog.F("workspace_id", ws.ID),
@ -114,13 +121,10 @@ func (e *Executor) runOnce(t time.Time) error {
continue
}
// Round time down to the nearest minute, as this is the finest granularity cron supports.
// Truncate is probably not necessary here, but doing it anyway to be sure.
nextTransitionAt := sched.Next(priorHistory.CreatedAt).Truncate(time.Minute)
if currentTick.Before(nextTransitionAt) {
if currentTick.Before(nextTransition) {
e.log.Debug(e.ctx, "skipping workspace: too early",
slog.F("workspace_id", ws.ID),
slog.F("next_transition_at", nextTransitionAt),
slog.F("next_transition_at", nextTransition),
slog.F("transition", validTransition),
slog.F("current_tick", currentTick),
)

View File

@ -194,27 +194,27 @@ func TestExecutorAutostopOK(t *testing.T) {
})
// Given: we have a user with a workspace
workspace = mustProvisionWorkspace(t, client)
ttl = time.Minute
)
// Given: workspace is running
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
// Given: the workspace initially has autostop disabled
require.Empty(t, workspace.AutostopSchedule)
require.Nil(t, workspace.TTL)
// When: we enable workspace autostop
sched, err := schedule.Weekly("* * * * *")
require.NoError(t, err)
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
Schedule: sched.String(),
require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: &ttl,
}))
// When: the autobuild executor ticks
// When: the autobuild executor ticks *after* the TTL:
go func() {
tickCh <- time.Now().UTC().Add(time.Minute)
tickCh <- time.Now().UTC().Add(ttl + time.Minute)
close(tickCh)
}()
// Then: the workspace should be started
// Then: the workspace should be stopped
<-time.After(5 * time.Second)
ws := mustWorkspace(t, client, workspace.ID)
require.NotEqual(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected a workspace build to occur")
@ -234,24 +234,24 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) {
})
// Given: we have a user with a workspace
workspace = mustProvisionWorkspace(t, client)
ttl = time.Minute
)
// Given: workspace is stopped
workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
// Given: the workspace initially has autostop disabled
require.Empty(t, workspace.AutostopSchedule)
require.Nil(t, workspace.TTL)
// When: we enable workspace autostart
sched, err := schedule.Weekly("* * * * *")
// When: we set the TTL on the workspace
require.NoError(t, err)
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
Schedule: sched.String(),
require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: &ttl,
}))
// When: the autobuild executor ticks
// When: the autobuild executor ticks past the TTL
go func() {
tickCh <- time.Now().UTC().Add(time.Minute)
tickCh <- time.Now().UTC().Add(ttl)
close(tickCh)
}()
@ -278,7 +278,7 @@ func TestExecutorAutostopNotEnabled(t *testing.T) {
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
// Given: the workspace has autostop disabled
require.Empty(t, workspace.AutostopSchedule)
require.Empty(t, workspace.TTL)
// When: the autobuild executor ticks
go func() {
@ -308,12 +308,12 @@ func TestExecutorWorkspaceDeleted(t *testing.T) {
)
// Given: the workspace initially has autostart disabled
require.Empty(t, workspace.AutostopSchedule)
require.Empty(t, workspace.AutostartSchedule)
// When: we enable workspace autostart
sched, err := schedule.Weekly("* * * * *")
require.NoError(t, err)
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
Schedule: sched.String(),
}))
@ -333,7 +333,7 @@ func TestExecutorWorkspaceDeleted(t *testing.T) {
require.Equal(t, codersdk.WorkspaceTransitionDelete, ws.LatestBuild.Transition, "expected workspace to be deleted")
}
func TestExecutorWorkspaceTooEarly(t *testing.T) {
func TestExecutorWorkspaceAutostartTooEarly(t *testing.T) {
t.Parallel()
var (
@ -348,14 +348,14 @@ func TestExecutorWorkspaceTooEarly(t *testing.T) {
)
// Given: the workspace initially has autostart disabled
require.Empty(t, workspace.AutostopSchedule)
require.Empty(t, workspace.AutostartSchedule)
// When: we enable workspace autostart with some time in the future
futureTime := time.Now().Add(time.Hour)
futureTimeCron := fmt.Sprintf("%d %d * * *", futureTime.Minute(), futureTime.Hour())
sched, err := schedule.Weekly(futureTimeCron)
require.NoError(t, err)
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
Schedule: sched.String(),
}))
@ -372,6 +372,41 @@ func TestExecutorWorkspaceTooEarly(t *testing.T) {
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
}
func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
t.Parallel()
var (
ctx = context.Background()
tickCh = make(chan time.Time)
client = coderdtest.New(t, &coderdtest.Options{
AutobuildTicker: tickCh,
})
// Given: we have a user with a workspace
workspace = mustProvisionWorkspace(t, client)
ttl = time.Hour
)
// Given: the workspace initially has TTL unset
require.Nil(t, workspace.TTL)
// When: we set the TTL to some time in the distant future
require.NoError(t, client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: &ttl,
}))
// When: the autobuild executor ticks
go func() {
tickCh <- time.Now().UTC()
close(tickCh)
}()
// Then: nothing should happen
<-time.After(5 * time.Second)
ws := mustWorkspace(t, client, workspace.ID)
require.Equal(t, workspace.LatestBuild.ID, ws.LatestBuild.ID, "expected no further workspace builds to occur")
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
}
func TestExecutorAutostartMultipleOK(t *testing.T) {
if os.Getenv("DB") == "" {
t.Skip(`This test only really works when using a "real" database, similar to a HA setup`)

View File

@ -305,8 +305,8 @@ 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("/ttl", func(r chi.Router) {
r.Put("/", api.putWorkspaceTTL)
})
r.Get("/watch", api.watchWorkspace)
})

View File

@ -363,14 +363,14 @@ func (q *fakeQuerier) GetWorkspaceByOwnerIDAndName(_ context.Context, arg databa
return database.Workspace{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetWorkspacesAutostartAutostop(_ context.Context) ([]database.Workspace, error) {
func (q *fakeQuerier) GetWorkspacesAutostart(_ context.Context) ([]database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
workspaces := make([]database.Workspace, 0)
for _, ws := range q.workspaces {
if ws.AutostartSchedule.String != "" {
workspaces = append(workspaces, ws)
} else if ws.AutostopSchedule.String != "" {
} else if ws.Ttl.Valid {
workspaces = append(workspaces, ws)
}
}
@ -1666,7 +1666,7 @@ func (q *fakeQuerier) UpdateWorkspaceAutostart(_ context.Context, arg database.U
return sql.ErrNoRows
}
func (q *fakeQuerier) UpdateWorkspaceAutostop(_ context.Context, arg database.UpdateWorkspaceAutostopParams) error {
func (q *fakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateWorkspaceTTLParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
@ -1674,7 +1674,7 @@ func (q *fakeQuerier) UpdateWorkspaceAutostop(_ context.Context, arg database.Up
if workspace.ID != arg.ID {
continue
}
workspace.AutostopSchedule = arg.AutostopSchedule
workspace.Ttl = arg.Ttl
q.workspaces[index] = workspace
return nil
}

View File

@ -314,7 +314,7 @@ CREATE TABLE workspaces (
deleted boolean DEFAULT false NOT NULL,
name character varying(64) NOT NULL,
autostart_schedule text,
autostop_schedule text
ttl bigint
);
ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('public.licenses_id_seq'::regclass);
@ -483,4 +483,3 @@ ALTER TABLE ONLY workspaces
ALTER TABLE ONLY workspaces
ADD CONSTRAINT workspaces_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE RESTRICT;

View File

@ -0,0 +1,2 @@
ALTER TABLE ONLY workspaces DROP COLUMN ttl;
ALTER TABLE ONLY workspaces ADD COLUMN autostop_schedule text DEFAULT NULL;

View File

@ -0,0 +1,2 @@
ALTER TABLE ONLY workspaces DROP COLUMN autostop_schedule;
ALTER TABLE ONLY workspaces ADD COLUMN ttl BIGINT DEFAULT NULL;

View File

@ -471,7 +471,7 @@ type Workspace struct {
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"`
Ttl sql.NullInt64 `db:"ttl" json:"ttl"`
}
type WorkspaceAgent struct {

View File

@ -70,7 +70,7 @@ type querier interface {
GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error)
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error)
GetWorkspacesAutostartAutostop(ctx context.Context) ([]Workspace, error)
GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error)
GetWorkspacesByOrganizationIDs(ctx context.Context, arg GetWorkspacesByOrganizationIDsParams) ([]Workspace, error)
GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error)
GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error)
@ -109,9 +109,9 @@ type querier interface {
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, 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
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error
}
var _ querier = (*sqlQuerier)(nil)

View File

@ -3193,7 +3193,7 @@ func (q *sqlQuerier) InsertWorkspaceResource(ctx context.Context, arg InsertWork
const getWorkspaceByID = `-- name: GetWorkspaceByID :one
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
FROM
workspaces
WHERE
@ -3215,14 +3215,14 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
)
return i, err
}
const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
FROM
workspaces
WHERE
@ -3250,7 +3250,7 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
)
return i, err
}
@ -3295,23 +3295,23 @@ func (q *sqlQuerier) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, i
return items, nil
}
const getWorkspacesAutostartAutostop = `-- name: GetWorkspacesAutostartAutostop :many
const getWorkspacesAutostart = `-- name: GetWorkspacesAutostart :many
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
FROM
workspaces
WHERE
deleted = false
AND
(
autostart_schedule <> ''
(autostart_schedule IS NOT NULL AND autostart_schedule <> '')
OR
autostop_schedule <> ''
(ttl IS NOT NULL AND ttl > 0)
)
`
func (q *sqlQuerier) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesAutostartAutostop)
func (q *sqlQuerier) GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesAutostart)
if err != nil {
return nil, err
}
@ -3329,7 +3329,7 @@ func (q *sqlQuerier) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Work
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
); err != nil {
return nil, err
}
@ -3345,7 +3345,7 @@ func (q *sqlQuerier) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Work
}
const getWorkspacesByOrganizationIDs = `-- name: GetWorkspacesByOrganizationIDs :many
SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE organization_id = ANY($1 :: uuid [ ]) AND deleted = $2
SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl FROM workspaces WHERE organization_id = ANY($1 :: uuid [ ]) AND deleted = $2
`
type GetWorkspacesByOrganizationIDsParams struct {
@ -3372,7 +3372,7 @@ func (q *sqlQuerier) GetWorkspacesByOrganizationIDs(ctx context.Context, arg Get
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
); err != nil {
return nil, err
}
@ -3389,7 +3389,7 @@ func (q *sqlQuerier) GetWorkspacesByOrganizationIDs(ctx context.Context, arg Get
const getWorkspacesByTemplateID = `-- name: GetWorkspacesByTemplateID :many
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
FROM
workspaces
WHERE
@ -3421,7 +3421,7 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorks
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
); err != nil {
return nil, err
}
@ -3438,7 +3438,7 @@ func (q *sqlQuerier) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorks
const getWorkspacesWithFilter = `-- name: GetWorkspacesWithFilter :many
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
FROM
workspaces
WHERE
@ -3483,7 +3483,7 @@ func (q *sqlQuerier) GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspa
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
); err != nil {
return nil, err
}
@ -3510,7 +3510,7 @@ INSERT INTO
name
)
VALUES
($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule
($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl
`
type InsertWorkspaceParams struct {
@ -3544,7 +3544,7 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.AutostopSchedule,
&i.Ttl,
)
return i, err
}
@ -3568,25 +3568,6 @@ func (q *sqlQuerier) UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWor
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
@ -3605,3 +3586,22 @@ func (q *sqlQuerier) UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateW
_, err := q.db.ExecContext(ctx, updateWorkspaceDeletedByID, arg.ID, arg.Deleted)
return err
}
const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec
UPDATE
workspaces
SET
ttl = $2
WHERE
id = $1
`
type UpdateWorkspaceTTLParams struct {
ID uuid.UUID `db:"id" json:"id"`
Ttl sql.NullInt64 `db:"ttl" json:"ttl"`
}
func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceTTL, arg.ID, arg.Ttl)
return err
}

View File

@ -33,7 +33,7 @@ WHERE
-- name: GetWorkspacesByOrganizationIDs :many
SELECT * FROM workspaces WHERE organization_id = ANY(@ids :: uuid [ ]) AND deleted = @deleted;
-- name: GetWorkspacesAutostartAutostop :many
-- name: GetWorkspacesAutostart :many
SELECT
*
FROM
@ -42,9 +42,9 @@ WHERE
deleted = false
AND
(
autostart_schedule <> ''
(autostart_schedule IS NOT NULL AND autostart_schedule <> '')
OR
autostop_schedule <> ''
(ttl IS NOT NULL AND ttl > 0)
);
-- name: GetWorkspacesByTemplateID :many
@ -107,10 +107,10 @@ SET
WHERE
id = $1;
-- name: UpdateWorkspaceAutostop :exec
-- name: UpdateWorkspaceTTL :exec
UPDATE
workspaces
SET
autostop_schedule = $2
ttl = $2
WHERE
id = $1;

View File

@ -547,38 +547,32 @@ func (api *api) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
}
}
func (api *api) putWorkspaceAutostop(rw http.ResponseWriter, r *http.Request) {
func (api *api) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceWorkspace.
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
var req codersdk.UpdateWorkspaceAutostopRequest
var req codersdk.UpdateWorkspaceTTLRequest
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
var dbTTL sql.NullInt64
if req.TTL != nil && *req.TTL > 0 {
truncated := req.TTL.Truncate(time.Minute)
dbTTL.Int64 = int64(truncated)
dbTTL.Valid = true
}
err := api.Database.UpdateWorkspaceAutostop(r.Context(), database.UpdateWorkspaceAutostopParams{
ID: workspace.ID,
AutostopSchedule: dbSched,
err := api.Database.UpdateWorkspaceTTL(r.Context(), database.UpdateWorkspaceTTLParams{
ID: workspace.ID,
Ttl: dbTTL,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("update workspace autostop schedule: %s", err),
Message: fmt.Sprintf("update workspace ttl: %s", err),
})
return
}
@ -777,6 +771,14 @@ func convertWorkspace(workspace database.Workspace, workspaceBuild codersdk.Work
Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(),
Name: workspace.Name,
AutostartSchedule: workspace.AutostartSchedule.String,
AutostopSchedule: workspace.AutostopSchedule.String,
TTL: convertSQLNullInt64(workspace.Ttl),
}
}
func convertSQLNullInt64(i sql.NullInt64) *time.Duration {
if !i.Valid {
return nil
}
return (*time.Duration)(&i.Int64)
}

View File

@ -551,69 +551,21 @@ func TestWorkspaceUpdateAutostart(t *testing.T) {
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 string
ttl *time.Duration
expectedError string
}{
{
name: "disable autostop",
schedule: "",
name: "disable ttl",
ttl: nil,
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: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix`,
},
{
name: "only 3 values",
schedule: "CRON_TZ=Europe/Dublin 30 9 *",
expectedError: `status code 500: invalid autostop schedule: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix`,
name: "enable ttl",
ttl: ptr(time.Hour),
expectedError: "",
},
}
@ -633,10 +585,10 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
)
// ensure test invariant: new workspaces have no autostop schedule.
require.Empty(t, workspace.AutostopSchedule, "expected newly-minted workspace to have no autstop schedule")
require.Nil(t, workspace.TTL, "expected newly-minted workspace to have no TTL")
err := client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
Schedule: testCase.schedule,
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{
TTL: testCase.ttl,
})
if testCase.expectedError != "" {
@ -649,18 +601,7 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
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")
require.Equal(t, testCase.ttl, updated.TTL, "expected autostop ttl to equal requested")
})
}
@ -670,12 +611,12 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
client = coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
wsid = uuid.New()
req = codersdk.UpdateWorkspaceAutostopRequest{
Schedule: "9 30 1-5",
req = codersdk.UpdateWorkspaceTTLRequest{
TTL: ptr(time.Hour),
}
)
err := client.UpdateWorkspaceAutostop(ctx, wsid, req)
err := client.UpdateWorkspaceTTL(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")
@ -683,15 +624,6 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
})
}
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
}
func TestWorkspaceWatcher(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
@ -715,3 +647,17 @@ func TestWorkspaceWatcher(t *testing.T) {
cancel()
require.EqualValues(t, codersdk.Workspace{}, <-wc)
}
func mustLocation(t *testing.T, location string) *time.Location {
t.Helper()
loc, err := time.LoadLocation(location)
if err != nil {
t.Errorf("failed to load location %s: %s", location, err.Error())
}
return loc
}
func ptr[T any](x T) *T {
return &x
}