Content-Length: 58289 | pFad | http://github.com/coder/coder/pull/18912.diff
thub.com 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",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+}
Fetched URL: http://github.com/coder/coder/pull/18912.diff
Alternative Proxies: