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:
Cian Johnston
2022-05-11 23:03:02 +01:00
committed by GitHub
parent e8e6d3c2f1
commit f4da5d4f3a
17 changed files with 765 additions and 6 deletions
@@ -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)
}
+91
View File
@@ -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)
}
})
}
}