mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
WIP: adding unit-tests for reconciliation loop
Signed-off-by: Danny Kopping <danny@coder.com>
This commit is contained in:
@ -93,6 +93,22 @@ func (c *Controller) ReconcileTemplate(templateID uuid.UUID) {
|
|||||||
c.nudgeCh <- &templateID
|
c.nudgeCh <- &templateID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconcile will attempt to resolve the desired vs actual state of all templates which have presets with prebuilds configured.
|
||||||
|
//
|
||||||
|
// NOTE:
|
||||||
|
//
|
||||||
|
// This function will kick of n provisioner jobs, based on the calculated state modifications.
|
||||||
|
//
|
||||||
|
// These provisioning jobs are fire-and-forget. We DO NOT wait for the prebuilt workspaces to complete their
|
||||||
|
// provisioning. As a consequence, it's possible that another reconciliation run will occur, which will mean that
|
||||||
|
// multiple preset versions could be reconciling at once. This may mean some temporary over-provisioning, but the
|
||||||
|
// reconciliation loop will bring these resources back into their desired numbers in an EVENTUALLY-consistent way.
|
||||||
|
//
|
||||||
|
// For example: we could decide to provision 1 new instance in this reconciliation.
|
||||||
|
// While that workspace is being provisioned, another template version is created which means this same preset will
|
||||||
|
// be reconciled again, leading to another workspace being provisioned. Two workspace builds will be occurring
|
||||||
|
// simultaneously for the same preset, but once both jobs have completed the reconciliation loop will notice the
|
||||||
|
// extraneous instance and delete it.
|
||||||
func (c *Controller) reconcile(ctx context.Context, templateID *uuid.UUID) {
|
func (c *Controller) reconcile(ctx context.Context, templateID *uuid.UUID) {
|
||||||
var logger slog.Logger
|
var logger slog.Logger
|
||||||
if templateID == nil {
|
if templateID == nil {
|
||||||
@ -121,7 +137,7 @@ func (c *Controller) reconcile(ctx context.Context, templateID *uuid.UUID) {
|
|||||||
err := c.store.InTx(func(db database.Store) error {
|
err := c.store.InTx(func(db database.Store) error {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
// TODO: give up after some time waiting on this?
|
// TODO: use TryAcquireLock here and bail out early.
|
||||||
err := db.AcquireLock(ctx, database.LockIDReconcileTemplatePrebuilds)
|
err := db.AcquireLock(ctx, database.LockIDReconcileTemplatePrebuilds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn(ctx, "failed to acquire top-level prebuilds reconciliation lock; likely running on another coderd replica", slog.Error(err))
|
logger.Warn(ctx, "failed to acquire top-level prebuilds reconciliation lock; likely running on another coderd replica", slog.Error(err))
|
||||||
@ -183,7 +199,7 @@ func (c *Controller) reconcile(ctx context.Context, templateID *uuid.UUID) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// determineState determines the current state of prebuilds & the presets which define them.
|
// determineState determines the current state of prebuilds & the presets which define them.
|
||||||
// This function MUST be called within
|
// An application-level lock is used
|
||||||
func (c *Controller) determineState(ctx context.Context, store database.Store, id uuid.NullUUID) (*reconciliationState, error) {
|
func (c *Controller) determineState(ctx context.Context, store database.Store, id uuid.NullUUID) (*reconciliationState, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -259,14 +275,15 @@ func (c *Controller) reconcilePrebuildsForPreset(ctx context.Context, ps *preset
|
|||||||
levelFn = vlogger.Info
|
levelFn = vlogger.Info
|
||||||
}
|
}
|
||||||
levelFn(ctx, "template prebuild state retrieved",
|
levelFn(ctx, "template prebuild state retrieved",
|
||||||
slog.F("to_create", actions.create), slog.F("to_delete", len(actions.deleteIDs)),
|
slog.F("create_count", actions.create), slog.F("delete_count", len(actions.deleteIDs)),
|
||||||
|
slog.F("to_delete", actions.deleteIDs),
|
||||||
slog.F("desired", actions.desired), slog.F("actual", actions.actual),
|
slog.F("desired", actions.desired), slog.F("actual", actions.actual),
|
||||||
slog.F("outdated", actions.outdated), slog.F("extraneous", actions.extraneous),
|
slog.F("outdated", actions.outdated), slog.F("extraneous", actions.extraneous),
|
||||||
slog.F("starting", actions.starting), slog.F("stopping", actions.stopping),
|
slog.F("starting", actions.starting), slog.F("stopping", actions.stopping),
|
||||||
slog.F("deleting", actions.deleting), slog.F("eligible", actions.eligible))
|
slog.F("deleting", actions.deleting), slog.F("eligible", actions.eligible))
|
||||||
|
|
||||||
// Provision workspaces within the same tx so we don't get any timing issues here.
|
// Provision workspaces within the same tx so we don't get any timing issues here.
|
||||||
// i.e. we hold the advisory lock until all reconciliatory actions have been taken.
|
// i.e. we hold the advisory lock until all "reconciliatory" actions have been taken.
|
||||||
// TODO: max per reconciliation iteration?
|
// TODO: max per reconciliation iteration?
|
||||||
|
|
||||||
// TODO: i've removed the surrounding tx, but if we restore it then we need to pass down the store to these funcs.
|
// TODO: i've removed the surrounding tx, but if we restore it then we need to pass down the store to these funcs.
|
||||||
|
@ -1,130 +0,0 @@
|
|||||||
package prebuilds
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
templateID = uuid.New()
|
|
||||||
templateVersionID = uuid.New()
|
|
||||||
presetID = uuid.New()
|
|
||||||
preset2ID = uuid.New()
|
|
||||||
prebuildID = uuid.New()
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestReconciliationActions(t *testing.T) {
|
|
||||||
cases := map[string]struct {
|
|
||||||
preset database.GetTemplatePresetsWithPrebuildsRow // TODO: make own structs; reusing these types is lame
|
|
||||||
running []database.GetRunningPrebuildsRow
|
|
||||||
inProgress []database.GetPrebuildsInProgressRow
|
|
||||||
expected reconciliationActions
|
|
||||||
}{
|
|
||||||
// New template version created which adds a new preset with prebuilds configured.
|
|
||||||
"CreateNetNew": {
|
|
||||||
preset: preset(true, 1),
|
|
||||||
expected: reconciliationActions{
|
|
||||||
desired: 1,
|
|
||||||
create: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// New template version created, making an existing preset and its prebuilds outdated.
|
|
||||||
"DeleteOutdated": {
|
|
||||||
preset: preset(false, 1),
|
|
||||||
running: []database.GetRunningPrebuildsRow{
|
|
||||||
{
|
|
||||||
WorkspaceID: prebuildID,
|
|
||||||
TemplateID: templateID,
|
|
||||||
TemplateVersionID: templateVersionID,
|
|
||||||
CurrentPresetID: uuid.NullUUID{UUID: presetID, Valid: true},
|
|
||||||
DesiredPresetID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
|
||||||
Ready: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: reconciliationActions{
|
|
||||||
outdated: 1,
|
|
||||||
deleteIDs: []uuid.UUID{prebuildID},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Somehow an additional prebuild is running, delete it.
|
|
||||||
// This can happen if an operator messes with a prebuild's state (stop, start).
|
|
||||||
"DeleteOldestExtraneous": {
|
|
||||||
preset: preset(true, 1),
|
|
||||||
running: []database.GetRunningPrebuildsRow{
|
|
||||||
{
|
|
||||||
WorkspaceID: prebuildID,
|
|
||||||
TemplateID: templateID,
|
|
||||||
TemplateVersionID: templateVersionID,
|
|
||||||
CurrentPresetID: uuid.NullUUID{UUID: presetID, Valid: true},
|
|
||||||
DesiredPresetID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
|
||||||
CreatedAt: time.Now().Add(-time.Hour),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
WorkspaceID: uuid.New(),
|
|
||||||
TemplateID: templateID,
|
|
||||||
TemplateVersionID: templateVersionID,
|
|
||||||
CurrentPresetID: uuid.NullUUID{UUID: presetID, Valid: true},
|
|
||||||
DesiredPresetID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: reconciliationActions{
|
|
||||||
desired: 1,
|
|
||||||
extraneous: 1,
|
|
||||||
actual: 2,
|
|
||||||
deleteIDs: []uuid.UUID{prebuildID},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, tc := range cases {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ps := presetState{
|
|
||||||
preset: tc.preset,
|
|
||||||
running: tc.running,
|
|
||||||
inProgress: tc.inProgress,
|
|
||||||
}
|
|
||||||
|
|
||||||
actions, err := ps.calculateActions()
|
|
||||||
require.NoError(t, err, "could not calculate reconciliation actions")
|
|
||||||
|
|
||||||
validateActions(t, tc.expected, *actions)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func preset(active bool, instances int32) database.GetTemplatePresetsWithPrebuildsRow {
|
|
||||||
return database.GetTemplatePresetsWithPrebuildsRow{
|
|
||||||
TemplateID: templateID,
|
|
||||||
TemplateVersionID: templateVersionID,
|
|
||||||
UsingActiveVersion: active,
|
|
||||||
PresetID: presetID,
|
|
||||||
Name: "bob",
|
|
||||||
DesiredInstances: instances,
|
|
||||||
Deleted: false,
|
|
||||||
Deprecated: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateActions is a convenience func to make tests more readable; it exploits the fact that the default states for
|
|
||||||
// prebuilds align with zero values.
|
|
||||||
func validateActions(t *testing.T, expected, actual reconciliationActions) bool {
|
|
||||||
return assert.EqualValuesf(t, expected.deleteIDs, actual.deleteIDs, "'deleteIDs' did not match expectation") &&
|
|
||||||
assert.EqualValuesf(t, expected.create, actual.create, "'create' did not match expectation") &&
|
|
||||||
assert.EqualValuesf(t, expected.desired, actual.desired, "'desired' did not match expectation") &&
|
|
||||||
assert.EqualValuesf(t, expected.actual, actual.actual, "'actual' did not match expectation") &&
|
|
||||||
assert.EqualValuesf(t, expected.eligible, actual.eligible, "'eligible' did not match expectation") &&
|
|
||||||
assert.EqualValuesf(t, expected.extraneous, actual.extraneous, "'extraneous' did not match expectation") &&
|
|
||||||
assert.EqualValuesf(t, expected.outdated, actual.outdated, "'outdated' did not match expectation") &&
|
|
||||||
assert.EqualValuesf(t, expected.starting, actual.starting, "'starting' did not match expectation") &&
|
|
||||||
assert.EqualValuesf(t, expected.stopping, actual.stopping, "'stopping' did not match expectation") &&
|
|
||||||
assert.EqualValuesf(t, expected.deleting, actual.deleting, "'deleting' did not match expectation")
|
|
||||||
}
|
|
@ -47,7 +47,7 @@ func (s reconciliationState) filterByPreset(presetID uuid.UUID) (*presetState, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
running := slice.Filter(s.runningPrebuilds, func(prebuild database.GetRunningPrebuildsRow) bool {
|
running := slice.Filter(s.runningPrebuilds, func(prebuild database.GetRunningPrebuildsRow) bool {
|
||||||
if !prebuild.DesiredPresetID.Valid && !prebuild.CurrentPresetID.Valid {
|
if !prebuild.CurrentPresetID.Valid {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return prebuild.CurrentPresetID.UUID == preset.PresetID &&
|
return prebuild.CurrentPresetID.UUID == preset.PresetID &&
|
||||||
@ -57,8 +57,10 @@ func (s reconciliationState) filterByPreset(presetID uuid.UUID) (*presetState, e
|
|||||||
// These aren't preset-specific, but they need to inhibit all presets of this template from operating since they could
|
// These aren't preset-specific, but they need to inhibit all presets of this template from operating since they could
|
||||||
// be in-progress builds which might impact another preset. For example, if a template goes from no defined prebuilds to defined prebuilds
|
// be in-progress builds which might impact another preset. For example, if a template goes from no defined prebuilds to defined prebuilds
|
||||||
// and back, or a template is updated from one version to another.
|
// and back, or a template is updated from one version to another.
|
||||||
|
// We group by the template so that all prebuilds being provisioned for a prebuild are inhibited if any prebuild for
|
||||||
|
// any preset in that template are in progress, to prevent clobbering.
|
||||||
inProgress := slice.Filter(s.prebuildsInProgress, func(prebuild database.GetPrebuildsInProgressRow) bool {
|
inProgress := slice.Filter(s.prebuildsInProgress, func(prebuild database.GetPrebuildsInProgressRow) bool {
|
||||||
return prebuild.TemplateVersionID == preset.TemplateVersionID
|
return prebuild.TemplateID == preset.TemplateID
|
||||||
})
|
})
|
||||||
|
|
||||||
return &presetState{
|
return &presetState{
|
||||||
@ -103,6 +105,8 @@ func (p presetState) calculateActions() (*reconciliationActions, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-progress builds are common across all presets belonging to a given template.
|
||||||
|
// In other words: these values will be identical across all presets belonging to this template.
|
||||||
for _, progress := range p.inProgress {
|
for _, progress := range p.inProgress {
|
||||||
switch progress.Transition {
|
switch progress.Transition {
|
||||||
case database.WorkspaceTransitionStart:
|
case database.WorkspaceTransitionStart:
|
||||||
@ -138,6 +142,7 @@ func (p presetState) calculateActions() (*reconciliationActions, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Bail early to avoid scheduling new prebuilds while operations are in progress.
|
// Bail early to avoid scheduling new prebuilds while operations are in progress.
|
||||||
|
// TODO: optimization: we should probably be able to create prebuilds while others are deleting for a given preset.
|
||||||
if (toCreate+toDelete) > 0 && (starting+stopping+deleting) > 0 {
|
if (toCreate+toDelete) > 0 && (starting+stopping+deleting) > 0 {
|
||||||
// TODO: move up
|
// TODO: move up
|
||||||
//c.logger.Warn(ctx, "prebuild operations in progress, skipping reconciliation",
|
//c.logger.Warn(ctx, "prebuild operations in progress, skipping reconciliation",
|
||||||
|
400
enterprise/coderd/prebuilds/reconciliation_test.go
Normal file
400
enterprise/coderd/prebuilds/reconciliation_test.go
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
package prebuilds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type options struct {
|
||||||
|
templateID uuid.UUID
|
||||||
|
templateVersionID uuid.UUID
|
||||||
|
presetID uuid.UUID
|
||||||
|
presetName string
|
||||||
|
prebuildID uuid.UUID
|
||||||
|
workspaceName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// templateID is common across all option sets.
|
||||||
|
var templateID = uuid.New()
|
||||||
|
|
||||||
|
const (
|
||||||
|
optionSet0 = iota
|
||||||
|
optionSet1
|
||||||
|
optionSet2
|
||||||
|
)
|
||||||
|
|
||||||
|
var opts = map[uint]options{
|
||||||
|
optionSet0: {
|
||||||
|
templateID: templateID,
|
||||||
|
templateVersionID: uuid.New(),
|
||||||
|
presetID: uuid.New(),
|
||||||
|
presetName: "my-preset",
|
||||||
|
prebuildID: uuid.New(),
|
||||||
|
workspaceName: "prebuilds0",
|
||||||
|
},
|
||||||
|
optionSet1: {
|
||||||
|
templateID: templateID,
|
||||||
|
templateVersionID: uuid.New(),
|
||||||
|
presetID: uuid.New(),
|
||||||
|
presetName: "my-preset",
|
||||||
|
prebuildID: uuid.New(),
|
||||||
|
workspaceName: "prebuilds1",
|
||||||
|
},
|
||||||
|
optionSet2: {
|
||||||
|
templateID: templateID,
|
||||||
|
templateVersionID: uuid.New(),
|
||||||
|
presetID: uuid.New(),
|
||||||
|
presetName: "my-preset",
|
||||||
|
prebuildID: uuid.New(),
|
||||||
|
workspaceName: "prebuilds2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new template version with a preset without prebuilds configured should result in no prebuilds being created.
|
||||||
|
func TestNoPrebuilds(t *testing.T) {
|
||||||
|
current := opts[optionSet0]
|
||||||
|
|
||||||
|
presets := []database.GetTemplatePresetsWithPrebuildsRow{
|
||||||
|
preset(true, 0, current),
|
||||||
|
}
|
||||||
|
|
||||||
|
state := newReconciliationState(presets, nil, nil)
|
||||||
|
ps, err := state.filterByPreset(current.presetID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
actions, err := ps.calculateActions()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
validateActions(t, reconciliationActions{ /*all zero values*/ }, *actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new template version with a preset with prebuilds configured should result in a new prebuild being created.
|
||||||
|
func TestNetNew(t *testing.T) {
|
||||||
|
current := opts[optionSet0]
|
||||||
|
|
||||||
|
presets := []database.GetTemplatePresetsWithPrebuildsRow{
|
||||||
|
preset(true, 1, current),
|
||||||
|
}
|
||||||
|
|
||||||
|
state := newReconciliationState(presets, nil, nil)
|
||||||
|
ps, err := state.filterByPreset(current.presetID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
actions, err := ps.calculateActions()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
validateActions(t, reconciliationActions{
|
||||||
|
desired: 1,
|
||||||
|
create: 1,
|
||||||
|
}, *actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new template version is created with a preset with prebuilds configured; this outdates the older version and
|
||||||
|
// requires the old prebuilds to be destroyed and new prebuilds to be created.
|
||||||
|
func TestOutdatedPrebuilds(t *testing.T) {
|
||||||
|
outdated := opts[optionSet0]
|
||||||
|
current := opts[optionSet1]
|
||||||
|
|
||||||
|
// GIVEN: 2 presets, one outdated and one new.
|
||||||
|
presets := []database.GetTemplatePresetsWithPrebuildsRow{
|
||||||
|
preset(false, 1, outdated),
|
||||||
|
preset(true, 1, current),
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIVEN: a running prebuild for the outdated preset.
|
||||||
|
running := []database.GetRunningPrebuildsRow{
|
||||||
|
prebuild(outdated),
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIVEN: no in-progress builds.
|
||||||
|
var inProgress []database.GetPrebuildsInProgressRow
|
||||||
|
|
||||||
|
// WHEN: calculating the outdated preset's state.
|
||||||
|
state := newReconciliationState(presets, running, inProgress)
|
||||||
|
ps, err := state.filterByPreset(outdated.presetID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// THEN: we should identify that this prebuild is outdated and needs to be deleted.
|
||||||
|
actions, err := ps.calculateActions()
|
||||||
|
require.NoError(t, err)
|
||||||
|
validateActions(t, reconciliationActions{outdated: 1, deleteIDs: []uuid.UUID{outdated.prebuildID}}, *actions)
|
||||||
|
|
||||||
|
// WHEN: calculating the current preset's state.
|
||||||
|
ps, err = state.filterByPreset(current.presetID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// THEN: we should not be blocked from creating a new prebuild while the outdate one deletes.
|
||||||
|
actions, err = ps.calculateActions()
|
||||||
|
require.NoError(t, err)
|
||||||
|
validateActions(t, reconciliationActions{desired: 1, create: 1}, *actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new template version is created with a preset with prebuilds configured; while the outdated prebuild is deleting,
|
||||||
|
// the new preset's prebuild cannot be provisioned concurrently, to prevent clobbering.
|
||||||
|
func TestBlockedOnDeleteActions(t *testing.T) {
|
||||||
|
outdated := opts[optionSet0]
|
||||||
|
current := opts[optionSet1]
|
||||||
|
|
||||||
|
// GIVEN: 2 presets, one outdated and one new.
|
||||||
|
presets := []database.GetTemplatePresetsWithPrebuildsRow{
|
||||||
|
preset(false, 1, outdated),
|
||||||
|
preset(true, 1, current),
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIVEN: a running prebuild for the outdated preset.
|
||||||
|
running := []database.GetRunningPrebuildsRow{
|
||||||
|
prebuild(outdated),
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIVEN: one prebuild for the old preset which is currently deleting.
|
||||||
|
inProgress := []database.GetPrebuildsInProgressRow{
|
||||||
|
{
|
||||||
|
TemplateID: outdated.templateID,
|
||||||
|
TemplateVersionID: outdated.templateVersionID,
|
||||||
|
Transition: database.WorkspaceTransitionDelete,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHEN: calculating the outdated preset's state.
|
||||||
|
state := newReconciliationState(presets, running, inProgress)
|
||||||
|
ps, err := state.filterByPreset(outdated.presetID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// THEN: we should identify that this prebuild is in progress, and not attempt to delete this prebuild again.
|
||||||
|
actions, err := ps.calculateActions()
|
||||||
|
require.NoError(t, err)
|
||||||
|
validateActions(t, reconciliationActions{outdated: 1, deleting: 1}, *actions)
|
||||||
|
|
||||||
|
// WHEN: calculating the current preset's state.
|
||||||
|
ps, err = state.filterByPreset(current.presetID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// THEN: we are blocked from creating a new prebuild while another one is busy provisioning.
|
||||||
|
actions, err = ps.calculateActions()
|
||||||
|
require.NoError(t, err)
|
||||||
|
validateActions(t, reconciliationActions{desired: 1, create: 0, deleting: 1}, *actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new template version is created with a preset with prebuilds configured. An operator comes along and stops one of the
|
||||||
|
// running prebuilds (this shouldn't be done, but it's possible). While this prebuild is stopping, all other prebuild
|
||||||
|
// actions are blocked.
|
||||||
|
func TestBlockedOnStopActions(t *testing.T) {
|
||||||
|
outdated := opts[optionSet0]
|
||||||
|
current := opts[optionSet1]
|
||||||
|
|
||||||
|
// GIVEN: 2 presets, one outdated and one new (which now expects 2 prebuilds!).
|
||||||
|
presets := []database.GetTemplatePresetsWithPrebuildsRow{
|
||||||
|
preset(false, 1, outdated),
|
||||||
|
preset(true, 2, current),
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIVEN: NO running prebuilds for either preset.
|
||||||
|
var running []database.GetRunningPrebuildsRow
|
||||||
|
|
||||||
|
// GIVEN: one prebuild for the old preset which is currently stopping.
|
||||||
|
inProgress := []database.GetPrebuildsInProgressRow{
|
||||||
|
{
|
||||||
|
TemplateID: outdated.templateID,
|
||||||
|
TemplateVersionID: outdated.templateVersionID,
|
||||||
|
Transition: database.WorkspaceTransitionStop,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHEN: calculating the outdated preset's state.
|
||||||
|
state := newReconciliationState(presets, running, inProgress)
|
||||||
|
ps, err := state.filterByPreset(outdated.presetID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// THEN: there is nothing to do.
|
||||||
|
actions, err := ps.calculateActions()
|
||||||
|
require.NoError(t, err)
|
||||||
|
validateActions(t, reconciliationActions{stopping: 1}, *actions)
|
||||||
|
|
||||||
|
// WHEN: calculating the current preset's state.
|
||||||
|
ps, err = state.filterByPreset(current.presetID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// THEN: we are blocked from creating a new prebuild while another one is busy provisioning.
|
||||||
|
actions, err = ps.calculateActions()
|
||||||
|
require.NoError(t, err)
|
||||||
|
validateActions(t, reconciliationActions{desired: 2, stopping: 1, create: 0}, *actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new template version is created with a preset with prebuilds configured; the outdated prebuilds are deleted,
|
||||||
|
// and one of the new prebuilds is already being provisioned, but we bail out early if operations are already in progress
|
||||||
|
// for this prebuild - to prevent clobbering.
|
||||||
|
func TestBlockedOnStartActions(t *testing.T) {
|
||||||
|
outdated := opts[optionSet0]
|
||||||
|
current := opts[optionSet1]
|
||||||
|
|
||||||
|
// GIVEN: 2 presets, one outdated and one new (which now expects 2 prebuilds!).
|
||||||
|
presets := []database.GetTemplatePresetsWithPrebuildsRow{
|
||||||
|
preset(false, 1, outdated),
|
||||||
|
preset(true, 2, current),
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIVEN: NO running prebuilds for either preset.
|
||||||
|
var running []database.GetRunningPrebuildsRow
|
||||||
|
|
||||||
|
// GIVEN: one prebuild for the old preset which is currently provisioning.
|
||||||
|
inProgress := []database.GetPrebuildsInProgressRow{
|
||||||
|
{
|
||||||
|
TemplateID: current.templateID,
|
||||||
|
TemplateVersionID: current.templateVersionID,
|
||||||
|
Transition: database.WorkspaceTransitionStart,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHEN: calculating the outdated preset's state.
|
||||||
|
state := newReconciliationState(presets, running, inProgress)
|
||||||
|
ps, err := state.filterByPreset(outdated.presetID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// THEN: there is nothing to do.
|
||||||
|
actions, err := ps.calculateActions()
|
||||||
|
require.NoError(t, err)
|
||||||
|
validateActions(t, reconciliationActions{starting: 1}, *actions)
|
||||||
|
|
||||||
|
// WHEN: calculating the current preset's state.
|
||||||
|
ps, err = state.filterByPreset(current.presetID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// THEN: we are blocked from creating a new prebuild while another one is busy provisioning.
|
||||||
|
actions, err = ps.calculateActions()
|
||||||
|
require.NoError(t, err)
|
||||||
|
validateActions(t, reconciliationActions{desired: 2, starting: 1, create: 0}, *actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional prebuilds exist for a given preset configuration; these must be deleted.
|
||||||
|
func TestExtraneous(t *testing.T) {
|
||||||
|
current := opts[optionSet0]
|
||||||
|
|
||||||
|
// GIVEN: a preset with 1 desired prebuild.
|
||||||
|
presets := []database.GetTemplatePresetsWithPrebuildsRow{
|
||||||
|
preset(true, 1, current),
|
||||||
|
}
|
||||||
|
|
||||||
|
var older uuid.UUID
|
||||||
|
// GIVEN: 2 running prebuilds for the preset.
|
||||||
|
running := []database.GetRunningPrebuildsRow{
|
||||||
|
prebuild(current, func(row database.GetRunningPrebuildsRow) database.GetRunningPrebuildsRow {
|
||||||
|
// The older of the running prebuilds will be deleted in order to maintain freshness.
|
||||||
|
row.CreatedAt = time.Now().Add(-time.Hour)
|
||||||
|
older = row.WorkspaceID
|
||||||
|
return row
|
||||||
|
}),
|
||||||
|
prebuild(current, func(row database.GetRunningPrebuildsRow) database.GetRunningPrebuildsRow {
|
||||||
|
row.CreatedAt = time.Now()
|
||||||
|
return row
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIVEN: NO prebuilds in progress.
|
||||||
|
var inProgress []database.GetPrebuildsInProgressRow
|
||||||
|
|
||||||
|
// WHEN: calculating the current preset's state.
|
||||||
|
state := newReconciliationState(presets, running, inProgress)
|
||||||
|
ps, err := state.filterByPreset(current.presetID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// THEN: an extraneous prebuild is detected and marked for deletion.
|
||||||
|
actions, err := ps.calculateActions()
|
||||||
|
require.NoError(t, err)
|
||||||
|
validateActions(t, reconciliationActions{
|
||||||
|
actual: 2, desired: 1, extraneous: 1, deleteIDs: []uuid.UUID{older}, eligible: 2,
|
||||||
|
}, *actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// As above, but no actions will be performed because
|
||||||
|
func TestExtraneousInProgress(t *testing.T) {
|
||||||
|
current := opts[optionSet0]
|
||||||
|
|
||||||
|
// GIVEN: a preset with 1 desired prebuild.
|
||||||
|
presets := []database.GetTemplatePresetsWithPrebuildsRow{
|
||||||
|
preset(true, 1, current),
|
||||||
|
}
|
||||||
|
|
||||||
|
var older uuid.UUID
|
||||||
|
// GIVEN: 2 running prebuilds for the preset.
|
||||||
|
running := []database.GetRunningPrebuildsRow{
|
||||||
|
prebuild(current, func(row database.GetRunningPrebuildsRow) database.GetRunningPrebuildsRow {
|
||||||
|
// The older of the running prebuilds will be deleted in order to maintain freshness.
|
||||||
|
row.CreatedAt = time.Now().Add(-time.Hour)
|
||||||
|
older = row.WorkspaceID
|
||||||
|
return row
|
||||||
|
}),
|
||||||
|
prebuild(current, func(row database.GetRunningPrebuildsRow) database.GetRunningPrebuildsRow {
|
||||||
|
row.CreatedAt = time.Now()
|
||||||
|
return row
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIVEN: NO prebuilds in progress.
|
||||||
|
var inProgress []database.GetPrebuildsInProgressRow
|
||||||
|
|
||||||
|
// WHEN: calculating the current preset's state.
|
||||||
|
state := newReconciliationState(presets, running, inProgress)
|
||||||
|
ps, err := state.filterByPreset(current.presetID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// THEN: an extraneous prebuild is detected and marked for deletion.
|
||||||
|
actions, err := ps.calculateActions()
|
||||||
|
require.NoError(t, err)
|
||||||
|
validateActions(t, reconciliationActions{
|
||||||
|
actual: 2, desired: 1, extraneous: 1, deleteIDs: []uuid.UUID{older}, eligible: 2,
|
||||||
|
}, *actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func preset(active bool, instances int32, opts options) database.GetTemplatePresetsWithPrebuildsRow {
|
||||||
|
return database.GetTemplatePresetsWithPrebuildsRow{
|
||||||
|
TemplateID: opts.templateID,
|
||||||
|
TemplateVersionID: opts.templateVersionID,
|
||||||
|
PresetID: opts.presetID,
|
||||||
|
UsingActiveVersion: active,
|
||||||
|
Name: opts.presetName,
|
||||||
|
DesiredInstances: instances,
|
||||||
|
Deleted: false,
|
||||||
|
Deprecated: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prebuild(opts options, muts ...func(row database.GetRunningPrebuildsRow) database.GetRunningPrebuildsRow) database.GetRunningPrebuildsRow {
|
||||||
|
entry := database.GetRunningPrebuildsRow{
|
||||||
|
WorkspaceID: opts.prebuildID,
|
||||||
|
WorkspaceName: opts.workspaceName,
|
||||||
|
TemplateID: opts.templateID,
|
||||||
|
TemplateVersionID: opts.templateVersionID,
|
||||||
|
CurrentPresetID: uuid.NullUUID{UUID: opts.presetID, Valid: true},
|
||||||
|
Ready: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mut := range muts {
|
||||||
|
entry = mut(entry)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateActions is a convenience func to make tests more readable; it exploits the fact that the default states for
|
||||||
|
// prebuilds align with zero values.
|
||||||
|
func validateActions(t *testing.T, expected, actual reconciliationActions) bool {
|
||||||
|
return assert.EqualValuesf(t, expected.deleteIDs, actual.deleteIDs, "'deleteIDs' did not match expectation") &&
|
||||||
|
assert.EqualValuesf(t, expected.create, actual.create, "'create' did not match expectation") &&
|
||||||
|
assert.EqualValuesf(t, expected.desired, actual.desired, "'desired' did not match expectation") &&
|
||||||
|
assert.EqualValuesf(t, expected.actual, actual.actual, "'actual' did not match expectation") &&
|
||||||
|
assert.EqualValuesf(t, expected.eligible, actual.eligible, "'eligible' did not match expectation") &&
|
||||||
|
assert.EqualValuesf(t, expected.extraneous, actual.extraneous, "'extraneous' did not match expectation") &&
|
||||||
|
assert.EqualValuesf(t, expected.outdated, actual.outdated, "'outdated' did not match expectation") &&
|
||||||
|
assert.EqualValuesf(t, expected.starting, actual.starting, "'starting' did not match expectation") &&
|
||||||
|
assert.EqualValuesf(t, expected.stopping, actual.stopping, "'stopping' did not match expectation") &&
|
||||||
|
assert.EqualValuesf(t, expected.deleting, actual.deleting, "'deleting' did not match expectation")
|
||||||
|
}
|
Reference in New Issue
Block a user