diff --git a/.gitignore b/.gitignore index 6380b3699a8b5..1b310e84cbde2 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ site/out/ .terraform/ .vscode/*.log +**/*.swp diff --git a/cli/autostart.go b/cli/autostart.go index b3929cac9aff3..b2c3fe37a0596 100644 --- a/cli/autostart.go +++ b/cli/autostart.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" - "github.com/coder/coder/coderd/autostart/schedule" + "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/codersdk" ) diff --git a/cli/autostop.go b/cli/autostop.go index a76dbb3f95c66..e344338707a50 100644 --- a/cli/autostop.go +++ b/cli/autostop.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" - "github.com/coder/coder/coderd/autostart/schedule" + "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/codersdk" ) diff --git a/cli/server.go b/cli/server.go index 3b2392f51dd9b..00c3db73e0623 100644 --- a/cli/server.go +++ b/cli/server.go @@ -39,6 +39,7 @@ import ( "github.com/coder/coder/cli/cliui" "github.com/coder/coder/cli/config" "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/autobuild/executor" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/databasefake" "github.com/coder/coder/coderd/devtunnel" @@ -343,6 +344,11 @@ func server() *cobra.Command { return xerrors.Errorf("notify systemd: %w", err) } + lifecyclePoller := time.NewTicker(time.Minute) + defer lifecyclePoller.Stop() + lifecycleExecutor := executor.New(cmd.Context(), options.Database, logger, lifecyclePoller.C) + lifecycleExecutor.Run() + // Because the graceful shutdown includes cleaning up workspaces in dev mode, we're // going to make it harder to accidentally skip the graceful shutdown by hitting ctrl+c // two or more times. So the stopChan is unlimited in size and we don't call diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go new file mode 100644 index 0000000000000..3fd1eb7fc28af --- /dev/null +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -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 +} diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go new file mode 100644 index 0000000000000..330662f3fc905 --- /dev/null +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -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) +} diff --git a/coderd/autostart/schedule/schedule.go b/coderd/autobuild/schedule/schedule.go similarity index 100% rename from coderd/autostart/schedule/schedule.go rename to coderd/autobuild/schedule/schedule.go diff --git a/coderd/autostart/schedule/schedule_test.go b/coderd/autobuild/schedule/schedule_test.go similarity index 98% rename from coderd/autostart/schedule/schedule_test.go rename to coderd/autobuild/schedule/schedule_test.go index d29f5505270fa..1109ced96c93d 100644 --- a/coderd/autostart/schedule/schedule_test.go +++ b/coderd/autobuild/schedule/schedule_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/autostart/schedule" + "github.com/coder/coder/coderd/autobuild/schedule" ) func Test_Weekly(t *testing.T) { diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index e205e272db3ce..2441dbff95946 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -36,6 +36,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/autobuild/executor" "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/databasefake" @@ -57,6 +58,7 @@ type Options struct { GoogleTokenValidator *idtoken.Validator SSHKeygenAlgorithm gitsshkey.Algorithm APIRateLimit int + LifecycleTicker <-chan time.Time } // New constructs an in-memory coderd instance and returns @@ -72,6 +74,11 @@ func New(t *testing.T, options *Options) *codersdk.Client { options.GoogleTokenValidator, err = idtoken.NewValidator(ctx, option.WithoutAuthentication()) require.NoError(t, err) } + if options.LifecycleTicker == nil { + ticker := make(chan time.Time) + options.LifecycleTicker = ticker + t.Cleanup(func() { close(ticker) }) + } // This can be hotswapped for a live database instance. db := databasefake.New() @@ -96,8 +103,16 @@ func New(t *testing.T, options *Options) *codersdk.Client { }) } - srv := httptest.NewUnstartedServer(nil) ctx, cancelFunc := context.WithCancel(context.Background()) + lifecycleExecutor := executor.New( + ctx, + db, + slogtest.Make(t, nil).Named("autobuild.executor").Leveled(slog.LevelDebug), + options.LifecycleTicker, + ) + lifecycleExecutor.Run() + + srv := httptest.NewUnstartedServer(nil) srv.Config.BaseContext = func(_ net.Listener) context.Context { return ctx } @@ -246,6 +261,23 @@ func CreateTemplate(t *testing.T, client *codersdk.Client, organization uuid.UUI return template } +// UpdateTemplateVersion creates a new template version with the "echo" provisioner +// and associates it with the given templateID. +func UpdateTemplateVersion(t *testing.T, client *codersdk.Client, organizationID uuid.UUID, res *echo.Responses, templateID uuid.UUID) codersdk.TemplateVersion { + data, err := echo.Tar(res) + require.NoError(t, err) + file, err := client.Upload(context.Background(), codersdk.ContentTypeTar, data) + require.NoError(t, err) + templateVersion, err := client.CreateTemplateVersion(context.Background(), organizationID, codersdk.CreateTemplateVersionRequest{ + TemplateID: templateID, + StorageSource: file.Hash, + StorageMethod: database.ProvisionerStorageMethodFile, + Provisioner: database.ProvisionerTypeEcho, + }) + require.NoError(t, err) + return templateVersion +} + // AwaitTemplateImportJob awaits for an import job to reach completed status. func AwaitTemplateVersionJob(t *testing.T, client *codersdk.Client, version uuid.UUID) codersdk.TemplateVersion { var templateVersion codersdk.TemplateVersion diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index f1ec50bbbbe2f..466b586057205 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -325,6 +325,20 @@ func (q *fakeQuerier) GetWorkspaceByOwnerIDAndName(_ context.Context, arg databa return database.Workspace{}, sql.ErrNoRows } +func (q *fakeQuerier) GetWorkspacesAutostartAutostop(_ context.Context) ([]database.Workspace, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + workspaces := make([]database.Workspace, 0) + for _, ws := range q.workspaces { + if ws.AutostartSchedule.String != "" { + workspaces = append(workspaces, ws) + } else if ws.AutostopSchedule.String != "" { + workspaces = append(workspaces, ws) + } + } + return workspaces, nil +} + func (q *fakeQuerier) GetWorkspaceOwnerCountsByTemplateIDs(_ context.Context, templateIDs []uuid.UUID) ([]database.GetWorkspaceOwnerCountsByTemplateIDsRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4182d15284a18..088ed4f0c32c2 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -69,6 +69,7 @@ type querier interface { GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error) + GetWorkspacesAutostartAutostop(ctx context.Context) ([]Workspace, error) GetWorkspacesByOrganizationID(ctx context.Context, arg GetWorkspacesByOrganizationIDParams) ([]Workspace, error) GetWorkspacesByOrganizationIDs(ctx context.Context, arg GetWorkspacesByOrganizationIDsParams) ([]Workspace, error) GetWorkspacesByOwnerID(ctx context.Context, arg GetWorkspacesByOwnerIDParams) ([]Workspace, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 362bae614c15e..e7527c2218a77 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3202,6 +3202,55 @@ func (q *sqlQuerier) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, i return items, nil } +const getWorkspacesAutostartAutostop = `-- name: GetWorkspacesAutostartAutostop :many +SELECT + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule +FROM + workspaces +WHERE + deleted = false +AND +( + autostart_schedule <> '' + OR + autostop_schedule <> '' +) +` + +func (q *sqlQuerier) GetWorkspacesAutostartAutostop(ctx context.Context) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesAutostartAutostop) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Workspace + for rows.Next() { + var i Workspace + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.AutostopSchedule, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspacesByOrganizationID = `-- name: GetWorkspacesByOrganizationID :many SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE organization_id = $1 AND deleted = $2 ` diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index d271f7ddf7847..baf42addfc2cd 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -14,6 +14,20 @@ SELECT * FROM workspaces WHERE organization_id = $1 AND deleted = $2; -- name: GetWorkspacesByOrganizationIDs :many SELECT * FROM workspaces WHERE organization_id = ANY(@ids :: uuid [ ]) AND deleted = @deleted; +-- name: GetWorkspacesAutostartAutostop :many +SELECT + * +FROM + workspaces +WHERE + deleted = false +AND +( + autostart_schedule <> '' + OR + autostop_schedule <> '' +); + -- name: GetWorkspacesByTemplateID :many SELECT * diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0ac6ae516507c..76384f7e1050c 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -13,7 +13,7 @@ import ( "github.com/moby/moby/pkg/namesgenerator" "golang.org/x/xerrors" - "github.com/coder/coder/coderd/autostart/schedule" + "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 862414a62bf19..f7f31e0017637 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -10,7 +10,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/coder/coder/coderd/autostart/schedule" + "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" "github.com/coder/coder/codersdk" diff --git a/site/.eslintignore b/site/.eslintignore index ef5e018bd53a5..8c9ebc52810d4 100644 --- a/site/.eslintignore +++ b/site/.eslintignore @@ -10,3 +10,4 @@ coverage storybook-static test-results **/*.typegen.ts +**/*.swp diff --git a/site/.prettierignore b/site/.prettierignore index 5de23f9b61ffe..9599675c1dbef 100644 --- a/site/.prettierignore +++ b/site/.prettierignore @@ -17,3 +17,5 @@ coverage/ out/ storybook-static/ test-results/ + +**/*.swp