mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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)
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package executor_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/goleak"
|
||||
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExecutorAutostartOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
err error
|
||||
tickCh = make(chan time.Time)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
LifecycleTicker: tickCh,
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
)
|
||||
// Given: workspace is stopped
|
||||
workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
|
||||
|
||||
// Given: the workspace initially has autostart disabled
|
||||
require.Empty(t, workspace.AutostartSchedule)
|
||||
|
||||
// When: we enable workspace autostart
|
||||
sched, err := schedule.Weekly("* * * * *")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||
Schedule: sched.String(),
|
||||
}))
|
||||
|
||||
// When: the autobuild executor ticks
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: the workspace should be started
|
||||
<-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, database.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected latest transition to be start")
|
||||
}
|
||||
|
||||
func TestExecutorAutostartTemplateUpdated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
err error
|
||||
tickCh = make(chan time.Time)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
LifecycleTicker: tickCh,
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
)
|
||||
// Given: workspace is stopped
|
||||
workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
|
||||
|
||||
// Given: the workspace initially has autostart disabled
|
||||
require.Empty(t, workspace.AutostartSchedule)
|
||||
|
||||
// Given: the workspace template has been updated
|
||||
orgs, err := client.OrganizationsByUser(ctx, workspace.OwnerID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, orgs, 1)
|
||||
|
||||
newVersion := coderdtest.UpdateTemplateVersion(t, client, orgs[0].ID, nil, workspace.TemplateID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, newVersion.ID)
|
||||
require.NoError(t, client.UpdateActiveTemplateVersion(ctx, workspace.TemplateID, codersdk.UpdateActiveTemplateVersion{
|
||||
ID: newVersion.ID,
|
||||
}))
|
||||
|
||||
// When: we enable workspace autostart
|
||||
sched, err := schedule.Weekly("* * * * *")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||
Schedule: sched.String(),
|
||||
}))
|
||||
|
||||
// When: the autobuild executor ticks
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: the workspace should be started using the previous template version, and not the updated version.
|
||||
<-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, database.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected latest transition to be start")
|
||||
require.Equal(t, workspace.LatestBuild.TemplateVersionID, ws.LatestBuild.TemplateVersionID, "expected workspace build to be using the old template version")
|
||||
}
|
||||
|
||||
func TestExecutorAutostartAlreadyRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
err error
|
||||
tickCh = make(chan time.Time)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
LifecycleTicker: tickCh,
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
)
|
||||
|
||||
// Given: we ensure the workspace is running
|
||||
require.Equal(t, database.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
|
||||
|
||||
// Given: the workspace initially has autostart disabled
|
||||
require.Empty(t, workspace.AutostartSchedule)
|
||||
|
||||
// When: we enable workspace autostart
|
||||
sched, err := schedule.Weekly("* * * * *")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.UpdateWorkspaceAutostart(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{
|
||||
Schedule: sched.String(),
|
||||
}))
|
||||
|
||||
// When: the autobuild executor ticks
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: the workspace should not be started.
|
||||
<-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, database.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
|
||||
}
|
||||
|
||||
func TestExecutorAutostartNotEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
tickCh = make(chan time.Time)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
LifecycleTicker: tickCh,
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
)
|
||||
|
||||
// Given: workspace is stopped
|
||||
workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
|
||||
|
||||
// Given: the workspace has autostart disabled
|
||||
require.Empty(t, workspace.AutostartSchedule)
|
||||
|
||||
// When: the autobuild executor ticks
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: the workspace should not be started.
|
||||
<-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.NotEqual(t, database.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace not to be running")
|
||||
}
|
||||
|
||||
func TestExecutorAutostopOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
err error
|
||||
tickCh = make(chan time.Time)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
LifecycleTicker: tickCh,
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
)
|
||||
// Given: workspace is running
|
||||
require.Equal(t, database.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
|
||||
|
||||
// Given: the workspace initially has autostop disabled
|
||||
require.Empty(t, workspace.AutostopSchedule)
|
||||
|
||||
// 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(),
|
||||
}))
|
||||
|
||||
// When: the autobuild executor ticks
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: the workspace should be started
|
||||
<-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, database.WorkspaceTransitionStop, ws.LatestBuild.Transition, "expected workspace not to be running")
|
||||
}
|
||||
|
||||
func TestExecutorAutostopAlreadyStopped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
err error
|
||||
tickCh = make(chan time.Time)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
LifecycleTicker: tickCh,
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
// When: we enable workspace autostart
|
||||
sched, err := schedule.Weekly("* * * * *")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
|
||||
Schedule: sched.String(),
|
||||
}))
|
||||
|
||||
// When: the autobuild executor ticks
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: the workspace should not be stopped.
|
||||
<-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, database.WorkspaceTransitionStop, ws.LatestBuild.Transition, "expected workspace not to be running")
|
||||
}
|
||||
|
||||
func TestExecutorAutostopNotEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
tickCh = make(chan time.Time)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
LifecycleTicker: tickCh,
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
)
|
||||
|
||||
// Given: workspace is running
|
||||
require.Equal(t, database.WorkspaceTransitionStart, workspace.LatestBuild.Transition)
|
||||
|
||||
// Given: the workspace has autostop disabled
|
||||
require.Empty(t, workspace.AutostopSchedule)
|
||||
|
||||
// When: the autobuild executor ticks
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(time.Minute)
|
||||
close(tickCh)
|
||||
}()
|
||||
|
||||
// Then: the workspace should not be stopped.
|
||||
<-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, database.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
|
||||
}
|
||||
|
||||
func TestExecutorWorkspaceDeleted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
err error
|
||||
tickCh = make(chan time.Time)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
LifecycleTicker: tickCh,
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
)
|
||||
|
||||
// Given: the workspace initially has autostart disabled
|
||||
require.Empty(t, workspace.AutostopSchedule)
|
||||
|
||||
// When: we enable workspace autostart
|
||||
sched, err := schedule.Weekly("* * * * *")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.UpdateWorkspaceAutostop(ctx, workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{
|
||||
Schedule: sched.String(),
|
||||
}))
|
||||
|
||||
// Given: workspace is deleted
|
||||
workspace = mustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionDelete)
|
||||
|
||||
// When: the autobuild executor ticks
|
||||
go func() {
|
||||
tickCh <- time.Now().UTC().Add(time.Minute)
|
||||
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, database.WorkspaceTransitionDelete, ws.LatestBuild.Transition, "expected workspace to be deleted")
|
||||
}
|
||||
|
||||
func TestExecutorWorkspaceTooEarly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = context.Background()
|
||||
err error
|
||||
tickCh = make(chan time.Time)
|
||||
client = coderdtest.New(t, &coderdtest.Options{
|
||||
LifecycleTicker: tickCh,
|
||||
})
|
||||
// Given: we have a user with a workspace
|
||||
workspace = mustProvisionWorkspace(t, client)
|
||||
)
|
||||
|
||||
// Given: the workspace initially has autostart disabled
|
||||
require.Empty(t, workspace.AutostopSchedule)
|
||||
|
||||
// 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{
|
||||
Schedule: sched.String(),
|
||||
}))
|
||||
|
||||
// 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, database.WorkspaceTransitionStart, ws.LatestBuild.Transition, "expected workspace to be running")
|
||||
}
|
||||
|
||||
func mustProvisionWorkspace(t *testing.T, client *codersdk.Client) codersdk.Workspace {
|
||||
t.Helper()
|
||||
coderdtest.NewProvisionerDaemon(t, client)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, ws.LatestBuild.ID)
|
||||
return mustWorkspace(t, client, ws.ID)
|
||||
}
|
||||
|
||||
func mustTransitionWorkspace(t *testing.T, client *codersdk.Client, workspaceID uuid.UUID, from, to database.WorkspaceTransition) codersdk.Workspace {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
workspace, err := client.Workspace(ctx, workspaceID)
|
||||
require.NoError(t, err, "unexpected error fetching workspace")
|
||||
require.Equal(t, workspace.LatestBuild.Transition, from, "expected workspace state: %s got: %s", from, workspace.LatestBuild.Transition)
|
||||
|
||||
template, err := client.Template(ctx, workspace.TemplateID)
|
||||
require.NoError(t, err, "fetch workspace template")
|
||||
|
||||
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Transition: to,
|
||||
})
|
||||
require.NoError(t, err, "unexpected error transitioning workspace to %s", to)
|
||||
|
||||
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
|
||||
|
||||
updated := mustWorkspace(t, client, workspace.ID)
|
||||
require.Equal(t, to, updated.LatestBuild.Transition, "expected workspace to be in state %s but got %s", to, updated.LatestBuild.Transition)
|
||||
return updated
|
||||
}
|
||||
|
||||
func mustWorkspace(t *testing.T, client *codersdk.Client, workspaceID uuid.UUID) codersdk.Workspace {
|
||||
ctx := context.Background()
|
||||
ws, err := client.Workspace(ctx, workspaceID)
|
||||
require.NoError(t, err, "no workspace found with id %s", workspaceID)
|
||||
return ws
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// package schedule provides utilities for parsing and deserializing
|
||||
// cron-style expressions.
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// For the purposes of this library, we only need minute, hour, and
|
||||
// day-of-week. However to ensure interoperability we will use the standard
|
||||
// five-valued cron format. Descriptors are not supported.
|
||||
const parserFormat = cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow
|
||||
|
||||
var defaultParser = cron.NewParser(parserFormat)
|
||||
|
||||
// Weekly parses a Schedule from spec scoped to a recurring weekly event.
|
||||
// Spec consists of the following space-delimited fields, in the following order:
|
||||
// - timezone e.g. CRON_TZ=US/Central (optional)
|
||||
// - minutes of hour e.g. 30 (required)
|
||||
// - hour of day e.g. 9 (required)
|
||||
// - day of month (must be *)
|
||||
// - month (must be *)
|
||||
// - day of week e.g. 1 (required)
|
||||
//
|
||||
// Example Usage:
|
||||
// local_sched, _ := schedule.Weekly("59 23 *")
|
||||
// fmt.Println(sched.Next(time.Now().Format(time.RFC3339)))
|
||||
// // Output: 2022-04-04T23:59:00Z
|
||||
//
|
||||
// us_sched, _ := schedule.Weekly("CRON_TZ=US/Central 30 9 1-5")
|
||||
// fmt.Println(sched.Next(time.Now()).Format(time.RFC3339))
|
||||
// // Output: 2022-04-04T14:30:00Z
|
||||
func Weekly(spec string) (*Schedule, error) {
|
||||
if err := validateWeeklySpec(spec); err != nil {
|
||||
return nil, xerrors.Errorf("validate weekly schedule: %w", err)
|
||||
}
|
||||
|
||||
specSched, err := defaultParser.Parse(spec)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse schedule: %w", err)
|
||||
}
|
||||
|
||||
schedule, ok := specSched.(*cron.SpecSchedule)
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("expected *cron.SpecSchedule but got %T", specSched)
|
||||
}
|
||||
|
||||
cronSched := &Schedule{
|
||||
sched: schedule,
|
||||
spec: spec,
|
||||
}
|
||||
return cronSched, nil
|
||||
}
|
||||
|
||||
// Schedule represents a cron schedule.
|
||||
// It's essentially a thin wrapper for robfig/cron/v3 that implements Stringer.
|
||||
type Schedule struct {
|
||||
sched *cron.SpecSchedule
|
||||
// XXX: there isn't any nice way for robfig/cron to serialize
|
||||
spec string
|
||||
}
|
||||
|
||||
// String serializes the schedule to its original human-friendly format.
|
||||
func (s Schedule) String() string {
|
||||
return s.spec
|
||||
}
|
||||
|
||||
// Next returns the next time in the schedule relative to t.
|
||||
func (s Schedule) Next(t time.Time) time.Time {
|
||||
return s.sched.Next(t)
|
||||
}
|
||||
|
||||
// validateWeeklySpec ensures that the day-of-month and month options of
|
||||
// spec are both set to *
|
||||
func validateWeeklySpec(spec string) error {
|
||||
parts := strings.Fields(spec)
|
||||
if len(parts) < 5 {
|
||||
return xerrors.Errorf("expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix")
|
||||
}
|
||||
if len(parts) == 6 {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if parts[2] != "*" || parts[3] != "*" {
|
||||
return xerrors.Errorf("expected month and dom to be *")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package schedule_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/autobuild/schedule"
|
||||
)
|
||||
|
||||
func Test_Weekly(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
at time.Time
|
||||
expectedNext time.Time
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "with timezone",
|
||||
spec: "CRON_TZ=US/Central 30 9 * * 1-5",
|
||||
at: time.Date(2022, 4, 1, 14, 29, 0, 0, time.UTC),
|
||||
expectedNext: time.Date(2022, 4, 1, 14, 30, 0, 0, time.UTC),
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "without timezone",
|
||||
spec: "30 9 * * 1-5",
|
||||
at: time.Date(2022, 4, 1, 9, 29, 0, 0, time.Local),
|
||||
expectedNext: time.Date(2022, 4, 1, 9, 30, 0, 0, time.Local),
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "invalid schedule",
|
||||
spec: "asdfasdfasdfsd",
|
||||
at: time.Time{},
|
||||
expectedNext: time.Time{},
|
||||
expectedError: "validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix",
|
||||
},
|
||||
{
|
||||
name: "invalid location",
|
||||
spec: "CRON_TZ=Fictional/Country 30 9 * * 1-5",
|
||||
at: time.Time{},
|
||||
expectedNext: time.Time{},
|
||||
expectedError: "parse schedule: provided bad location Fictional/Country: unknown time zone Fictional/Country",
|
||||
},
|
||||
{
|
||||
name: "invalid schedule with 3 fields",
|
||||
spec: "CRON_TZ=Fictional/Country 30 9 1-5",
|
||||
at: time.Time{},
|
||||
expectedNext: time.Time{},
|
||||
expectedError: "validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix",
|
||||
},
|
||||
{
|
||||
name: "invalid schedule with 3 fields and no timezone",
|
||||
spec: "30 9 1-5",
|
||||
at: time.Time{},
|
||||
expectedNext: time.Time{},
|
||||
expectedError: "validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ=<timezone> prefix",
|
||||
},
|
||||
{
|
||||
name: "valid schedule with 5 fields but month and dom not set to *",
|
||||
spec: "30 9 1 1 1-5",
|
||||
at: time.Time{},
|
||||
expectedNext: time.Time{},
|
||||
expectedError: "validate weekly schedule: expected month and dom to be *",
|
||||
},
|
||||
{
|
||||
name: "valid schedule with 5 fields and timezone but month and dom not set to *",
|
||||
spec: "CRON_TZ=Europe/Dublin 30 9 1 1 1-5",
|
||||
at: time.Time{},
|
||||
expectedNext: time.Time{},
|
||||
expectedError: "validate weekly schedule: expected month and dom to be *",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
testCase := testCase
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
actual, err := schedule.Weekly(testCase.spec)
|
||||
if testCase.expectedError == "" {
|
||||
nextTime := actual.Next(testCase.at)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testCase.expectedNext, nextTime)
|
||||
require.Equal(t, testCase.spec, actual.String())
|
||||
} else {
|
||||
require.EqualError(t, err, testCase.expectedError)
|
||||
require.Nil(t, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user