Skip to content

Commit 8bfa982

Browse files
johnstcnkylecarbs
authored andcommitted
feat: add lifecycle.Executor to manage autostart and autostop (#1183)
This PR adds a package lifecycle and an Executor implementation that attempts to schedule a build of workspaces with autostart configured. - lifecycle.Executor takes a chan time.Time in its constructor (e.g. time.Tick(time.Minute)) - Whenever a value is received from this channel, it executes one iteration of looping through the workspaces and triggering lifecycle operations. - When the context passed to the executor is Done, it exits. - Only workspaces that meet the following criteria will have a lifecycle operation applied to them: - Workspace has a valid and non-empty autostart or autostop schedule (either) - Workspace's last build was successful - The following transitions will be applied depending on the current workspace state: - If the workspace is currently running, it will be stopped. - If the workspace is currently stopped, it will be started. - Otherwise, nothing will be done. - Workspace builds will be created with the same parameters and template version as the last successful build (for example, template version)
1 parent ec21112 commit 8bfa982

17 files changed

+765
-6
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ site/out/
3737
.terraform/
3838

3939
.vscode/*.log
40+
**/*.swp

cli/autostart.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77

88
"github.com/spf13/cobra"
99

10-
"github.com/coder/coder/coderd/autostart/schedule"
10+
"github.com/coder/coder/coderd/autobuild/schedule"
1111
"github.com/coder/coder/codersdk"
1212
)
1313

cli/autostop.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77

88
"github.com/spf13/cobra"
99

10-
"github.com/coder/coder/coderd/autostart/schedule"
10+
"github.com/coder/coder/coderd/autobuild/schedule"
1111
"github.com/coder/coder/codersdk"
1212
)
1313

cli/server.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import (
3939
"github.com/coder/coder/cli/cliui"
4040
"github.com/coder/coder/cli/config"
4141
"github.com/coder/coder/coderd"
42+
"github.com/coder/coder/coderd/autobuild/executor"
4243
"github.com/coder/coder/coderd/database"
4344
"github.com/coder/coder/coderd/database/databasefake"
4445
"github.com/coder/coder/coderd/devtunnel"
@@ -343,6 +344,11 @@ func server() *cobra.Command {
343344
return xerrors.Errorf("notify systemd: %w", err)
344345
}
345346

347+
lifecyclePoller := time.NewTicker(time.Minute)
348+
defer lifecyclePoller.Stop()
349+
lifecycleExecutor := executor.New(cmd.Context(), options.Database, logger, lifecyclePoller.C)
350+
lifecycleExecutor.Run()
351+
346352
// Because the graceful shutdown includes cleaning up workspaces in dev mode, we're
347353
// going to make it harder to accidentally skip the graceful shutdown by hitting ctrl+c
348354
// two or more times. So the stopChan is unlimited in size and we don't call
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package executor
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"time"
7+
8+
"cdr.dev/slog"
9+
10+
"github.com/coder/coder/coderd/autobuild/schedule"
11+
"github.com/coder/coder/coderd/database"
12+
13+
"github.com/google/uuid"
14+
"github.com/moby/moby/pkg/namesgenerator"
15+
"golang.org/x/xerrors"
16+
)
17+
18+
// Executor automatically starts or stops workspaces.
19+
type Executor struct {
20+
ctx context.Context
21+
db database.Store
22+
log slog.Logger
23+
tick <-chan time.Time
24+
}
25+
26+
// New returns a new autobuild executor.
27+
func New(ctx context.Context, db database.Store, log slog.Logger, tick <-chan time.Time) *Executor {
28+
le := &Executor{
29+
ctx: ctx,
30+
db: db,
31+
tick: tick,
32+
log: log,
33+
}
34+
return le
35+
}
36+
37+
// Run will cause executor to start or stop workspaces on every
38+
// tick from its channel. It will stop when its context is Done, or when
39+
// its channel is closed.
40+
func (e *Executor) Run() {
41+
go func() {
42+
for t := range e.tick {
43+
if err := e.runOnce(t); err != nil {
44+
e.log.Error(e.ctx, "error running once", slog.Error(err))
45+
}
46+
}
47+
}()
48+
}
49+
50+
func (e *Executor) runOnce(t time.Time) error {
51+
currentTick := t.Truncate(time.Minute)
52+
return e.db.InTx(func(db database.Store) error {
53+
eligibleWorkspaces, err := db.GetWorkspacesAutostartAutostop(e.ctx)
54+
if err != nil {
55+
return xerrors.Errorf("get eligible workspaces for autostart or autostop: %w", err)
56+
}
57+
58+
for _, ws := range eligibleWorkspaces {
59+
// Determine the workspace state based on its latest build.
60+
priorHistory, err := db.GetWorkspaceBuildByWorkspaceIDWithoutAfter(e.ctx, ws.ID)
61+
if err != nil {
62+
e.log.Warn(e.ctx, "get latest workspace build",
63+
slog.F("workspace_id", ws.ID),
64+
slog.Error(err),
65+
)
66+
continue
67+
}
68+
69+
priorJob, err := db.GetProvisionerJobByID(e.ctx, priorHistory.JobID)
70+
if err != nil {
71+
e.log.Warn(e.ctx, "get last provisioner job for workspace %q: %w",
72+
slog.F("workspace_id", ws.ID),
73+
slog.Error(err),
74+
)
75+
continue
76+
}
77+
78+
if !priorJob.CompletedAt.Valid || priorJob.Error.String != "" {
79+
e.log.Warn(e.ctx, "last workspace build did not complete successfully, skipping",
80+
slog.F("workspace_id", ws.ID),
81+
slog.F("error", priorJob.Error.String),
82+
)
83+
continue
84+
}
85+
86+
var validTransition database.WorkspaceTransition
87+
var sched *schedule.Schedule
88+
switch priorHistory.Transition {
89+
case database.WorkspaceTransitionStart:
90+
validTransition = database.WorkspaceTransitionStop
91+
sched, err = schedule.Weekly(ws.AutostopSchedule.String)
92+
if err != nil {
93+
e.log.Warn(e.ctx, "workspace has invalid autostop schedule, skipping",
94+
slog.F("workspace_id", ws.ID),
95+
slog.F("autostart_schedule", ws.AutostopSchedule.String),
96+
)
97+
continue
98+
}
99+
case database.WorkspaceTransitionStop:
100+
validTransition = database.WorkspaceTransitionStart
101+
sched, err = schedule.Weekly(ws.AutostartSchedule.String)
102+
if err != nil {
103+
e.log.Warn(e.ctx, "workspace has invalid autostart schedule, skipping",
104+
slog.F("workspace_id", ws.ID),
105+
slog.F("autostart_schedule", ws.AutostartSchedule.String),
106+
)
107+
continue
108+
}
109+
default:
110+
e.log.Debug(e.ctx, "last transition not valid for autostart or autostop",
111+
slog.F("workspace_id", ws.ID),
112+
slog.F("latest_build_transition", priorHistory.Transition),
113+
)
114+
continue
115+
}
116+
117+
// Round time down to the nearest minute, as this is the finest granularity cron supports.
118+
// Truncate is probably not necessary here, but doing it anyway to be sure.
119+
nextTransitionAt := sched.Next(priorHistory.CreatedAt).Truncate(time.Minute)
120+
if currentTick.Before(nextTransitionAt) {
121+
e.log.Debug(e.ctx, "skipping workspace: too early",
122+
slog.F("workspace_id", ws.ID),
123+
slog.F("next_transition_at", nextTransitionAt),
124+
slog.F("transition", validTransition),
125+
slog.F("current_tick", currentTick),
126+
)
127+
continue
128+
}
129+
130+
e.log.Info(e.ctx, "scheduling workspace transition",
131+
slog.F("workspace_id", ws.ID),
132+
slog.F("transition", validTransition),
133+
)
134+
135+
if err := build(e.ctx, db, ws, validTransition, priorHistory, priorJob); err != nil {
136+
e.log.Error(e.ctx, "unable to transition workspace",
137+
slog.F("workspace_id", ws.ID),
138+
slog.F("transition", validTransition),
139+
slog.Error(err),
140+
)
141+
}
142+
}
143+
return nil
144+
})
145+
}
146+
147+
// TODO(cian): this function duplicates most of api.postWorkspaceBuilds. Refactor.
148+
// See: https://github.com/coder/coder/issues/1401
149+
func build(ctx context.Context, store database.Store, workspace database.Workspace, trans database.WorkspaceTransition, priorHistory database.WorkspaceBuild, priorJob database.ProvisionerJob) error {
150+
template, err := store.GetTemplateByID(ctx, workspace.TemplateID)
151+
if err != nil {
152+
return xerrors.Errorf("get workspace template: %w", err)
153+
}
154+
155+
priorHistoryID := uuid.NullUUID{
156+
UUID: priorHistory.ID,
157+
Valid: true,
158+
}
159+
160+
var newWorkspaceBuild database.WorkspaceBuild
161+
// This must happen in a transaction to ensure history can be inserted, and
162+
// the prior history can update it's "after" column to point at the new.
163+
workspaceBuildID := uuid.New()
164+
input, err := json.Marshal(struct {
165+
WorkspaceBuildID string `json:"workspace_build_id"`
166+
}{
167+
WorkspaceBuildID: workspaceBuildID.String(),
168+
})
169+
if err != nil {
170+
return xerrors.Errorf("marshal provision job: %w", err)
171+
}
172+
provisionerJobID := uuid.New()
173+
now := database.Now()
174+
newProvisionerJob, err := store.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
175+
ID: provisionerJobID,
176+
CreatedAt: now,
177+
UpdatedAt: now,
178+
InitiatorID: workspace.OwnerID,
179+
OrganizationID: template.OrganizationID,
180+
Provisioner: template.Provisioner,
181+
Type: database.ProvisionerJobTypeWorkspaceBuild,
182+
StorageMethod: priorJob.StorageMethod,
183+
StorageSource: priorJob.StorageSource,
184+
Input: input,
185+
})
186+
if err != nil {
187+
return xerrors.Errorf("insert provisioner job: %w", err)
188+
}
189+
newWorkspaceBuild, err = store.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{
190+
ID: workspaceBuildID,
191+
CreatedAt: now,
192+
UpdatedAt: now,
193+
WorkspaceID: workspace.ID,
194+
TemplateVersionID: priorHistory.TemplateVersionID,
195+
BeforeID: priorHistoryID,
196+
Name: namesgenerator.GetRandomName(1),
197+
ProvisionerState: priorHistory.ProvisionerState,
198+
InitiatorID: workspace.OwnerID,
199+
Transition: trans,
200+
JobID: newProvisionerJob.ID,
201+
})
202+
if err != nil {
203+
return xerrors.Errorf("insert workspace build: %w", err)
204+
}
205+
206+
if priorHistoryID.Valid {
207+
// Update the prior history entries "after" column.
208+
err = store.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
209+
ID: priorHistory.ID,
210+
ProvisionerState: priorHistory.ProvisionerState,
211+
UpdatedAt: now,
212+
AfterID: uuid.NullUUID{
213+
UUID: newWorkspaceBuild.ID,
214+
Valid: true,
215+
},
216+
})
217+
if err != nil {
218+
return xerrors.Errorf("update prior workspace build: %w", err)
219+
}
220+
}
221+
return nil
222+
}

0 commit comments

Comments
 (0)