mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(cli): add CLI support for creating a workspace with preset (#18912)
## Description
This PR introduces a `--preset` flag for the `create` command to allow
users to apply a predefined preset to their workspace build.
## Changes
- The `--preset` flag on the `create` command integrates with the
parameter resolution logic and takes precedence over other sources
(e.g., CLI/env vars, last build, etc.).
- Added internal logic to ensure that preset parameters override
parameters values during resolution.
- Updated tests and added new ones to cover these flows.
## Implementation logic
* If a template has presets and includes a default, the CLI will
automatically use the default when `--preset` is not specified.
* If a template has presets but no default, the CLI will prompt the user
to select one when `--preset` is not specified.
* If a template does not have presets, the CLI will not prompt the user
for a preset.
* If the user specifies a preset using the `--preset` flag, that preset
will be used.
* If the user passes `--preset None`, no preset will be applied.
This logic aligns with the behavior in the UI for consistency.
```
> coder create --help
USAGE:
coder create [flags] [workspace]
Create a workspace
- Create a workspace for another user (if you have permission):
$ coder create <username>/<workspace_name>
OPTIONS:
(...)
--preset string, $CODER_PRESET_NAME
Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used.
(...)
-y, --yes bool
Bypass prompts.
```
## Breaking change
**Note:** This is a breaking change to the create CLI command. If a
template includes presets and the user does not provide a `--preset`
flag, the CLI will now prompt the user to select one. This behavior may
break non-interactive scripts or automated workflows.
Relates to PR: https://github.com/coder/coder/pull/18910 - please
consider both PRs together as they’re part of the same workflow
Relates to issue: https://github.com/coder/coder/issues/16594
This commit is contained in:
@@ -2,14 +2,33 @@ package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/v2/cli"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/wsbuilder"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"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/enterprise/coderd/prebuilds"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
@@ -202,3 +221,375 @@ func TestEnterpriseCreate(t *testing.T) {
|
||||
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(),
|
||||
)
|
||||
var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db)
|
||||
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(),
|
||||
)
|
||||
var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db)
|
||||
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,
|
||||
ProvisionPlan: []*proto.Response{
|
||||
{
|
||||
Type: &proto.Response_Plan{
|
||||
Plan: &proto.PlanComplete{
|
||||
Parameters: parameters,
|
||||
Presets: presets,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ProvisionApply: []*proto.Response{
|
||||
{
|
||||
Type: &proto.Response_Apply{
|
||||
Apply: &proto.ApplyComplete{
|
||||
Resources: []*proto.Resource{
|
||||
{
|
||||
Type: "compute",
|
||||
Name: "main",
|
||||
Agents: []*proto.Agent{
|
||||
{
|
||||
Name: "smith",
|
||||
OperatingSystem: "linux",
|
||||
Architecture: "i386",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user