Skip to content

Commit a145d6d

Browse files
committed
feat: add lifecycle.Executor to autostart workspaces.
1 parent 2df92e6 commit a145d6d

File tree

7 files changed

+434
-2
lines changed

7 files changed

+434
-2
lines changed
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package lifecycle
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"time"
7+
8+
"cdr.dev/slog"
9+
10+
"github.com/coder/coder/coderd/autostart/schedule"
11+
"github.com/coder/coder/coderd/database"
12+
"github.com/coder/coder/codersdk"
13+
14+
"github.com/google/uuid"
15+
"github.com/moby/moby/pkg/namesgenerator"
16+
"golang.org/x/xerrors"
17+
)
18+
19+
//var ExecutorUUID = uuid.MustParse("00000000-0000-0000-0000-000000000000")
20+
21+
// Executor executes automated workspace lifecycle operations.
22+
type Executor struct {
23+
ctx context.Context
24+
db database.Store
25+
log slog.Logger
26+
tick <-chan time.Time
27+
}
28+
29+
func NewExecutor(ctx context.Context, db database.Store, log slog.Logger, tick <-chan time.Time) *Executor {
30+
le := &Executor{
31+
ctx: ctx,
32+
db: db,
33+
tick: tick,
34+
log: log,
35+
}
36+
return le
37+
}
38+
39+
func (e *Executor) Run() error {
40+
for {
41+
select {
42+
case t := <-e.tick:
43+
if err := e.runOnce(t); err != nil {
44+
e.log.Error(e.ctx, "error running once", slog.Error(err))
45+
}
46+
case <-e.ctx.Done():
47+
return nil
48+
default:
49+
}
50+
}
51+
}
52+
53+
func (e *Executor) runOnce(t time.Time) error {
54+
currentTick := t.Round(time.Minute)
55+
return e.db.InTx(func(db database.Store) error {
56+
allWorkspaces, err := db.GetWorkspaces(e.ctx)
57+
if err != nil {
58+
return xerrors.Errorf("get all workspaces: %w", err)
59+
}
60+
61+
for _, ws := range allWorkspaces {
62+
// We only care about workspaces with autostart enabled.
63+
if ws.AutostartSchedule.String == "" {
64+
continue
65+
}
66+
sched, err := schedule.Weekly(ws.AutostartSchedule.String)
67+
if err != nil {
68+
e.log.Warn(e.ctx, "workspace has invalid autostart schedule",
69+
slog.F("workspace_id", ws.ID),
70+
slog.F("autostart_schedule", ws.AutostartSchedule.String),
71+
)
72+
continue
73+
}
74+
75+
// Determine the workspace state based on its latest build. We expect it to be stopped.
76+
// TODO(cian): is this **guaranteed** to be the latest build???
77+
latestBuild, err := db.GetWorkspaceBuildByWorkspaceIDWithoutAfter(e.ctx, ws.ID)
78+
if err != nil {
79+
return xerrors.Errorf("get latest build for workspace %q: %w", ws.ID, err)
80+
}
81+
if latestBuild.Transition != database.WorkspaceTransitionStop {
82+
e.log.Debug(e.ctx, "autostart: skipping workspace: wrong transition",
83+
slog.F("transition", latestBuild.Transition),
84+
slog.F("workspace_id", ws.ID),
85+
)
86+
continue
87+
}
88+
89+
// Round time to the nearest minute, as this is the finest granularity cron supports.
90+
earliestAutostart := sched.Next(latestBuild.CreatedAt).Round(time.Minute)
91+
if earliestAutostart.After(currentTick) {
92+
e.log.Debug(e.ctx, "autostart: skipping workspace: too early",
93+
slog.F("workspace_id", ws.ID),
94+
slog.F("earliest_autostart", earliestAutostart),
95+
slog.F("current_tick", currentTick),
96+
)
97+
continue
98+
}
99+
100+
e.log.Info(e.ctx, "autostart: scheduling workspace start",
101+
slog.F("workspace_id", ws.ID),
102+
)
103+
104+
if err := doBuild(e.ctx, db, ws, currentTick); err != nil {
105+
e.log.Error(e.ctx, "autostart workspace", slog.F("workspace_id", ws.ID), slog.Error(err))
106+
}
107+
}
108+
return nil
109+
})
110+
}
111+
112+
// XXX: cian: this function shouldn't really exist. Refactor.
113+
func doBuild(ctx context.Context, store database.Store, workspace database.Workspace, now time.Time) error {
114+
template, err := store.GetTemplateByID(ctx, workspace.TemplateID)
115+
if err != nil {
116+
return xerrors.Errorf("get template: %w", err)
117+
}
118+
119+
priorHistory, err := store.GetWorkspaceBuildByWorkspaceIDWithoutAfter(ctx, workspace.ID)
120+
priorJob, err := store.GetProvisionerJobByID(ctx, priorHistory.JobID)
121+
if err == nil && !priorJob.CompletedAt.Valid {
122+
return xerrors.Errorf("workspace build already active")
123+
}
124+
125+
priorHistoryID := uuid.NullUUID{
126+
UUID: priorHistory.ID,
127+
Valid: true,
128+
}
129+
130+
var newWorkspaceBuild database.WorkspaceBuild
131+
// This must happen in a transaction to ensure history can be inserted, and
132+
// the prior history can update it's "after" column to point at the new.
133+
workspaceBuildID := uuid.New()
134+
input, err := json.Marshal(struct {
135+
WorkspaceBuildID string `json:"workspace_build_id"`
136+
}{
137+
WorkspaceBuildID: workspaceBuildID.String(),
138+
})
139+
if err != nil {
140+
return xerrors.Errorf("marshal provision job: %w", err)
141+
}
142+
provisionerJobID := uuid.New()
143+
newProvisionerJob, err := store.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
144+
ID: provisionerJobID,
145+
CreatedAt: database.Now(),
146+
UpdatedAt: database.Now(),
147+
InitiatorID: workspace.OwnerID,
148+
OrganizationID: template.OrganizationID,
149+
Provisioner: template.Provisioner,
150+
Type: database.ProvisionerJobTypeWorkspaceBuild,
151+
StorageMethod: priorJob.StorageMethod,
152+
StorageSource: priorJob.StorageSource,
153+
Input: input,
154+
})
155+
if err != nil {
156+
return xerrors.Errorf("insert provisioner job: %w", err)
157+
}
158+
newWorkspaceBuild, err = store.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{
159+
ID: workspaceBuildID,
160+
CreatedAt: database.Now(),
161+
UpdatedAt: database.Now(),
162+
WorkspaceID: workspace.ID,
163+
TemplateVersionID: priorHistory.TemplateVersionID,
164+
BeforeID: priorHistoryID,
165+
Name: namesgenerator.GetRandomName(1),
166+
ProvisionerState: priorHistory.ProvisionerState,
167+
InitiatorID: workspace.OwnerID,
168+
Transition: database.WorkspaceTransitionStart,
169+
JobID: newProvisionerJob.ID,
170+
})
171+
if err != nil {
172+
return xerrors.Errorf("insert workspace build: %w", err)
173+
}
174+
175+
if priorHistoryID.Valid {
176+
// Update the prior history entries "after" column.
177+
err = store.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
178+
ID: priorHistory.ID,
179+
ProvisionerState: priorHistory.ProvisionerState,
180+
UpdatedAt: database.Now(),
181+
AfterID: uuid.NullUUID{
182+
UUID: newWorkspaceBuild.ID,
183+
Valid: true,
184+
},
185+
})
186+
if err != nil {
187+
return xerrors.Errorf("update prior workspace build: %w", err)
188+
}
189+
}
190+
return nil
191+
}
192+
193+
func provisionerJobStatus(j database.ProvisionerJob, now time.Time) codersdk.ProvisionerJobStatus {
194+
switch {
195+
case j.CanceledAt.Valid:
196+
if j.CompletedAt.Valid {
197+
return codersdk.ProvisionerJobCanceled
198+
}
199+
return codersdk.ProvisionerJobCanceling
200+
case !j.StartedAt.Valid:
201+
return codersdk.ProvisionerJobPending
202+
case j.CompletedAt.Valid:
203+
if j.Error.String == "" {
204+
return codersdk.ProvisionerJobSucceeded
205+
}
206+
return codersdk.ProvisionerJobFailed
207+
case now.Sub(j.UpdatedAt) > 30*time.Second:
208+
return codersdk.ProvisionerJobFailed
209+
default:
210+
return codersdk.ProvisionerJobRunning
211+
}
212+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package lifecycle_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"cdr.dev/slog"
9+
"cdr.dev/slog/sloggers/slogtest"
10+
11+
"github.com/coder/coder/coderd/autostart/lifecycle"
12+
"github.com/coder/coder/coderd/autostart/schedule"
13+
"github.com/coder/coder/coderd/coderdtest"
14+
"github.com/coder/coder/coderd/database"
15+
"github.com/coder/coder/coderd/database/databasefake"
16+
"github.com/coder/coder/codersdk"
17+
18+
"github.com/stretchr/testify/require"
19+
)
20+
21+
func Test_Executor_Run(t *testing.T) {
22+
t.Parallel()
23+
24+
t.Run("OK", func(t *testing.T) {
25+
t.Parallel()
26+
27+
var (
28+
ctx = context.Background()
29+
cancelCtx, cancel = context.WithCancel(context.Background())
30+
log = slogtest.Make(t, nil).Named("lifecycle.executor").Leveled(slog.LevelDebug)
31+
err error
32+
tickCh = make(chan time.Time)
33+
db = databasefake.New()
34+
le = lifecycle.NewExecutor(cancelCtx, db, log, tickCh)
35+
client = coderdtest.New(t, &coderdtest.Options{
36+
LifecycleExecutor: le,
37+
Store: db,
38+
})
39+
// Given: we have a user with a workspace
40+
_ = coderdtest.NewProvisionerDaemon(t, client)
41+
user = coderdtest.CreateFirstUser(t, client)
42+
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
43+
template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
44+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
45+
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
46+
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
47+
)
48+
// Given: workspace is stopped
49+
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
50+
TemplateVersionID: template.ActiveVersionID,
51+
Transition: database.WorkspaceTransitionStop,
52+
})
53+
require.NoError(t, err, "stop workspace")
54+
// Given: we wait for the stop to complete
55+
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
56+
57+
// Given: we update the workspace with its new state
58+
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
59+
// Given: we ensure the workspace is now in a stopped state
60+
require.Equal(t, database.WorkspaceTransitionStop, workspace.LatestBuild.Transition)
61+
62+
// Given: the workspace initially has autostart disabled
63+
require.Empty(t, workspace.AutostartSchedule)
64+
65+
// When: we enable workspace autostart
66+
sched, err := schedule.Weekly("* * * * *")
67+
require.NoError(t, err)
68+
require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
69+
Schedule: sched.String(),
70+
}))
71+
72+
// When: the lifecycle executor ticks
73+
go func() {
74+
tickCh <- time.Now().UTC().Add(time.Minute)
75+
cancel()
76+
}()
77+
require.NoError(t, le.Run())
78+
79+
// Then: the workspace should be started
80+
require.Eventually(t, func() bool {
81+
ws := coderdtest.MustWorkspace(t, client, workspace.ID)
82+
return ws.LatestBuild.Job.Status == codersdk.ProvisionerJobSucceeded &&
83+
ws.LatestBuild.Transition == database.WorkspaceTransitionStart
84+
}, 10*time.Second, 1000*time.Millisecond)
85+
})
86+
87+
t.Run("AlreadyRunning", func(t *testing.T) {
88+
t.Parallel()
89+
90+
var (
91+
ctx = context.Background()
92+
cancelCtx, cancel = context.WithCancel(context.Background())
93+
log = slogtest.Make(t, nil).Named("lifecycle.executor").Leveled(slog.LevelDebug)
94+
err error
95+
tickCh = make(chan time.Time)
96+
db = databasefake.New()
97+
le = lifecycle.NewExecutor(cancelCtx, db, log, tickCh)
98+
client = coderdtest.New(t, &coderdtest.Options{
99+
LifecycleExecutor: le,
100+
Store: db,
101+
})
102+
// Given: we have a user with a workspace
103+
_ = coderdtest.NewProvisionerDaemon(t, client)
104+
user = coderdtest.CreateFirstUser(t, client)
105+
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
106+
template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
107+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
108+
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
109+
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
110+
)
111+
112+
// Given: we ensure the workspace is now in a stopped state
113+
require.Equal(t, database.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
114+
115+
// Given: the workspace initially has autostart disabled
116+
require.Empty(t, workspace.AutostartSchedule)
117+
118+
// When: we enable workspace autostart
119+
sched, err := schedule.Weekly("* * * * *")
120+
require.NoError(t, err)
121+
require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
122+
Schedule: sched.String(),
123+
}))
124+
125+
// When: the lifecycle executor ticks
126+
go func() {
127+
tickCh <- time.Now().UTC().Add(time.Minute)
128+
cancel()
129+
}()
130+
require.NoError(t, le.Run())
131+
132+
// Then: the workspace should not be started.
133+
require.Never(t, func() bool {
134+
ws := coderdtest.MustWorkspace(t, client, workspace.ID)
135+
return ws.LatestBuild.ID != workspace.LatestBuild.ID
136+
}, 10*time.Second, 1000*time.Millisecond)
137+
})
138+
}

0 commit comments

Comments
 (0)