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",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+}
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: