Files
coder/enterprise/coderd/prebuilds/reconciliation_test.go
Danny Kopping a87e12750e More tests
Signed-off-by: Danny Kopping <danny@coder.com>
2025-02-25 07:36:40 +00:00

439 lines
15 KiB
Go

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)
}
// A template marked as deprecated will not have prebuilds running.
func TestDeprecated(t *testing.T) {
current := opts[optionSet0]
// GIVEN: a preset with 1 desired prebuild.
presets := []database.GetTemplatePresetsWithPrebuildsRow{
preset(true, 1, current, func(row database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow {
row.Deprecated = true
return row
}),
}
// GIVEN: 1 running prebuilds for the preset.
running := []database.GetRunningPrebuildsRow{
prebuild(current),
}
// 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: all running prebuilds should be deleted because the template is deprecated.
actions, err := ps.calculateActions()
require.NoError(t, err)
validateActions(t, reconciliationActions{
actual: 1, deleteIDs: []uuid.UUID{current.prebuildID}, eligible: 1,
}, *actions)
}
func preset(active bool, instances int32, opts options, muts ...func(row database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow) database.GetTemplatePresetsWithPrebuildsRow {
entry := database.GetTemplatePresetsWithPrebuildsRow{
TemplateID: opts.templateID,
TemplateVersionID: opts.templateVersionID,
PresetID: opts.presetID,
UsingActiveVersion: active,
Name: opts.presetName,
DesiredInstances: instances,
Deleted: false,
Deprecated: false,
}
for _, mut := range muts {
entry = mut(entry)
}
return entry
}
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")
}