Files
coder/enterprise/cli/create_test.go
T
Callum Styan 5f3be6b288 feat: add provisioner job queue wait time histogram and jobs enqueued counter (#21869)
This PR adds some metrics to help identify job enqueue rates and
latencies. This work was initiated as a way to help reduce the cost of
the observation/measurement itself for autostart scaletests, which
impacts our ability to identify/reason about the load caused by
autostart. See: https://github.com/coder/internal/issues/1209

I've extended the metrics here to account for regular user initiated
builds, prebuilds, autostarts, etc. IMO there is still the question here
of whether we want to include or need the `transition` label, which is
only present on workspace builds. Including it does lead to an increase
in cardinality, and in the case of the histogram (when not using native
histograms) that's at least a few extra series for every bucket. We
could remove the transition label there but keep it on the counter.

Additionally, the histogram is currently observing latencies for other
jobs, such as template builds/version imports, those do not have a
transition type associated with them.

Tested briefly in a workspace, can see metric values like the following:
-
`coderd_workspace_builds_enqueued_total{build_reason="autostart",provisioner_type="terraform",status="success",transition="start"}
1`
-
`coderd_provisioner_job_queue_wait_seconds_bucket{build_reason="autostart",job_type="workspace_build",provisioner_type="terraform",transition="start",le="0.025"}
1`

---------

Signed-off-by: Callum Styan <callumstyan@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 13:40:47 -08:00

592 lines
20 KiB
Go

package cli_test
import (
"context"
"database/sql"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace/noop"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/files"
"github.com/coder/coder/v2/coderd/notifications"
agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/enterprise/coderd/prebuilds"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestEnterpriseCreate(t *testing.T) {
t.Parallel()
type setupData struct {
firstResponse codersdk.CreateFirstUserResponse
second codersdk.Organization
owner *codersdk.Client
member *codersdk.Client
}
type setupArgs struct {
firstTemplates []string
secondTemplates []string
}
// setupMultipleOrganizations creates an extra organization, assigns a member
// both organizations, and optionally creates templates in each organization.
setupMultipleOrganizations := func(t *testing.T, args setupArgs) setupData {
ownerClient, first := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
// This only affects the first org.
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureExternalProvisionerDaemons: 1,
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
second := coderdenttest.CreateOrganization(t, ownerClient, coderdenttest.CreateOrganizationOptions{
IncludeProvisionerDaemon: true,
})
member, _ := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID, rbac.ScopedRoleOrgMember(second.ID))
var wg sync.WaitGroup
createTemplate := func(tplName string, orgID uuid.UUID) {
version := coderdtest.CreateTemplateVersion(t, ownerClient, orgID, nil)
wg.Add(1)
go func() {
coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, version.ID)
wg.Done()
}()
coderdtest.CreateTemplate(t, ownerClient, orgID, version.ID, func(request *codersdk.CreateTemplateRequest) {
request.Name = tplName
})
}
for _, tplName := range args.firstTemplates {
createTemplate(tplName, first.OrganizationID)
}
for _, tplName := range args.secondTemplates {
createTemplate(tplName, second.ID)
}
wg.Wait()
return setupData{
firstResponse: first,
owner: ownerClient,
second: second,
member: member,
}
}
// Test creating a workspace in the second organization with a template
// name.
t.Run("CreateMultipleOrganization", func(t *testing.T) {
t.Parallel()
const templateName = "secondtemplate"
setup := setupMultipleOrganizations(t, setupArgs{
secondTemplates: []string{templateName},
})
member := setup.member
args := []string{
"create",
"my-workspace",
"-y",
"--template", templateName,
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
_ = ptytest.New(t).Attach(inv)
err := inv.Run()
require.NoError(t, err)
ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
if assert.NoError(t, err, "expected workspace to be created") {
assert.Equal(t, ws.TemplateName, templateName)
assert.Equal(t, ws.OrganizationName, setup.second.Name, "workspace in second organization")
}
})
// If a template name exists in two organizations, the workspace create will
// fail.
t.Run("AmbiguousTemplateName", func(t *testing.T) {
t.Parallel()
const templateName = "ambiguous"
setup := setupMultipleOrganizations(t, setupArgs{
firstTemplates: []string{templateName},
secondTemplates: []string{templateName},
})
member := setup.member
args := []string{
"create",
"my-workspace",
"-y",
"--template", templateName,
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
_ = ptytest.New(t).Attach(inv)
err := inv.Run()
require.Error(t, err, "expected error due to ambiguous template name")
require.ErrorContains(t, err, "multiple templates found")
})
// Ambiguous template names are allowed if the organization is specified.
t.Run("WorkingAmbiguousTemplateName", func(t *testing.T) {
t.Parallel()
const templateName = "ambiguous"
setup := setupMultipleOrganizations(t, setupArgs{
firstTemplates: []string{templateName},
secondTemplates: []string{templateName},
})
member := setup.member
args := []string{
"create",
"my-workspace",
"-y",
"--template", templateName,
"--org", setup.second.Name,
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
_ = ptytest.New(t).Attach(inv)
err := inv.Run()
require.NoError(t, err)
ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{})
if assert.NoError(t, err, "expected workspace to be created") {
assert.Equal(t, ws.TemplateName, templateName)
assert.Equal(t, ws.OrganizationName, setup.second.Name, "workspace in second organization")
}
})
// If an organization is specified, but the template is not in that
// organization, an error is thrown.
t.Run("CreateIncorrectOrg", func(t *testing.T) {
t.Parallel()
const templateName = "secondtemplate"
setup := setupMultipleOrganizations(t, setupArgs{
firstTemplates: []string{templateName},
})
member := setup.member
args := []string{
"create",
"my-workspace",
"-y",
"--org", setup.second.Name,
"--template", templateName,
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
_ = ptytest.New(t).Attach(inv)
err := inv.Run()
require.Error(t, err)
// The error message should indicate the flag to fix the issue.
require.ErrorContains(t, err, fmt.Sprintf("--org=%q", "coder"))
})
}
func TestEnterpriseCreateWithPreset(t *testing.T) {
t.Parallel()
const (
firstParameterName = "first_parameter"
firstParameterDisplayName = "First Parameter"
firstParameterDescription = "This is the first parameter"
firstParameterValue = "1"
firstOptionalParameterName = "first_optional_parameter"
firstOptionParameterDescription = "This is the first optional parameter"
firstOptionalParameterValue = "1"
secondOptionalParameterName = "second_optional_parameter"
secondOptionalParameterDescription = "This is the second optional parameter"
secondOptionalParameterValue = "2"
thirdParameterName = "third_parameter"
thirdParameterDescription = "This is the third parameter"
thirdParameterValue = "3"
)
echoResponses := func(presets ...*proto.Preset) *echo.Responses {
return prepareEchoResponses([]*proto.RichParameter{
{
Name: firstParameterName,
DisplayName: firstParameterDisplayName,
Description: firstParameterDescription,
Mutable: true,
DefaultValue: firstParameterValue,
Options: []*proto.RichParameterOption{
{
Name: firstOptionalParameterName,
Description: firstOptionParameterDescription,
Value: firstOptionalParameterValue,
},
{
Name: secondOptionalParameterName,
Description: secondOptionalParameterDescription,
Value: secondOptionalParameterValue,
},
},
},
{
Name: thirdParameterName,
Description: thirdParameterDescription,
DefaultValue: thirdParameterValue,
Mutable: true,
},
}, presets...)
}
runReconciliationLoop := func(
t *testing.T,
ctx context.Context,
db database.Store,
reconciler *prebuilds.StoreReconciler,
presets []codersdk.Preset,
) {
t.Helper()
state, err := reconciler.SnapshotState(ctx, db)
require.NoError(t, err)
require.Len(t, presets, 1)
ps, err := state.FilterByPreset(presets[0].ID)
require.NoError(t, err)
require.NotNil(t, ps)
actions, err := reconciler.CalculateActions(ctx, *ps)
require.NoError(t, err)
require.NotNil(t, actions)
require.NoError(t, reconciler.ReconcilePreset(ctx, *ps))
}
getRunningPrebuilds := func(
t *testing.T,
ctx context.Context,
db database.Store,
prebuildInstances int,
) []database.GetRunningPrebuiltWorkspacesRow {
t.Helper()
var runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow
testutil.Eventually(ctx, t, func(context.Context) bool {
runningPrebuilds = nil
rows, err := db.GetRunningPrebuiltWorkspaces(ctx)
if err != nil {
return false
}
for _, row := range rows {
runningPrebuilds = append(runningPrebuilds, row)
agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, row.ID)
if err != nil || len(agents) == 0 {
return false
}
for _, agent := range agents {
err = db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{
ID: agent.ID,
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
StartedAt: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
ReadyAt: sql.NullTime{Time: time.Now().Add(-1 * time.Hour), Valid: true},
})
if err != nil {
return false
}
}
}
t.Logf("found %d running prebuilds so far, want %d", len(runningPrebuilds), prebuildInstances)
return len(runningPrebuilds) == prebuildInstances
}, testutil.IntervalSlow, "prebuilds not running")
return runningPrebuilds
}
// This test verifies that when the selected preset has running prebuilds,
// one of those prebuilds is claimed for the user upon workspace creation.
t.Run("PresetFlagClaimsPrebuiltWorkspace", func(t *testing.T) {
t.Parallel()
// Setup
ctx := testutil.Context(t, testutil.WaitSuperLong)
db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
client, _, api, owner := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
Options: &coderdtest.Options{
Database: db,
Pubsub: pb,
IncludeProvisionerDaemon: true,
},
})
// Setup Prebuild reconciler
cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{})
newNoopUsageCheckerPtr := func() *atomic.Pointer[wsbuilder.UsageChecker] {
var noopUsageChecker wsbuilder.UsageChecker = wsbuilder.NoopUsageChecker{}
buildUsageChecker := atomic.Pointer[wsbuilder.UsageChecker]{}
buildUsageChecker.Store(&noopUsageChecker)
return &buildUsageChecker
}
reconciler := prebuilds.NewStoreReconciler(
db, pb, cache,
codersdk.PrebuildsConfig{},
testutil.Logger(t),
quartz.NewMock(t),
prometheus.NewRegistry(),
notifications.NewNoopEnqueuer(),
newNoopUsageCheckerPtr(),
noop.NewTracerProvider(),
10,
nil,
)
var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer()
api.AGPL.PrebuildsClaimer.Store(&claimer)
// Given: a template and a template version where the preset defines values for all required parameters,
// and is configured to have 1 prebuild instance
prebuildInstances := int32(1)
preset := proto.Preset{
Name: "preset-test",
Parameters: []*proto.PresetParameter{
{Name: firstParameterName, Value: secondOptionalParameterValue},
{Name: thirdParameterName, Value: thirdParameterValue},
},
Prebuild: &proto.Prebuild{
Instances: prebuildInstances,
},
}
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&preset))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
presets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, presets, 1)
require.Equal(t, preset.Name, presets[0].Name)
// Given: Reconciliation loop runs and starts prebuilt workspaces
runReconciliationLoop(t, ctx, db, reconciler, presets)
runningPrebuilds := getRunningPrebuilds(t, ctx, db, int(prebuildInstances))
require.Len(t, runningPrebuilds, int(prebuildInstances))
require.Equal(t, presets[0].ID, runningPrebuilds[0].CurrentPresetID.UUID)
// Given: a running prebuilt workspace, ready to be claimed
prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID)
require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition)
require.Equal(t, template.ID, prebuild.TemplateID)
require.Equal(t, version.ID, prebuild.TemplateActiveVersionID)
require.Equal(t, presets[0].ID, *prebuild.LatestBuild.TemplateVersionPresetID)
// When: running the create command with the specified preset
workspaceName := "my-workspace"
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", preset.Name)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err = inv.Run()
require.NoError(t, err)
// Should: display the selected preset as well as its parameters
presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
pty.ExpectMatch(presetName)
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue))
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// Should: create the user's workspace by claiming the existing prebuilt workspace
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Name: workspaceName,
})
require.NoError(t, err)
require.Len(t, workspaces.Workspaces, 1)
require.Equal(t, prebuild.ID, workspaces.Workspaces[0].ID)
// Should: create a workspace using the expected template version and the preset-defined parameters
workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
require.Equal(t, presets[0].ID, *workspaceLatestBuild.TemplateVersionPresetID)
buildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceLatestBuild.ID)
require.NoError(t, err)
require.Len(t, buildParameters, 2)
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: secondOptionalParameterValue})
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: thirdParameterName, Value: thirdParameterValue})
})
// This test verifies that when the user provides `--preset None`,
// no preset is applied, no prebuilt workspace is claimed, and
// a new regular workspace is created instead.
t.Run("PresetNoneDoesNotClaimPrebuiltWorkspace", func(t *testing.T) {
t.Parallel()
// Setup
ctx := testutil.Context(t, testutil.WaitSuperLong)
db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
client, _, api, owner := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
Options: &coderdtest.Options{
Database: db,
Pubsub: pb,
IncludeProvisionerDaemon: true,
},
})
// Setup Prebuild reconciler
cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{})
newNoopUsageCheckerPtr := func() *atomic.Pointer[wsbuilder.UsageChecker] {
var noopUsageChecker wsbuilder.UsageChecker = wsbuilder.NoopUsageChecker{}
buildUsageChecker := atomic.Pointer[wsbuilder.UsageChecker]{}
buildUsageChecker.Store(&noopUsageChecker)
return &buildUsageChecker
}
reconciler := prebuilds.NewStoreReconciler(
db, pb, cache,
codersdk.PrebuildsConfig{},
testutil.Logger(t),
quartz.NewMock(t),
prometheus.NewRegistry(),
notifications.NewNoopEnqueuer(),
newNoopUsageCheckerPtr(),
noop.NewTracerProvider(),
10,
nil,
)
var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer()
api.AGPL.PrebuildsClaimer.Store(&claimer)
// Given: a template and a template version where the preset defines values for all required parameters,
// and is configured to have 1 prebuild instance
prebuildInstances := int32(1)
presetWithPrebuild := proto.Preset{
Name: "preset-test",
Parameters: []*proto.PresetParameter{
{Name: firstParameterName, Value: secondOptionalParameterValue},
{Name: thirdParameterName, Value: thirdParameterValue},
},
Prebuild: &proto.Prebuild{
Instances: prebuildInstances,
},
}
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&presetWithPrebuild))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
presets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, presets, 1)
// Given: Reconciliation loop runs and starts prebuilt workspaces
runReconciliationLoop(t, ctx, db, reconciler, presets)
runningPrebuilds := getRunningPrebuilds(t, ctx, db, int(prebuildInstances))
require.Len(t, runningPrebuilds, int(prebuildInstances))
require.Equal(t, presets[0].ID, runningPrebuilds[0].CurrentPresetID.UUID)
// Given: a running prebuilt workspace, ready to be claimed
prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID)
require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition)
require.Equal(t, template.ID, prebuild.TemplateID)
require.Equal(t, version.ID, prebuild.TemplateActiveVersionID)
require.Equal(t, presets[0].ID, *prebuild.LatestBuild.TemplateVersionPresetID)
// When: running the create command without a preset flag
workspaceName := "my-workspace"
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y",
"--preset", cli.PresetNone,
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstParameterValue),
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
inv.Stdout = pty.Output()
inv.Stderr = pty.Output()
err = inv.Run()
require.NoError(t, err)
pty.ExpectMatch("No preset applied.")
// Verify if the new workspace uses expected parameters.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// Should: create a new user's workspace without claiming the existing prebuilt workspace
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Name: workspaceName,
})
require.NoError(t, err)
require.Len(t, workspaces.Workspaces, 1)
require.NotEqual(t, prebuild.ID, workspaces.Workspaces[0].ID)
// Should: create a workspace using the expected template version and the specified parameters
workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
require.Nil(t, workspaceLatestBuild.TemplateVersionPresetID)
buildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceLatestBuild.ID)
require.NoError(t, err)
require.Len(t, buildParameters, 2)
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstParameterValue})
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: thirdParameterName, Value: thirdParameterValue})
})
}
func prepareEchoResponses(parameters []*proto.RichParameter, presets ...*proto.Preset) *echo.Responses {
return &echo.Responses{
Parse: echo.ParseComplete,
ProvisionGraph: []*proto.Response{
{
Type: &proto.Response_Graph{
Graph: &proto.GraphComplete{
Parameters: parameters,
Presets: presets,
Resources: []*proto.Resource{
{
Type: "compute",
Name: "main",
Agents: []*proto.Agent{
{
Name: "smith",
OperatingSystem: "linux",
Architecture: "i386",
},
},
},
},
},
},
},
},
}
}