diff --git a/cli/create.go b/cli/create.go
index fbf26349b3b95..4e0e47b43eaa4 100644
--- a/cli/create.go
+++ b/cli/create.go
@@ -2,6 +2,7 @@ package cli
import (
"context"
+ "errors"
"fmt"
"io"
"slices"
@@ -21,10 +22,18 @@ import (
"github.com/coder/serpent"
)
+// PresetNone represents the special preset value "none".
+// It is used when a user runs `create --preset none`,
+// indicating that the CLI should not apply any preset.
+const PresetNone = "none"
+
+var ErrNoPresetFound = xerrors.New("no preset found")
+
func (r *RootCmd) create() *serpent.Command {
var (
templateName string
templateVersion string
+ presetName string
startAt string
stopAfter time.Duration
workspaceName string
@@ -263,11 +272,45 @@ func (r *RootCmd) create() *serpent.Command {
}
}
+ // Get presets for the template version
+ tvPresets, err := client.TemplateVersionPresets(inv.Context(), templateVersionID)
+ if err != nil {
+ return xerrors.Errorf("failed to get presets: %w", err)
+ }
+
+ var preset *codersdk.Preset
+ var presetParameters []codersdk.WorkspaceBuildParameter
+
+ // If the template has no presets, or the user explicitly used --preset none,
+ // skip applying a preset
+ if len(tvPresets) > 0 && strings.ToLower(presetName) != PresetNone {
+ // Attempt to resolve which preset to use
+ preset, err = resolvePreset(tvPresets, presetName)
+ if err != nil {
+ if !errors.Is(err, ErrNoPresetFound) {
+ return xerrors.Errorf("unable to resolve preset: %w", err)
+ }
+ // If no preset found, prompt the user to choose a preset
+ if preset, err = promptPresetSelection(inv, tvPresets); err != nil {
+ return xerrors.Errorf("unable to prompt user for preset: %w", err)
+ }
+ }
+
+ // Convert preset parameters into workspace build parameters
+ presetParameters = presetParameterAsWorkspaceBuildParameters(preset.Parameters)
+ // Inform the user which preset was applied and its parameters
+ displayAppliedPreset(inv, preset, presetParameters)
+ } else {
+ // Inform the user that no preset was applied
+ _, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied."))
+ }
+
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
Action: WorkspaceCreate,
TemplateVersionID: templateVersionID,
NewWorkspaceName: workspaceName,
+ PresetParameters: presetParameters,
RichParameterFile: parameterFlags.richParameterFile,
RichParameters: cliBuildParameters,
RichParameterDefaults: cliBuildParameterDefaults,
@@ -291,14 +334,21 @@ func (r *RootCmd) create() *serpent.Command {
ttlMillis = ptr.Ref(stopAfter.Milliseconds())
}
- workspace, err := client.CreateUserWorkspace(inv.Context(), workspaceOwner, codersdk.CreateWorkspaceRequest{
+ req := codersdk.CreateWorkspaceRequest{
TemplateVersionID: templateVersionID,
Name: workspaceName,
AutostartSchedule: schedSpec,
TTLMillis: ttlMillis,
RichParameterValues: richParameters,
AutomaticUpdates: codersdk.AutomaticUpdates(autoUpdates),
- })
+ }
+
+ // If a preset exists, update the create workspace request's preset ID
+ if preset != nil {
+ req.TemplateVersionPresetID = preset.ID
+ }
+
+ workspace, err := client.CreateUserWorkspace(inv.Context(), workspaceOwner, req)
if err != nil {
return xerrors.Errorf("create workspace: %w", err)
}
@@ -333,6 +383,12 @@ func (r *RootCmd) create() *serpent.Command {
Description: "Specify a template version name.",
Value: serpent.StringOf(&templateVersion),
},
+ serpent.Option{
+ Flag: "preset",
+ Env: "CODER_PRESET_NAME",
+ Description: "Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used.",
+ Value: serpent.StringOf(&presetName),
+ },
serpent.Option{
Flag: "start-at",
Env: "CODER_WORKSPACE_START_AT",
@@ -377,12 +433,76 @@ type prepWorkspaceBuildArgs struct {
PromptEphemeralParameters bool
EphemeralParameters []codersdk.WorkspaceBuildParameter
+ PresetParameters []codersdk.WorkspaceBuildParameter
PromptRichParameters bool
RichParameters []codersdk.WorkspaceBuildParameter
RichParameterFile string
RichParameterDefaults []codersdk.WorkspaceBuildParameter
}
+// resolvePreset returns the preset matching the given presetName (if specified),
+// or the default preset (if any).
+// Returns ErrNoPresetFound if no matching or default preset is found.
+func resolvePreset(presets []codersdk.Preset, presetName string) (*codersdk.Preset, error) {
+ // If preset name is specified, find it
+ if presetName != "" {
+ for _, p := range presets {
+ if p.Name == presetName {
+ return &p, nil
+ }
+ }
+ return nil, xerrors.Errorf("preset %q not found", presetName)
+ }
+
+ // No preset name specified, search for the default preset
+ for _, p := range presets {
+ if p.Default {
+ return &p, nil
+ }
+ }
+
+ // No preset found
+ return nil, ErrNoPresetFound
+}
+
+// promptPresetSelection shows a CLI selection menu of the presets defined in the template version.
+// Returns the selected preset
+func promptPresetSelection(inv *serpent.Invocation, presets []codersdk.Preset) (*codersdk.Preset, error) {
+ presetMap := make(map[string]*codersdk.Preset)
+ var presetOptions []string
+
+ for _, preset := range presets {
+ option := preset.Name
+ presetOptions = append(presetOptions, option)
+ presetMap[option] = &preset
+ }
+
+ // Show selection UI
+ _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a preset below:"))
+ selected, err := cliui.Select(inv, cliui.SelectOptions{
+ Options: presetOptions,
+ HideSearch: true,
+ })
+ if err != nil {
+ return nil, xerrors.Errorf("failed to select preset: %w", err)
+ }
+
+ return presetMap[selected], nil
+}
+
+// displayAppliedPreset shows the user which preset was applied and its parameters
+func displayAppliedPreset(inv *serpent.Invocation, preset *codersdk.Preset, parameters []codersdk.WorkspaceBuildParameter) {
+ label := fmt.Sprintf("Preset '%s'", preset.Name)
+ if preset.Default {
+ label += " (default)"
+ }
+
+ _, _ = fmt.Fprintf(inv.Stdout, "%s applied:\n", cliui.Bold(label))
+ for _, param := range parameters {
+ _, _ = fmt.Fprintf(inv.Stdout, " %s: '%s'\n", cliui.Bold(param.Name), param.Value)
+ }
+}
+
// prepWorkspaceBuild will ensure a workspace build will succeed on the latest template version.
// Any missing params will be prompted to the user. It supports rich parameters.
func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.WorkspaceBuildParameter, error) {
@@ -411,6 +531,7 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
WithSourceWorkspaceParameters(args.SourceWorkspaceParameters).
WithPromptEphemeralParameters(args.PromptEphemeralParameters).
WithEphemeralParameters(args.EphemeralParameters).
+ WithPresetParameters(args.PresetParameters).
WithPromptRichParameters(args.PromptRichParameters).
WithRichParameters(args.RichParameters).
WithRichParametersFile(parameterFile).
diff --git a/cli/create_test.go b/cli/create_test.go
index 668fd466d605c..9db2e328c6ce9 100644
--- a/cli/create_test.go
+++ b/cli/create_test.go
@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "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/externalauth"
@@ -298,7 +299,7 @@ func TestCreate(t *testing.T) {
})
}
-func prepareEchoResponses(parameters []*proto.RichParameter) *echo.Responses {
+func prepareEchoResponses(parameters []*proto.RichParameter, presets ...*proto.Preset) *echo.Responses {
return &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{
@@ -306,6 +307,7 @@ func prepareEchoResponses(parameters []*proto.RichParameter) *echo.Responses {
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Parameters: parameters,
+ Presets: presets,
},
},
},
@@ -663,6 +665,641 @@ func TestCreateWithRichParameters(t *testing.T) {
})
}
+func TestCreateWithPreset(t *testing.T) {
+ t.Parallel()
+
+ const (
+ firstParameterName = "first_parameter"
+ firstParameterDisplayName = "First Parameter"
+ firstParameterDescription = "This is the first parameter"
+ firstParameterValue = "1"
+
+ firstOptionalParameterName = "first_optional_parameter"
+ firstOptionalParameterDescription = "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: firstOptionalParameterDescription,
+ Value: firstOptionalParameterValue,
+ },
+ {
+ Name: secondOptionalParameterName,
+ Description: secondOptionalParameterDescription,
+ Value: secondOptionalParameterValue,
+ },
+ },
+ },
+ {
+ Name: thirdParameterName,
+ Description: thirdParameterDescription,
+ DefaultValue: thirdParameterValue,
+ Mutable: true,
+ },
+ }, presets...)
+ }
+
+ // This test verifies that when a template has presets,
+ // including a default preset, and the user specifies a `--preset` flag,
+ // the CLI uses the specified preset instead of the default
+ t.Run("PresetFlag", func(t *testing.T) {
+ t.Parallel()
+
+ client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
+ owner := coderdtest.CreateFirstUser(t, client)
+ member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+
+ // Given: a template and a template version with two presets, including a default
+ defaultPreset := proto.Preset{
+ Name: "preset-default",
+ Default: true,
+ Parameters: []*proto.PresetParameter{
+ {Name: thirdParameterName, Value: thirdParameterValue},
+ },
+ }
+ preset := proto.Preset{
+ Name: "preset-test",
+ Parameters: []*proto.PresetParameter{
+ {Name: firstParameterName, Value: secondOptionalParameterValue},
+ {Name: thirdParameterName, Value: thirdParameterValue},
+ },
+ }
+ version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&defaultPreset, &preset))
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
+ template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
+
+ // 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()
+
+ tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
+ require.NoError(t, err)
+ require.Len(t, tvPresets, 2)
+ var selectedPreset *codersdk.Preset
+ for _, tvPreset := range tvPresets {
+ if tvPreset.Name == preset.Name {
+ selectedPreset = &tvPreset
+ }
+ }
+ require.NotNil(t, selectedPreset)
+
+ workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
+ Name: workspaceName,
+ })
+ require.NoError(t, err)
+ require.Len(t, workspaces.Workspaces, 1)
+
+ // 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, selectedPreset.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 a template has presets,
+ // including a default preset, and the user does not specify the `--preset` flag,
+ // the CLI automatically uses the default preset to create the workspace
+ t.Run("DefaultPreset", func(t *testing.T) {
+ t.Parallel()
+
+ client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
+ owner := coderdtest.CreateFirstUser(t, client)
+ member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+
+ // Given: a template and a template version with two presets, including a default
+ defaultPreset := proto.Preset{
+ Name: "preset-default",
+ Default: true,
+ Parameters: []*proto.PresetParameter{
+ {Name: firstParameterName, Value: secondOptionalParameterValue},
+ {Name: thirdParameterName, Value: thirdParameterValue},
+ },
+ }
+ preset := proto.Preset{
+ Name: "preset-test",
+ Parameters: []*proto.PresetParameter{
+ {Name: thirdParameterName, Value: thirdParameterValue},
+ },
+ }
+ version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&defaultPreset, &preset))
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
+ template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
+
+ // When: running the create command without a preset
+ workspaceName := "my-workspace"
+ inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y")
+ 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 default preset as well as its parameters
+ presetName := fmt.Sprintf("Preset '%s' (default) applied:", defaultPreset.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()
+
+ tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
+ require.NoError(t, err)
+ require.Len(t, tvPresets, 2)
+ var selectedPreset *codersdk.Preset
+ for _, tvPreset := range tvPresets {
+ if tvPreset.Default {
+ selectedPreset = &tvPreset
+ }
+ }
+ require.NotNil(t, selectedPreset)
+
+ workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
+ Name: workspaceName,
+ })
+ require.NoError(t, err)
+ require.Len(t, workspaces.Workspaces, 1)
+
+ // Should: create a workspace using the expected template version and the default preset parameters
+ workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
+ require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
+ require.Equal(t, selectedPreset.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 a template has presets but no default preset,
+ // and the user does not provide the `--preset` flag,
+ // the CLI prompts the user to select a preset.
+ t.Run("NoDefaultPresetPromptUser", func(t *testing.T) {
+ t.Parallel()
+
+ client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
+ owner := coderdtest.CreateFirstUser(t, client)
+ member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+
+ // Given: a template and a template version with two presets
+ preset := proto.Preset{
+ Name: "preset-test",
+ Parameters: []*proto.PresetParameter{
+ {Name: firstParameterName, Value: secondOptionalParameterValue},
+ {Name: thirdParameterName, Value: thirdParameterValue},
+ },
+ }
+ version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&preset))
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
+ template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
+
+ // When: running the create command without specifying a preset
+ workspaceName := "my-workspace"
+ inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name,
+ "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
+ "--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue))
+ clitest.SetupConfig(t, member, root)
+ doneChan := make(chan struct{})
+ pty := ptytest.New(t).Attach(inv)
+ go func() {
+ defer close(doneChan)
+ err := inv.Run()
+ assert.NoError(t, err)
+ }()
+
+ // Should: prompt the user for the preset
+ pty.ExpectMatch("Select a preset below:")
+ pty.WriteLine("\n")
+ pty.ExpectMatch("Preset 'preset-test' applied")
+ pty.ExpectMatch("Confirm create?")
+ pty.WriteLine("yes")
+
+ <-doneChan
+
+ // Verify if the new workspace uses expected parameters.
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
+ defer cancel()
+
+ tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
+ require.NoError(t, err)
+ require.Len(t, tvPresets, 1)
+
+ workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
+ Name: workspaceName,
+ })
+ require.NoError(t, err)
+ require.Len(t, workspaces.Workspaces, 1)
+
+ // 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, tvPresets[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 a template version has no presets,
+ // the CLI does not prompt the user to select a preset and proceeds
+ // with workspace creation without applying any preset.
+ t.Run("TemplateVersionWithoutPresets", func(t *testing.T) {
+ t.Parallel()
+
+ client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
+ owner := coderdtest.CreateFirstUser(t, client)
+ member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+
+ // Given: a template and a template version without presets
+ version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses())
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
+ template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
+
+ // When: running the create command without a preset
+ workspaceName := "my-workspace"
+ inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y",
+ "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
+ "--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()
+
+ workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
+ Name: workspaceName,
+ })
+ require.NoError(t, err)
+ require.Len(t, workspaces.Workspaces, 1)
+
+ // Should: create a workspace using the expected template version and no preset
+ 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: firstOptionalParameterValue})
+ require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: thirdParameterName, Value: thirdParameterValue})
+ })
+
+ // This test verifies that when the user provides `--preset none`,
+ // the CLI skips applying any preset, even if the template version has a default preset.
+ // The workspace should be created without using any preset-defined parameters.
+ t.Run("PresetFlagNone", func(t *testing.T) {
+ t.Parallel()
+
+ client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
+ owner := coderdtest.CreateFirstUser(t, client)
+ member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+
+ // Given: a template and a template version with a default preset
+ preset := proto.Preset{
+ Name: "preset-test",
+ Default: true,
+ Parameters: []*proto.PresetParameter{
+ {Name: firstParameterName, Value: secondOptionalParameterValue},
+ {Name: thirdParameterName, Value: thirdParameterValue},
+ },
+ }
+ version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&preset))
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
+ template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
+
+ // When: running the create command with flag '--preset none'
+ workspaceName := "my-workspace"
+ inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", cli.PresetNone,
+ "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
+ "--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 that the new workspace doesn't use the preset parameters.
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
+ defer cancel()
+
+ tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
+ require.NoError(t, err)
+ require.Len(t, tvPresets, 1)
+
+ workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
+ Name: workspaceName,
+ })
+ require.NoError(t, err)
+ require.Len(t, workspaces.Workspaces, 1)
+
+ // Should: create a workspace using the expected template version and no preset
+ 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: firstOptionalParameterValue})
+ require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: thirdParameterName, Value: thirdParameterValue})
+ })
+
+ // This test verifies that the CLI returns an appropriate error
+ // when a user provides a `--preset` value that does not correspond
+ // to any existing preset in the template version.
+ t.Run("FailsWhenPresetDoesNotExist", func(t *testing.T) {
+ t.Parallel()
+
+ client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
+ owner := coderdtest.CreateFirstUser(t, client)
+ member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+
+ // Given: a template and a template version where the preset defines values for all required parameters
+ preset := proto.Preset{
+ Name: "preset-test",
+ Parameters: []*proto.PresetParameter{
+ {Name: firstParameterName, Value: secondOptionalParameterValue},
+ {Name: thirdParameterName, Value: thirdParameterValue},
+ },
+ }
+ version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&preset))
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
+ template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
+
+ // When: running the create command with a non-existent preset
+ workspaceName := "my-workspace"
+ inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", "invalid-preset")
+ clitest.SetupConfig(t, member, root)
+ pty := ptytest.New(t).Attach(inv)
+ inv.Stdout = pty.Output()
+ inv.Stderr = pty.Output()
+ err := inv.Run()
+
+ // Should: fail with an error indicating the preset was not found
+ require.Contains(t, err.Error(), "preset \"invalid-preset\" not found")
+ })
+
+ // This test verifies that when both a preset and a user-provided
+ // `--parameter` flag define a value for the same parameter,
+ // the preset's value takes precedence over the user's.
+ //
+ // The preset defines one parameter (A), and two `--parameter` flags provide A and B.
+ // The workspace should be created using:
+ // - the value of parameter A from the preset (overriding the parameter flag's value),
+ // - and the value of parameter B from the parameter flag.
+ t.Run("PresetOverridesParameterFlagValues", func(t *testing.T) {
+ t.Parallel()
+
+ client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
+ owner := coderdtest.CreateFirstUser(t, client)
+ member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+
+ // Given: a template version with a preset that defines one parameter
+ preset := proto.Preset{
+ Name: "preset-test",
+ Parameters: []*proto.PresetParameter{
+ {Name: firstParameterName, Value: secondOptionalParameterValue},
+ },
+ }
+ version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&preset))
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
+ template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
+
+ // When: creating a workspace with a preset and passing overlapping and additional parameters via `--parameter`
+ workspaceName := "my-workspace"
+ inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y",
+ "--preset", preset.Name,
+ "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
+ "--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)
+
+ // Should: display the selected preset as well as its parameter
+ presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
+ pty.ExpectMatch(presetName)
+ pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
+
+ // Verify if the new workspace uses expected parameters.
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
+ defer cancel()
+
+ tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
+ require.NoError(t, err)
+ require.Len(t, tvPresets, 1)
+
+ workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
+ Name: workspaceName,
+ })
+ require.NoError(t, err)
+ require.Len(t, workspaces.Workspaces, 1)
+
+ // Should: include both parameters, one from the preset and one from the `--parameter` flag
+ workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
+ require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
+ require.Equal(t, tvPresets[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 both a preset and a user-provided
+ // `--rich-parameter-file` define a value for the same parameter,
+ // the preset's value takes precedence over the one in the file.
+ //
+ // The preset defines one parameter (A), and the parameter file provides two parameters (A and B).
+ // The workspace should be created using:
+ // - the value of parameter A from the preset (overriding the file's value),
+ // - and the value of parameter B from the file.
+ t.Run("PresetOverridesParameterFileValues", func(t *testing.T) {
+ t.Parallel()
+
+ client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
+ owner := coderdtest.CreateFirstUser(t, client)
+ member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+
+ // Given: a template version with a preset that defines one parameter
+ preset := proto.Preset{
+ Name: "preset-test",
+ Parameters: []*proto.PresetParameter{
+ {Name: firstParameterName, Value: secondOptionalParameterValue},
+ },
+ }
+ version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&preset))
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
+ template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
+
+ // When: creating a workspace with the preset and passing the second required parameter via `--rich-parameter-file`
+ workspaceName := "my-workspace"
+ tempDir := t.TempDir()
+ removeTmpDirUntilSuccessAfterTest(t, tempDir)
+ parameterFile, _ := os.CreateTemp(tempDir, "testParameterFile*.yaml")
+ _, _ = parameterFile.WriteString(
+ firstParameterName + ": " + firstOptionalParameterValue + "\n" +
+ thirdParameterName + ": " + thirdParameterValue)
+ inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y",
+ "--preset", preset.Name,
+ "--rich-parameter-file", parameterFile.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 parameter
+ presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name)
+ pty.ExpectMatch(presetName)
+ pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue))
+
+ // Verify if the new workspace uses expected parameters.
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
+ defer cancel()
+
+ tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
+ require.NoError(t, err)
+ require.Len(t, tvPresets, 1)
+
+ workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
+ Name: workspaceName,
+ })
+ require.NoError(t, err)
+ require.Len(t, workspaces.Workspaces, 1)
+
+ // Should: include both parameters, one from the preset and one from the `--rich-parameter-file` flag
+ workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
+ require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
+ require.Equal(t, tvPresets[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 a preset provides only some parameters,
+ // and the remaining ones are not provided via flags,
+ // the CLI prompts the user for input to fill in the missing parameters.
+ t.Run("PromptsForMissingParametersWhenPresetIsIncomplete", func(t *testing.T) {
+ t.Parallel()
+
+ client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
+ owner := coderdtest.CreateFirstUser(t, client)
+ member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
+
+ // Given: a template version with a preset that defines one parameter
+ preset := proto.Preset{
+ Name: "preset-test",
+ Parameters: []*proto.PresetParameter{
+ {Name: firstParameterName, Value: secondOptionalParameterValue},
+ },
+ }
+ version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&preset))
+ coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
+ template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
+
+ // When: running the create command with the specified preset
+ workspaceName := "my-workspace"
+ inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "--preset", preset.Name)
+ clitest.SetupConfig(t, member, root)
+ doneChan := make(chan struct{})
+ pty := ptytest.New(t).Attach(inv)
+ go func() {
+ defer close(doneChan)
+ err := inv.Run()
+ assert.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))
+
+ // Should: prompt for the missing parameter
+ pty.ExpectMatch(thirdParameterDescription)
+ pty.WriteLine(thirdParameterValue)
+ pty.ExpectMatch("Confirm create?")
+ pty.WriteLine("yes")
+
+ <-doneChan
+
+ // Verify if the new workspace uses expected parameters.
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
+ require.NoError(t, err)
+ require.Len(t, tvPresets, 1)
+
+ workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
+ Name: workspaceName,
+ })
+ require.NoError(t, err)
+ require.Len(t, workspaces.Workspaces, 1)
+
+ // 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, tvPresets[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})
+ })
+}
+
func TestCreateValidateRichParameters(t *testing.T) {
t.Parallel()
diff --git a/cli/parameter.go b/cli/parameter.go
index 97c551ffa5a7f..2b56c364faf23 100644
--- a/cli/parameter.go
+++ b/cli/parameter.go
@@ -100,6 +100,14 @@ func (wpf *workspaceParameterFlags) alwaysPrompt() serpent.Option {
}
}
+func presetParameterAsWorkspaceBuildParameters(presetParameters []codersdk.PresetParameter) []codersdk.WorkspaceBuildParameter {
+ var params []codersdk.WorkspaceBuildParameter
+ for _, parameter := range presetParameters {
+ params = append(params, codersdk.WorkspaceBuildParameter(parameter))
+ }
+ return params
+}
+
func asWorkspaceBuildParameters(nameValuePairs []string) ([]codersdk.WorkspaceBuildParameter, error) {
var params []codersdk.WorkspaceBuildParameter
for _, nameValue := range nameValuePairs {
diff --git a/cli/parameterresolver.go b/cli/parameterresolver.go
index 40625331fa6aa..cbd00fb59623e 100644
--- a/cli/parameterresolver.go
+++ b/cli/parameterresolver.go
@@ -26,6 +26,7 @@ type ParameterResolver struct {
lastBuildParameters []codersdk.WorkspaceBuildParameter
sourceWorkspaceParameters []codersdk.WorkspaceBuildParameter
+ presetParameters []codersdk.WorkspaceBuildParameter
richParameters []codersdk.WorkspaceBuildParameter
richParametersDefaults map[string]string
richParametersFile map[string]string
@@ -45,6 +46,11 @@ func (pr *ParameterResolver) WithSourceWorkspaceParameters(params []codersdk.Wor
return pr
}
+func (pr *ParameterResolver) WithPresetParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
+ pr.presetParameters = params
+ return pr
+}
+
func (pr *ParameterResolver) WithRichParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
pr.richParameters = params
return pr
@@ -80,6 +86,8 @@ func (pr *ParameterResolver) WithPromptEphemeralParameters(promptEphemeralParame
return pr
}
+// Resolve gathers workspace build parameters in a layered fashion, applying values from various sources
+// in order of precedence: parameter file < CLI/ENV < source build < last build < preset < user input.
func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) {
var staged []codersdk.WorkspaceBuildParameter
var err error
@@ -88,6 +96,7 @@ func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCL
staged = pr.resolveWithCommandLineOrEnv(staged)
staged = pr.resolveWithSourceBuildParameters(staged, templateVersionParameters)
staged = pr.resolveWithLastBuildParameters(staged, templateVersionParameters)
+ staged = pr.resolveWithPreset(staged) // Preset parameters take precedence from all other parameters
if err = pr.verifyConstraints(staged, action, templateVersionParameters); err != nil {
return nil, err
}
@@ -97,6 +106,21 @@ func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCL
return staged, nil
}
+func (pr *ParameterResolver) resolveWithPreset(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
+next:
+ for _, presetParameter := range pr.presetParameters {
+ for i, r := range resolved {
+ if r.Name == presetParameter.Name {
+ resolved[i].Value = presetParameter.Value
+ continue next
+ }
+ }
+ resolved = append(resolved, presetParameter)
+ }
+
+ return resolved
+}
+
func (pr *ParameterResolver) resolveWithParametersMapFile(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
next:
for name, value := range pr.richParametersFile {
diff --git a/cli/testdata/coder_create_--help.golden b/cli/testdata/coder_create_--help.golden
index 8e8ea4a1701eb..47e809e8f5af6 100644
--- a/cli/testdata/coder_create_--help.golden
+++ b/cli/testdata/coder_create_--help.golden
@@ -26,6 +26,10 @@ OPTIONS:
--parameter-default string-array, $CODER_RICH_PARAMETER_DEFAULT
Rich parameter default values in the format "name=value".
+ --preset string, $CODER_PRESET_NAME
+ Specify the name of a template version preset. Use 'none' to
+ explicitly indicate that no preset should be used.
+
--rich-parameter-file string, $CODER_RICH_PARAMETER_FILE
Specify a file path with values for rich parameters defined in the
template. The file should be in YAML format, containing key-value
diff --git a/docs/reference/cli/create.md b/docs/reference/cli/create.md
index 58c0fad4a14e8..d18b4ea5c8e05 100644
--- a/docs/reference/cli/create.md
+++ b/docs/reference/cli/create.md
@@ -37,6 +37,15 @@ Specify a template name.
Specify a template version name.
+### --preset
+
+| | |
+|-------------|---------------------------------|
+| Type | string
|
+| Environment | $CODER_PRESET_NAME
|
+
+Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used.
+
### --start-at
| | |
diff --git a/enterprise/cli/create_test.go b/enterprise/cli/create_test.go
index 040768473c55d..44218abb5a58d 100644
--- a/enterprise/cli/create_test.go
+++ b/enterprise/cli/create_test.go
@@ -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",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+}