mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
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)
223 lines
6.8 KiB
Go
223 lines
6.8 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"cdr.dev/slog"
|
|
|
|
"github.com/coder/coder/coderd/autobuild/schedule"
|
|
"github.com/coder/coder/coderd/database"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/moby/moby/pkg/namesgenerator"
|
|
"golang.org/x/xerrors"
|
|
)
|
|
|
|
// Executor automatically starts or stops workspaces.
|
|
type Executor struct {
|
|
ctx context.Context
|
|
db database.Store
|
|
log slog.Logger
|
|
tick <-chan time.Time
|
|
}
|
|
|
|
// New returns a new autobuild executor.
|
|
func New(ctx context.Context, db database.Store, log slog.Logger, tick <-chan time.Time) *Executor {
|
|
le := &Executor{
|
|
ctx: ctx,
|
|
db: db,
|
|
tick: tick,
|
|
log: log,
|
|
}
|
|
return le
|
|
}
|
|
|
|
// Run will cause executor to start or stop workspaces on every
|
|
// tick from its channel. It will stop when its context is Done, or when
|
|
// its channel is closed.
|
|
func (e *Executor) Run() {
|
|
go func() {
|
|
for t := range e.tick {
|
|
if err := e.runOnce(t); err != nil {
|
|
e.log.Error(e.ctx, "error running once", slog.Error(err))
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
return xerrors.Errorf("get eligible workspaces for autostart or autostop: %w", err)
|
|
}
|
|
|
|
for _, ws := range eligibleWorkspaces {
|
|
// Determine the workspace state based on its latest build.
|
|
priorHistory, err := db.GetWorkspaceBuildByWorkspaceIDWithoutAfter(e.ctx, ws.ID)
|
|
if err != nil {
|
|
e.log.Warn(e.ctx, "get latest workspace build",
|
|
slog.F("workspace_id", ws.ID),
|
|
slog.Error(err),
|
|
)
|
|
continue
|
|
}
|
|
|
|
priorJob, err := db.GetProvisionerJobByID(e.ctx, priorHistory.JobID)
|
|
if err != nil {
|
|
e.log.Warn(e.ctx, "get last provisioner job for workspace %q: %w",
|
|
slog.F("workspace_id", ws.ID),
|
|
slog.Error(err),
|
|
)
|
|
continue
|
|
}
|
|
|
|
if !priorJob.CompletedAt.Valid || priorJob.Error.String != "" {
|
|
e.log.Warn(e.ctx, "last workspace build did not complete successfully, skipping",
|
|
slog.F("workspace_id", ws.ID),
|
|
slog.F("error", priorJob.Error.String),
|
|
)
|
|
continue
|
|
}
|
|
|
|
var validTransition database.WorkspaceTransition
|
|
var sched *schedule.Schedule
|
|
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",
|
|
slog.F("workspace_id", ws.ID),
|
|
slog.F("autostart_schedule", ws.AutostopSchedule.String),
|
|
)
|
|
continue
|
|
}
|
|
case database.WorkspaceTransitionStop:
|
|
validTransition = database.WorkspaceTransitionStart
|
|
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),
|
|
slog.F("autostart_schedule", ws.AutostartSchedule.String),
|
|
)
|
|
continue
|
|
}
|
|
default:
|
|
e.log.Debug(e.ctx, "last transition not valid for autostart or autostop",
|
|
slog.F("workspace_id", ws.ID),
|
|
slog.F("latest_build_transition", priorHistory.Transition),
|
|
)
|
|
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) {
|
|
e.log.Debug(e.ctx, "skipping workspace: too early",
|
|
slog.F("workspace_id", ws.ID),
|
|
slog.F("next_transition_at", nextTransitionAt),
|
|
slog.F("transition", validTransition),
|
|
slog.F("current_tick", currentTick),
|
|
)
|
|
continue
|
|
}
|
|
|
|
e.log.Info(e.ctx, "scheduling workspace transition",
|
|
slog.F("workspace_id", ws.ID),
|
|
slog.F("transition", validTransition),
|
|
)
|
|
|
|
if err := build(e.ctx, db, ws, validTransition, priorHistory, priorJob); err != nil {
|
|
e.log.Error(e.ctx, "unable to transition workspace",
|
|
slog.F("workspace_id", ws.ID),
|
|
slog.F("transition", validTransition),
|
|
slog.Error(err),
|
|
)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// TODO(cian): this function duplicates most of api.postWorkspaceBuilds. Refactor.
|
|
// See: https://github.com/coder/coder/issues/1401
|
|
func build(ctx context.Context, store database.Store, workspace database.Workspace, trans database.WorkspaceTransition, priorHistory database.WorkspaceBuild, priorJob database.ProvisionerJob) error {
|
|
template, err := store.GetTemplateByID(ctx, workspace.TemplateID)
|
|
if err != nil {
|
|
return xerrors.Errorf("get workspace template: %w", err)
|
|
}
|
|
|
|
priorHistoryID := uuid.NullUUID{
|
|
UUID: priorHistory.ID,
|
|
Valid: true,
|
|
}
|
|
|
|
var newWorkspaceBuild database.WorkspaceBuild
|
|
// This must happen in a transaction to ensure history can be inserted, and
|
|
// the prior history can update it's "after" column to point at the new.
|
|
workspaceBuildID := uuid.New()
|
|
input, err := json.Marshal(struct {
|
|
WorkspaceBuildID string `json:"workspace_build_id"`
|
|
}{
|
|
WorkspaceBuildID: workspaceBuildID.String(),
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("marshal provision job: %w", err)
|
|
}
|
|
provisionerJobID := uuid.New()
|
|
now := database.Now()
|
|
newProvisionerJob, err := store.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
|
|
ID: provisionerJobID,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
InitiatorID: workspace.OwnerID,
|
|
OrganizationID: template.OrganizationID,
|
|
Provisioner: template.Provisioner,
|
|
Type: database.ProvisionerJobTypeWorkspaceBuild,
|
|
StorageMethod: priorJob.StorageMethod,
|
|
StorageSource: priorJob.StorageSource,
|
|
Input: input,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("insert provisioner job: %w", err)
|
|
}
|
|
newWorkspaceBuild, err = store.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{
|
|
ID: workspaceBuildID,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
WorkspaceID: workspace.ID,
|
|
TemplateVersionID: priorHistory.TemplateVersionID,
|
|
BeforeID: priorHistoryID,
|
|
Name: namesgenerator.GetRandomName(1),
|
|
ProvisionerState: priorHistory.ProvisionerState,
|
|
InitiatorID: workspace.OwnerID,
|
|
Transition: trans,
|
|
JobID: newProvisionerJob.ID,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("insert workspace build: %w", err)
|
|
}
|
|
|
|
if priorHistoryID.Valid {
|
|
// Update the prior history entries "after" column.
|
|
err = store.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
|
|
ID: priorHistory.ID,
|
|
ProvisionerState: priorHistory.ProvisionerState,
|
|
UpdatedAt: now,
|
|
AfterID: uuid.NullUUID{
|
|
UUID: newWorkspaceBuild.ID,
|
|
Valid: true,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("update prior workspace build: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|