mirror of
https://github.com/coder/coder.git
synced 2025-07-29 10:35:52 +00:00
feat: add API/SDK support for autostop extension (#1778)
* Adds deadline column to workspace_builds, associated DB/API plumbing * database: Upon inserting a row into workspace_builds, deadline will initially be zero. * autobuild: Executor now checks the Deadline field of the workspace_build for the purpose of autostop logic. * coderd: Adds a new route /api/v2/workspaces/:workspace/extend which allows updating the deadline of the currently active workspace build. The new deadline must be after the existing deadline, and not the zero time. * provisionerd: updates workspace_build.deadline upon successful workspace build completion (equal to now plus workspace TTL, if it exists).
This commit is contained in:
@@ -50,6 +50,18 @@ 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 {
|
||||
// TTL is set at the workspace level, and deadline at the workspace build level.
|
||||
// When a workspace build is created, its deadline initially starts at zero.
|
||||
// When provisionerd successfully completes a provision job, the deadline is
|
||||
// set to now + TTL if the associated workspace has a TTL set. This deadline
|
||||
// is what we compare against when performing autostop operations, rounded down
|
||||
// to the minute.
|
||||
//
|
||||
// NOTE: Currently, if a workspace build is created with a given TTL and then
|
||||
// the user either changes or unsets the TTL, the deadline for the workspace
|
||||
// build will not have changed. So, autostop will still happen at the
|
||||
// original TTL value from when the workspace build was created.
|
||||
// Whether this is expected behavior from a user's perspective is not yet known.
|
||||
eligibleWorkspaces, err := db.GetWorkspacesAutostart(e.ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get eligible workspaces for autostart or autostop: %w", err)
|
||||
@@ -88,18 +100,15 @@ func (e *Executor) runOnce(t time.Time) error {
|
||||
switch priorHistory.Transition {
|
||||
case database.WorkspaceTransitionStart:
|
||||
validTransition = database.WorkspaceTransitionStop
|
||||
if !ws.Ttl.Valid || ws.Ttl.Int64 == 0 {
|
||||
e.log.Debug(e.ctx, "invalid or zero ws ttl, skipping",
|
||||
if priorHistory.Deadline.IsZero() {
|
||||
e.log.Debug(e.ctx, "latest workspace build has zero deadline, skipping",
|
||||
slog.F("workspace_id", ws.ID),
|
||||
slog.F("ttl", time.Duration(ws.Ttl.Int64)),
|
||||
slog.F("workspace_build_id", priorHistory.ID),
|
||||
)
|
||||
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)
|
||||
// Truncate to nearest minute for consistency with autostart behavior
|
||||
nextTransition = priorHistory.Deadline.Truncate(time.Minute)
|
||||
case database.WorkspaceTransitionStop:
|
||||
validTransition = database.WorkspaceTransitionStart
|
||||
sched, err := schedule.Weekly(ws.AutostartSchedule.String)
|
||||
|
@@ -190,14 +190,14 @@ func TestExecutorAutostopOK(t *testing.T) {
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
ttl = *workspace.TTL
|
||||
)
|
||||
// Given: workspace is running
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
|
||||
require.NotZero(t, workspace.LatestBuild.Deadline)
|
||||
|
||||
// When: the autobuild executor ticks *after* the TTL:
|
||||
// When: the autobuild executor ticks *after* the deadline:
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(ttl + time.Minute)
|
||||
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
@@ -209,6 +209,55 @@ func TestExecutorAutostopOK(t *testing.T) {
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStop, ws.LatestBuild.Transition, "expected workspace not to be running")
|
||||
}
|
||||
|
||||
func TestExecutorAutostopExtend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
tickCh = make(chan time.Time)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
AutobuildTicker: tickCh,
|
||||
IncludeProvisionerD: true,
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
originalDeadline = workspace.LatestBuild.Deadline
|
||||
)
|
||||
// Given: workspace is running
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
|
||||
require.NotZero(t, originalDeadline)
|
||||
|
||||
// Given: we extend the workspace deadline
|
||||
err := client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
|
||||
Deadline: originalDeadline.Add(30 * time.Minute),
|
||||
})
|
||||
require.NoError(t, err, "extend workspace deadline")
|
||||
|
||||
// When: the autobuild executor ticks *after* the original deadline:
|
||||
go func() {
|
||||
tickCh <- originalDeadline.Add(time.Minute)
|
||||
}()
|
||||
|
||||
// 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")
|
||||
|
||||
// When: the autobuild executor ticks after the *new* deadline:
|
||||
go func() {
|
||||
tickCh <- ws.LatestBuild.Deadline.Add(time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// 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")
|
||||
require.Equal(t, codersdk.ProvisionerJobSucceeded, ws.LatestBuild.Job.Status, "expected provisioner job to have succeeded")
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStop, ws.LatestBuild.Transition, "expected workspace not to be running")
|
||||
}
|
||||
|
||||
func TestExecutorAutostopAlreadyStopped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -222,7 +271,6 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) {
|
||||
workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
cwr.AutostartSchedule = nil
|
||||
})
|
||||
ttl = *workspace.TTL
|
||||
)
|
||||
|
||||
// Given: workspace is stopped
|
||||
@@ -230,7 +278,7 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) {
|
||||
|
||||
// When: the autobuild executor ticks past the TTL
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(ttl + time.Minute)
|
||||
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
@@ -264,7 +312,7 @@ func TestExecutorAutostopNotEnabled(t *testing.T) {
|
||||
|
||||
// When: the autobuild executor ticks past the TTL
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(time.Minute)
|
||||
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
@@ -352,7 +400,7 @@ func TestExecutorWorkspaceAutostartTooEarly(t *testing.T) {
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
|
||||
}
|
||||
|
||||
func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
|
||||
func TestExecutorWorkspaceAutostopBeforeDeadline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
@@ -367,7 +415,7 @@ func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
|
||||
|
||||
// When: the autobuild executor ticks before the TTL
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC()
|
||||
tickCh <- workspace.LatestBuild.Deadline.Add(-1 * time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
@@ -378,6 +426,38 @@ func TestExecutorWorkspaceTTLTooEarly(t *testing.T) {
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
|
||||
}
|
||||
|
||||
func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
tickCh = make(chan time.Time)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
AutobuildTicker: tickCh,
|
||||
IncludeProvisionerD: true,
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
)
|
||||
|
||||
// Given: the user changes their mind and decides their workspace should not auto-stop
|
||||
err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTL: nil})
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: the autobuild executor ticks after the deadline
|
||||
go func() {
|
||||
tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: the workspace should still stop - sorry!
|
||||
<-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")
|
||||
require.Equal(t, codersdk.ProvisionerJobSucceeded, ws.LatestBuild.Job.Status, "expected provisioner job to have succeeded")
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStop, ws.LatestBuild.Transition, "expected workspace not 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`)
|
||||
|
@@ -315,6 +315,7 @@ func New(options *Options) *API {
|
||||
r.Put("/", api.putWorkspaceTTL)
|
||||
})
|
||||
r.Get("/watch", api.watchWorkspace)
|
||||
r.Put("/extend", api.putExtendWorkspace)
|
||||
})
|
||||
})
|
||||
r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) {
|
||||
|
@@ -1485,6 +1485,7 @@ func (q *fakeQuerier) InsertWorkspaceBuild(_ context.Context, arg database.Inser
|
||||
InitiatorID: arg.InitiatorID,
|
||||
JobID: arg.JobID,
|
||||
ProvisionerState: arg.ProvisionerState,
|
||||
Deadline: arg.Deadline,
|
||||
}
|
||||
q.workspaceBuilds = append(q.workspaceBuilds, workspaceBuild)
|
||||
return workspaceBuild, nil
|
||||
@@ -1693,6 +1694,7 @@ func (q *fakeQuerier) UpdateWorkspaceBuildByID(_ context.Context, arg database.U
|
||||
}
|
||||
workspaceBuild.UpdatedAt = arg.UpdatedAt
|
||||
workspaceBuild.ProvisionerState = arg.ProvisionerState
|
||||
workspaceBuild.Deadline = arg.Deadline
|
||||
q.workspaceBuilds[index] = workspaceBuild
|
||||
return nil
|
||||
}
|
||||
|
3
coderd/database/dump.sql
generated
3
coderd/database/dump.sql
generated
@@ -291,7 +291,8 @@ CREATE TABLE workspace_builds (
|
||||
transition workspace_transition NOT NULL,
|
||||
initiator_id uuid NOT NULL,
|
||||
provisioner_state bytea,
|
||||
job_id uuid NOT NULL
|
||||
job_id uuid NOT NULL,
|
||||
deadline timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE workspace_resources (
|
||||
|
@@ -0,0 +1 @@
|
||||
ALTER TABLE ONLY workspace_builds DROP COLUMN deadline;
|
@@ -0,0 +1 @@
|
||||
ALTER TABLE ONLY workspace_builds ADD COLUMN deadline TIMESTAMPTZ NOT NULL DEFAULT TIMESTAMPTZ '0001-01-01 00:00:00+00:00';
|
@@ -505,6 +505,7 @@ type WorkspaceBuild struct {
|
||||
InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"`
|
||||
ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"`
|
||||
JobID uuid.UUID `db:"job_id" json:"job_id"`
|
||||
Deadline time.Time `db:"deadline" json:"deadline"`
|
||||
}
|
||||
|
||||
type WorkspaceResource struct {
|
||||
|
@@ -2745,7 +2745,7 @@ func (q *sqlQuerier) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg
|
||||
|
||||
const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id
|
||||
id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id, deadline
|
||||
FROM
|
||||
workspace_builds
|
||||
WHERE
|
||||
@@ -2771,12 +2771,13 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many
|
||||
SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.name, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id
|
||||
SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.name, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline
|
||||
FROM (
|
||||
SELECT
|
||||
workspace_id, MAX(build_number) as max_build_number
|
||||
@@ -2813,6 +2814,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context,
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2829,7 +2831,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context,
|
||||
|
||||
const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id
|
||||
id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id, deadline
|
||||
FROM
|
||||
workspace_builds
|
||||
WHERE
|
||||
@@ -2853,13 +2855,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id
|
||||
id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id, deadline
|
||||
FROM
|
||||
workspace_builds
|
||||
WHERE
|
||||
@@ -2883,13 +2886,14 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceBuildByWorkspaceID = `-- name: GetWorkspaceBuildByWorkspaceID :many
|
||||
SELECT
|
||||
id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id
|
||||
id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id, deadline
|
||||
FROM
|
||||
workspace_builds
|
||||
WHERE
|
||||
@@ -2953,6 +2957,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceID(ctx context.Context, arg Get
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2969,7 +2974,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceID(ctx context.Context, arg Get
|
||||
|
||||
const getWorkspaceBuildByWorkspaceIDAndName = `-- name: GetWorkspaceBuildByWorkspaceIDAndName :one
|
||||
SELECT
|
||||
id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id
|
||||
id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id, deadline
|
||||
FROM
|
||||
workspace_builds
|
||||
WHERE
|
||||
@@ -2997,6 +3002,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndName(ctx context.Context,
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -3014,10 +3020,11 @@ INSERT INTO
|
||||
transition,
|
||||
initiator_id,
|
||||
job_id,
|
||||
provisioner_state
|
||||
provisioner_state,
|
||||
deadline
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id, deadline
|
||||
`
|
||||
|
||||
type InsertWorkspaceBuildParams struct {
|
||||
@@ -3032,6 +3039,7 @@ type InsertWorkspaceBuildParams struct {
|
||||
InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"`
|
||||
JobID uuid.UUID `db:"job_id" json:"job_id"`
|
||||
ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"`
|
||||
Deadline time.Time `db:"deadline" json:"deadline"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) (WorkspaceBuild, error) {
|
||||
@@ -3047,6 +3055,7 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa
|
||||
arg.InitiatorID,
|
||||
arg.JobID,
|
||||
arg.ProvisionerState,
|
||||
arg.Deadline,
|
||||
)
|
||||
var i WorkspaceBuild
|
||||
err := row.Scan(
|
||||
@@ -3061,6 +3070,7 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa
|
||||
&i.InitiatorID,
|
||||
&i.ProvisionerState,
|
||||
&i.JobID,
|
||||
&i.Deadline,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -3070,7 +3080,8 @@ UPDATE
|
||||
workspace_builds
|
||||
SET
|
||||
updated_at = $2,
|
||||
provisioner_state = $3
|
||||
provisioner_state = $3,
|
||||
deadline = $4
|
||||
WHERE
|
||||
id = $1
|
||||
`
|
||||
@@ -3079,10 +3090,16 @@ type UpdateWorkspaceBuildByIDParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ProvisionerState []byte `db:"provisioner_state" json:"provisioner_state"`
|
||||
Deadline time.Time `db:"deadline" json:"deadline"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWorkspaceBuildByIDParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateWorkspaceBuildByID, arg.ID, arg.UpdatedAt, arg.ProvisionerState)
|
||||
_, err := q.db.ExecContext(ctx, updateWorkspaceBuildByID,
|
||||
arg.ID,
|
||||
arg.UpdatedAt,
|
||||
arg.ProvisionerState,
|
||||
arg.Deadline,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
|
@@ -101,16 +101,18 @@ INSERT INTO
|
||||
transition,
|
||||
initiator_id,
|
||||
job_id,
|
||||
provisioner_state
|
||||
provisioner_state,
|
||||
deadline
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *;
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *;
|
||||
|
||||
-- name: UpdateWorkspaceBuildByID :exec
|
||||
UPDATE
|
||||
workspace_builds
|
||||
SET
|
||||
updated_at = $2,
|
||||
provisioner_state = $3
|
||||
provisioner_state = $3,
|
||||
deadline = $4
|
||||
WHERE
|
||||
id = $1;
|
||||
|
@@ -473,6 +473,7 @@ func (server *provisionerdServer) FailJob(ctx context.Context, failJob *proto.Fa
|
||||
ID: input.WorkspaceBuildID,
|
||||
UpdatedAt: database.Now(),
|
||||
ProvisionerState: jobType.WorkspaceBuild.State,
|
||||
// We are explicitly not updating deadline here.
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("update workspace build state: %w", err)
|
||||
@@ -544,6 +545,18 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr
|
||||
}
|
||||
|
||||
err = server.Database.InTx(func(db database.Store) error {
|
||||
now := database.Now()
|
||||
var workspaceDeadline time.Time
|
||||
workspace, err := db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
|
||||
if err == nil {
|
||||
if workspace.Ttl.Valid {
|
||||
workspaceDeadline = now.Add(time.Duration(workspace.Ttl.Int64)).Truncate(time.Minute)
|
||||
}
|
||||
} else {
|
||||
// Huh? Did the workspace get deleted?
|
||||
// In any case, since this is just for the TTL, try and continue anyway.
|
||||
server.Logger.Error(ctx, "fetch workspace for build", slog.F("workspace_build_id", workspaceBuild.ID), slog.F("workspace_id", workspaceBuild.WorkspaceID))
|
||||
}
|
||||
err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{
|
||||
ID: jobID,
|
||||
UpdatedAt: database.Now(),
|
||||
@@ -557,8 +570,9 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr
|
||||
}
|
||||
err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
|
||||
ID: workspaceBuild.ID,
|
||||
UpdatedAt: database.Now(),
|
||||
Deadline: workspaceDeadline,
|
||||
ProvisionerState: jobType.WorkspaceBuild.State,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update workspace build: %w", err)
|
||||
|
@@ -445,6 +445,7 @@ func convertWorkspaceBuild(workspaceBuild database.WorkspaceBuild, job codersdk.
|
||||
Transition: codersdk.WorkspaceTransition(workspaceBuild.Transition),
|
||||
InitiatorID: workspaceBuild.InitiatorID,
|
||||
Job: job,
|
||||
Deadline: workspaceBuild.Deadline,
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -477,7 +477,8 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
|
||||
InitiatorID: apiKey.UserID,
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
JobID: provisionerJob.ID,
|
||||
BuildNumber: 1, // First build!
|
||||
BuildNumber: 1, // First build!
|
||||
Deadline: time.Time{}, // provisionerd will set this upon success
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert workspace build: %w", err)
|
||||
@@ -570,6 +571,69 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||||
workspace := httpmw.WorkspaceParam(r)
|
||||
|
||||
if !api.Authorize(rw, r, rbac.ActionUpdate, workspace) {
|
||||
return
|
||||
}
|
||||
|
||||
var req codersdk.PutExtendWorkspaceRequest
|
||||
if !httpapi.Read(rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
var code = http.StatusOK
|
||||
|
||||
err := api.Database.InTx(func(s database.Store) error {
|
||||
build, err := s.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
|
||||
if err != nil {
|
||||
code = http.StatusInternalServerError
|
||||
return xerrors.Errorf("get latest workspace build: %w", err)
|
||||
}
|
||||
|
||||
if build.Transition != database.WorkspaceTransitionStart {
|
||||
code = http.StatusConflict
|
||||
return xerrors.Errorf("workspace must be started, current status: %s", build.Transition)
|
||||
}
|
||||
|
||||
newDeadline := req.Deadline.Truncate(time.Minute).UTC()
|
||||
if newDeadline.IsZero() {
|
||||
// This should not be possible because the struct validation field enforces a non-zero value.
|
||||
code = http.StatusBadRequest
|
||||
return xerrors.New("new deadline cannot be zero")
|
||||
}
|
||||
|
||||
if newDeadline.Before(build.Deadline) || newDeadline.Before(time.Now()) {
|
||||
code = http.StatusBadRequest
|
||||
return xerrors.Errorf("new deadline %q must be after existing deadline %q", newDeadline.Format(time.RFC3339), build.Deadline.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// both newDeadline and build.Deadline are truncated to time.Minute
|
||||
if newDeadline == build.Deadline {
|
||||
code = http.StatusNotModified
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.UpdateWorkspaceBuildByID(r.Context(), database.UpdateWorkspaceBuildByIDParams{
|
||||
ID: build.ID,
|
||||
UpdatedAt: build.UpdatedAt,
|
||||
ProvisionerState: build.ProvisionerState,
|
||||
Deadline: newDeadline,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("update workspace build: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
var resp = httpapi.Response{}
|
||||
if err != nil {
|
||||
resp.Message = err.Error()
|
||||
}
|
||||
httpapi.Write(rw, code, resp)
|
||||
}
|
||||
|
||||
func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||||
workspace := httpmw.WorkspaceParam(r)
|
||||
|
||||
|
@@ -538,7 +538,7 @@ func TestWorkspaceUpdateAutostart(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkspaceUpdateAutostop(t *testing.T) {
|
||||
func TestWorkspaceUpdateTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
@@ -615,6 +615,56 @@ func TestWorkspaceUpdateAutostop(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkspaceExtend(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
ctx = context.Background()
|
||||
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
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, user.OrganizationID, project.ID)
|
||||
extend = 90 * time.Minute
|
||||
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
oldDeadline = time.Now().Add(*workspace.TTL).UTC()
|
||||
newDeadline = time.Now().Add(*workspace.TTL + extend).UTC()
|
||||
)
|
||||
|
||||
workspace, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err, "fetch provisioned workspace")
|
||||
require.InDelta(t, oldDeadline.Unix(), workspace.LatestBuild.Deadline.Unix(), 60)
|
||||
|
||||
// Updating the deadline should succeed
|
||||
req := codersdk.PutExtendWorkspaceRequest{
|
||||
Deadline: newDeadline,
|
||||
}
|
||||
err = client.PutExtendWorkspace(ctx, workspace.ID, req)
|
||||
require.NoError(t, err, "failed to extend workspace")
|
||||
|
||||
// Ensure deadline set correctly
|
||||
updated, err := client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err, "failed to fetch updated workspace")
|
||||
require.InDelta(t, newDeadline.Unix(), updated.LatestBuild.Deadline.Unix(), 60)
|
||||
|
||||
// Zero time should fail
|
||||
err = client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
|
||||
Deadline: time.Time{},
|
||||
})
|
||||
require.ErrorContains(t, err, "deadline: required", "setting an empty deadline on a workspace should fail")
|
||||
|
||||
// Updating with an earlier time should also fail
|
||||
err = client.PutExtendWorkspace(ctx, workspace.ID, codersdk.PutExtendWorkspaceRequest{
|
||||
Deadline: oldDeadline,
|
||||
})
|
||||
require.ErrorContains(t, err, "must be after existing deadline", "setting an earlier deadline should fail")
|
||||
|
||||
// Ensure deadline still set correctly
|
||||
updated, err = client.Workspace(ctx, workspace.ID)
|
||||
require.NoError(t, err, "failed to fetch updated workspace")
|
||||
require.InDelta(t, newDeadline.Unix(), updated.LatestBuild.Deadline.Unix(), 60)
|
||||
}
|
||||
|
||||
func TestWorkspaceWatcher(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
|
@@ -32,6 +32,7 @@ type WorkspaceBuild struct {
|
||||
Transition WorkspaceTransition `json:"transition"`
|
||||
InitiatorID uuid.UUID `json:"initiator_id"`
|
||||
Job ProvisionerJob `json:"job"`
|
||||
Deadline time.Time `json:"deadline"`
|
||||
}
|
||||
|
||||
// WorkspaceBuild returns a single workspace build for a workspace.
|
||||
|
@@ -177,6 +177,26 @@ func (c *Client) UpdateWorkspaceTTL(ctx context.Context, id uuid.UUID, req Updat
|
||||
return nil
|
||||
}
|
||||
|
||||
// PutExtendWorkspaceRequest is a request to extend the deadline of
|
||||
// the active workspace build.
|
||||
type PutExtendWorkspaceRequest struct {
|
||||
Deadline time.Time `json:"deadline" validate:"required"`
|
||||
}
|
||||
|
||||
// PutExtendWorkspace updates the deadline for resources of the latest workspace build.
|
||||
func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutExtendWorkspaceRequest) error {
|
||||
path := fmt.Sprintf("/api/v2/workspaces/%s/extend", id.String())
|
||||
res, err := c.Request(ctx, http.MethodPut, path, req)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("extend workspace ttl: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNotModified {
|
||||
return readBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WorkspaceFilter struct {
|
||||
OrganizationID uuid.UUID
|
||||
// Owner can be a user_id (uuid), "me", or a username
|
||||
|
@@ -216,6 +216,11 @@ export interface ProvisionerJobLog {
|
||||
readonly output: string
|
||||
}
|
||||
|
||||
// From codersdk/workspaces.go:182:6
|
||||
export interface PutExtendWorkspaceRequest {
|
||||
readonly deadline: string
|
||||
}
|
||||
|
||||
// From codersdk/roles.go:12:6
|
||||
export interface Role {
|
||||
readonly name: string
|
||||
@@ -423,6 +428,7 @@ export interface WorkspaceBuild {
|
||||
readonly transition: WorkspaceTransition
|
||||
readonly initiator_id: string
|
||||
readonly job: ProvisionerJob
|
||||
readonly deadline: string
|
||||
}
|
||||
|
||||
// From codersdk/workspaces.go:64:6
|
||||
@@ -430,7 +436,7 @@ export interface WorkspaceBuildsRequest extends Pagination {
|
||||
readonly WorkspaceID: string
|
||||
}
|
||||
|
||||
// From codersdk/workspaces.go:180:6
|
||||
// From codersdk/workspaces.go:200:6
|
||||
export interface WorkspaceFilter {
|
||||
readonly OrganizationID: string
|
||||
readonly Owner: string
|
||||
|
@@ -124,6 +124,7 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
|
||||
transition: "start",
|
||||
updated_at: "2022-05-17T17:39:01.382927298Z",
|
||||
workspace_id: "test-workspace",
|
||||
deadline: "2022-05-17T23:39:00.00Z",
|
||||
}
|
||||
|
||||
export const MockWorkspaceBuildStop: TypesGen.WorkspaceBuild = {
|
||||
|
Reference in New Issue
Block a user