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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 765 additions and 6 deletions

1
.gitignore vendored
View File

@ -37,3 +37,4 @@ site/out/
.terraform/
.vscode/*.log
**/*.swp

View File

@ -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"
)

View File

@ -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"
)

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View File

@ -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) {

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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
`

View File

@ -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
*

View File

@ -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"

View File

@ -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"

View File

@ -10,3 +10,4 @@ coverage
storybook-static
test-results
**/*.typegen.ts
**/*.swp

View File

@ -17,3 +17,5 @@ coverage/
out/
storybook-static/
test-results/
**/*.swp