From 982031ac34fcfcca67f1fd28bf7016c73c203453 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Thu, 17 Jul 2025 08:59:28 +0000 Subject: [PATCH 01/10] feat(cli): add CLI support for creating a workspace with preset --- cli/create.go | 71 ++- cli/create_test.go | 580 +++++++++++++++++++++++- cli/parameter.go | 8 + cli/parameterresolver.go | 24 + cli/testdata/coder_create_--help.golden | 4 + docs/reference/cli/create.md | 9 + enterprise/cli/create_test.go | 367 +++++++++++++++ 7 files changed, 1060 insertions(+), 3 deletions(-) diff --git a/cli/create.go b/cli/create.go index fbf26349b3b95..489d7563bec73 100644 --- a/cli/create.go +++ b/cli/create.go @@ -21,10 +21,15 @@ import ( "github.com/coder/serpent" ) +// DefaultPresetName is used when a user runs `create --preset default`. +// It instructs the CLI to use the default preset defined for the template version, if one exists. +const DefaultPresetName = "default" + func (r *RootCmd) create() *serpent.Command { var ( templateName string templateVersion string + presetName string startAt string stopAfter time.Duration workspaceName string @@ -263,11 +268,58 @@ func (r *RootCmd) create() *serpent.Command { } } + // If a preset name is provided, resolve the preset to use. + var preset *codersdk.Preset + var presetParameters []codersdk.WorkspaceBuildParameter + isDefaultPreset := false + if len(presetName) > 0 { + tvPresets, err := client.TemplateVersionPresets(inv.Context(), templateVersionID) + if err != nil { + return xerrors.Errorf("failed to get presets: %w", err) + } + + for _, tvPreset := range tvPresets { + // If the preset name is the special "default" keyword, + // fetch the template version's default preset (if any). + if presetName == DefaultPresetName && tvPreset.Default { + preset = &tvPreset + isDefaultPreset = true + break + } + if tvPreset.Name == presetName { + preset = &tvPreset + break + } + } + + if preset == nil { + return xerrors.Errorf("preset %q not found", presetName) + } + + // Convert preset parameters into workspace build parameters. + presetBuildParameters, err := presetParameterAsWorkspaceBuildParameters(preset.Parameters) + if err != nil { + return xerrors.Errorf("failed to parse preset parameters: %w", err) + } + presetParameters = append(presetParameters, presetBuildParameters...) + + // Inform the user which preset was applied and its parameters. + presetLabel := fmt.Sprintf("Preset '%s'", preset.Name) + if isDefaultPreset { + presetLabel += " (default)" + } + _, _ = fmt.Fprintf(inv.Stdout, "%s applied:", cliui.Bold(presetLabel)) + for _, p := range presetParameters { + _, _ = fmt.Fprintf(inv.Stdout, " %s: '%s'\n", cliui.Bold(p.Name), p.Value) + } + } + richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ Action: WorkspaceCreate, TemplateVersionID: templateVersionID, NewWorkspaceName: workspaceName, + PresetParameters: presetParameters, RichParameterFile: parameterFlags.richParameterFile, RichParameters: cliBuildParameters, RichParameterDefaults: cliBuildParameterDefaults, @@ -291,14 +343,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 +392,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 a template version preset name. Use 'default' to apply the default preset defined in the template version, if available.", + Value: serpent.StringOf(&presetName), + }, serpent.Option{ Flag: "start-at", Env: "CODER_WORKSPACE_START_AT", @@ -377,6 +442,7 @@ type prepWorkspaceBuildArgs struct { PromptEphemeralParameters bool EphemeralParameters []codersdk.WorkspaceBuildParameter + PresetParameters []codersdk.WorkspaceBuildParameter PromptRichParameters bool RichParameters []codersdk.WorkspaceBuildParameter RichParameterFile string @@ -411,6 +477,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..69031e400f505 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/coder/coder/v2/cli" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -298,7 +300,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 +308,7 @@ func prepareEchoResponses(parameters []*proto.RichParameter) *echo.Responses { Type: &proto.Response_Plan{ Plan: &proto.PlanComplete{ Parameters: parameters, + Presets: presets, }, }, }, @@ -663,6 +666,581 @@ 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" + 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...) + } + + // This test verifies that when a user provides the `--preset` flag, + // the CLI correctly uses the parameters defined in the preset. + // The workspace is created using those preset parameters. + 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 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 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, 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 the user does not provide the `--preset` flag, + // the workspace is created without using any preset. + t.Run("NoPresetFlag", 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 + 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) + + // 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 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 the user provides `--preset default`, + // the CLI selects the preset marked as the default in the template version. + // The workspace should be created using that default preset's parameters. + t.Run("PresetFlagDefault", 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 the default preset + workspaceName := "my-workspace" + inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", cli.DefaultPresetName) + 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:", 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, 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 the user provides `--preset default`, + // but there is no preset marked as default in the template version, + // and a preset literally named "default" exists, + // the CLI selects the preset named "default" instead. + // The workspace should be created using that preset's parameters. + t.Run("PresetFlagDefaultName", 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 preset named "default" + preset := proto.Preset{ + Name: cli.DefaultPresetName, + 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 the 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 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, 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 the user provides `--preset default` + // but the template version does not define any default preset, + // the CLI returns an appropriate error. + t.Run("FailsWhenDefaultPresetIsRequestedButNotDefined", 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 default preset + workspaceName := "my-workspace" + inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", cli.DefaultPresetName) + 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 default preset was not found + require.Contains(t, err.Error(), "preset \"default\" 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 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}, + }, + } + 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.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}) + }) +} + func TestCreateValidateRichParameters(t *testing.T) { t.Parallel() diff --git a/cli/parameter.go b/cli/parameter.go index 02ff4e11f63e4..8b3f91e041068 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, error) { + var params []codersdk.WorkspaceBuildParameter + for _, parameter := range presetParameters { + params = append(params, codersdk.WorkspaceBuildParameter(parameter)) + } + return params, nil +} + 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..d557ebd97fd25 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 a template version preset name. Use 'default' to apply the + default preset defined in the template version, if available. + --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..0075b2b0e7152 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 a template version preset name. Use 'default' to apply the default preset defined in the template version, if available. + ### --start-at | | | diff --git a/enterprise/cli/create_test.go b/enterprise/cli/create_test.go index 040768473c55d..66260eddc7a48 100644 --- a/enterprise/cli/create_test.go +++ b/enterprise/cli/create_test.go @@ -2,14 +2,28 @@ package cli_test import ( "context" + "database/sql" "fmt" "sync" "testing" + "time" "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 +216,356 @@ 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) + 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 { + 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{}) + reconciler := prebuilds.NewStoreReconciler( + db, pb, cache, + codersdk.PrebuildsConfig{}, + testutil.Logger(t), + quartz.NewMock(t), + prometheus.NewRegistry(), + notifications.NewNoopEnqueuer(), + ) + 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 no preset is selected, no prebuilt workspace + // is claimed, and instead a new regular workspace is created. + t.Run("NoPresetFlagDoesNotClaimPrebuiltWorkspace", 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{}) + reconciler := prebuilds.NewStoreReconciler( + db, pb, cache, + codersdk.PrebuildsConfig{}, + testutil.Logger(t), + quartz.NewMock(t), + prometheus.NewRegistry(), + notifications.NewNoopEnqueuer(), + ) + 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: firstOptionalParameterValue}, + {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", + "--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) + + // 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", + }, + }, + }, + }, + }, + }, + }, + }, + } +} From ae16155a0dacea13f203619009056b5caa2c1454 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Wed, 23 Jul 2025 12:18:38 +0000 Subject: [PATCH 02/10] chore: fix presetParameterAsWorkspaceBuildParameters function --- cli/create.go | 5 +---- cli/parameter.go | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/cli/create.go b/cli/create.go index 489d7563bec73..2c5ca8420efe9 100644 --- a/cli/create.go +++ b/cli/create.go @@ -297,10 +297,7 @@ func (r *RootCmd) create() *serpent.Command { } // Convert preset parameters into workspace build parameters. - presetBuildParameters, err := presetParameterAsWorkspaceBuildParameters(preset.Parameters) - if err != nil { - return xerrors.Errorf("failed to parse preset parameters: %w", err) - } + presetBuildParameters := presetParameterAsWorkspaceBuildParameters(preset.Parameters) presetParameters = append(presetParameters, presetBuildParameters...) // Inform the user which preset was applied and its parameters. diff --git a/cli/parameter.go b/cli/parameter.go index 8b3f91e041068..a0dc7a37a10cb 100644 --- a/cli/parameter.go +++ b/cli/parameter.go @@ -100,12 +100,12 @@ func (wpf *workspaceParameterFlags) alwaysPrompt() serpent.Option { } } -func presetParameterAsWorkspaceBuildParameters(presetParameters []codersdk.PresetParameter) ([]codersdk.WorkspaceBuildParameter, error) { +func presetParameterAsWorkspaceBuildParameters(presetParameters []codersdk.PresetParameter) []codersdk.WorkspaceBuildParameter { var params []codersdk.WorkspaceBuildParameter for _, parameter := range presetParameters { params = append(params, codersdk.WorkspaceBuildParameter(parameter)) } - return params, nil + return params } func asWorkspaceBuildParameters(nameValuePairs []string) ([]codersdk.WorkspaceBuildParameter, error) { From 3d7b40e4300ad7605124f8bd502034515875504a Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Wed, 23 Jul 2025 12:42:59 +0000 Subject: [PATCH 03/10] chore: minor fixes --- cli/create_test.go | 4 ++-- enterprise/cli/create_test.go | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/create_test.go b/cli/create_test.go index 69031e400f505..37038b7cd92fd 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -676,7 +676,7 @@ func TestCreateWithPreset(t *testing.T) { firstParameterValue = "1" firstOptionalParameterName = "first_optional_parameter" - firstOptionParameterDescription = "This is the first optional parameter" + firstOptionalParameterDescription = "This is the first optional parameter" firstOptionalParameterValue = "1" secondOptionalParameterName = "second_optional_parameter" secondOptionalParameterDescription = "This is the second optional parameter" @@ -698,7 +698,7 @@ func TestCreateWithPreset(t *testing.T) { Options: []*proto.RichParameterOption{ { Name: firstOptionalParameterName, - Description: firstOptionParameterDescription, + Description: firstOptionalParameterDescription, Value: firstOptionalParameterValue, }, { diff --git a/enterprise/cli/create_test.go b/enterprise/cli/create_test.go index 66260eddc7a48..794c2ec89dd1a 100644 --- a/enterprise/cli/create_test.go +++ b/enterprise/cli/create_test.go @@ -279,6 +279,7 @@ func TestEnterpriseCreateWithPreset(t *testing.T) { 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) @@ -298,6 +299,7 @@ func TestEnterpriseCreateWithPreset(t *testing.T) { var runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow testutil.Eventually(ctx, t, func(context.Context) bool { + runningPrebuilds = nil rows, err := db.GetRunningPrebuiltWorkspaces(ctx) if err != nil { return false From b8a1c14902e10af5046d505a47a8542d12c54609 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Wed, 23 Jul 2025 13:32:09 +0000 Subject: [PATCH 04/10] chore: fix merge orgin/main --- enterprise/cli/create_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/enterprise/cli/create_test.go b/enterprise/cli/create_test.go index 794c2ec89dd1a..79d6b6e24bbff 100644 --- a/enterprise/cli/create_test.go +++ b/enterprise/cli/create_test.go @@ -5,9 +5,12 @@ import ( "database/sql" "fmt" "sync" + "sync/atomic" "testing" "time" + "github.com/coder/coder/v2/coderd/wsbuilder" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" @@ -351,6 +354,12 @@ func TestEnterpriseCreateWithPreset(t *testing.T) { // 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{}, @@ -358,6 +367,7 @@ func TestEnterpriseCreateWithPreset(t *testing.T) { quartz.NewMock(t), prometheus.NewRegistry(), notifications.NewNoopEnqueuer(), + newNoopUsageCheckerPtr(), ) var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) api.AGPL.PrebuildsClaimer.Store(&claimer) @@ -454,6 +464,12 @@ func TestEnterpriseCreateWithPreset(t *testing.T) { // 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{}, @@ -461,6 +477,7 @@ func TestEnterpriseCreateWithPreset(t *testing.T) { quartz.NewMock(t), prometheus.NewRegistry(), notifications.NewNoopEnqueuer(), + newNoopUsageCheckerPtr(), ) var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) api.AGPL.PrebuildsClaimer.Store(&claimer) From 77a72b16c3d84715ebfb2bfad8e1cac87d468319 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Wed, 23 Jul 2025 19:35:10 +0000 Subject: [PATCH 05/10] chore: prompt user to select a preset when no --preset flag is provided --- cli/create.go | 127 ++++++++++++------ cli/create_test.go | 233 +++++++++++++++------------------- enterprise/cli/create_test.go | 13 +- 3 files changed, 203 insertions(+), 170 deletions(-) diff --git a/cli/create.go b/cli/create.go index 2c5ca8420efe9..9fbedb3069eb8 100644 --- a/cli/create.go +++ b/cli/create.go @@ -21,9 +21,10 @@ import ( "github.com/coder/serpent" ) -// DefaultPresetName is used when a user runs `create --preset default`. -// It instructs the CLI to use the default preset defined for the template version, if one exists. -const DefaultPresetName = "default" +// 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" func (r *RootCmd) create() *serpent.Command { var ( @@ -268,47 +269,31 @@ func (r *RootCmd) create() *serpent.Command { } } - // If a preset name is provided, resolve the preset to use. + // 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 - isDefaultPreset := false - if len(presetName) > 0 { - tvPresets, err := client.TemplateVersionPresets(inv.Context(), templateVersionID) - if err != nil { - return xerrors.Errorf("failed to get presets: %w", err) - } - for _, tvPreset := range tvPresets { - // If the preset name is the special "default" keyword, - // fetch the template version's default preset (if any). - if presetName == DefaultPresetName && tvPreset.Default { - preset = &tvPreset - isDefaultPreset = true - break - } - if tvPreset.Name == presetName { - preset = &tvPreset - break - } - } - - if preset == nil { - return xerrors.Errorf("preset %q not found", presetName) + // If the template has no presets, or the user explicitly used --preset none, + // skip applying a preset. + if len(tvPresets) > 0 && presetName != PresetNone { + // Resolve which preset to use + preset, err = resolvePreset(inv, tvPresets, presetName) + if err != nil { + return xerrors.Errorf("unable to resolve preset: %w", err) } // Convert preset parameters into workspace build parameters. - presetBuildParameters := presetParameterAsWorkspaceBuildParameters(preset.Parameters) - presetParameters = append(presetParameters, presetBuildParameters...) - + presetParameters = presetParameterAsWorkspaceBuildParameters(preset.Parameters) // Inform the user which preset was applied and its parameters. - presetLabel := fmt.Sprintf("Preset '%s'", preset.Name) - if isDefaultPreset { - presetLabel += " (default)" - } - _, _ = fmt.Fprintf(inv.Stdout, "%s applied:", cliui.Bold(presetLabel)) - for _, p := range presetParameters { - _, _ = fmt.Fprintf(inv.Stdout, " %s: '%s'\n", cliui.Bold(p.Name), p.Value) - } + 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{ @@ -446,6 +431,74 @@ type prepWorkspaceBuildArgs struct { RichParameterDefaults []codersdk.WorkspaceBuildParameter } +// resolvePreset determines which preset to use based on the --preset flag, +// or prompts the user to select one if the flag is not provided. +func resolvePreset(inv *serpent.Invocation, presets []codersdk.Preset, presetName string) (*codersdk.Preset, error) { + // If preset name is specified, find it + if presetName != "" { + for _, preset := range presets { + if preset.Name == presetName { + return &preset, nil + } + } + return nil, xerrors.Errorf("preset %q not found", presetName) + } + + // No preset specified, prompt user to select one + return promptPresetSelection(inv, presets) +} + +// promptPresetSelection shows a CLI selection menu of the presets defined in the template version. +func promptPresetSelection(inv *serpent.Invocation, presets []codersdk.Preset) (*codersdk.Preset, error) { + presetMap := make(map[string]*codersdk.Preset) + var defaultOption string + var options []string + + // Process presets, with the default option (if any) listed first. + for _, preset := range presets { + option := preset.Name + if preset.Default { + option = "(default) " + preset.Name + defaultOption = option + } + presetMap[option] = &preset + } + + if defaultOption != "" { + options = append(options, defaultOption) + } + for option := range presetMap { + if option != defaultOption { + options = append(options, option) + } + } + + // Show selection UI + _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a preset below:")) + selected, err := cliui.Select(inv, cliui.SelectOptions{ + Options: options, + 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) { diff --git a/cli/create_test.go b/cli/create_test.go index 37038b7cd92fd..fddb932579beb 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -780,9 +780,10 @@ func TestCreateWithPreset(t *testing.T) { require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: thirdParameterName, Value: thirdParameterValue}) }) - // This test verifies that when the user does not provide the `--preset` flag, - // the workspace is created without using any preset. - t.Run("NoPresetFlag", func(t *testing.T) { + // 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}) @@ -805,6 +806,7 @@ func TestCreateWithPreset(t *testing.T) { 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) @@ -827,45 +829,10 @@ func TestCreateWithPreset(t *testing.T) { 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 the user provides `--preset default`, - // the CLI selects the preset marked as the default in the template version. - // The workspace should be created using that default preset's parameters. - t.Run("PresetFlagDefault", func(t *testing.T) { + // 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}) @@ -885,88 +852,20 @@ func TestCreateWithPreset(t *testing.T) { coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) - // When: running the create command with the default preset + // 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.DefaultPresetName) - 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:", 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, 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 the user provides `--preset default`, - // but there is no preset marked as default in the template version, - // and a preset literally named "default" exists, - // the CLI selects the preset named "default" instead. - // The workspace should be created using that preset's parameters. - t.Run("PresetFlagDefaultName", 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 preset named "default" - preset := proto.Preset{ - Name: cli.DefaultPresetName, - 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 the preset - workspaceName := "my-workspace" - inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", preset.Name) + 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.") - // Should: display the 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. + // Verify that the new workspace doesn't use the preset parameters. ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() @@ -980,21 +879,21 @@ func TestCreateWithPreset(t *testing.T) { require.NoError(t, err) require.Len(t, workspaces.Workspaces, 1) - // Should: create a workspace using the expected template version and the preset-defined parameters + // 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.Equal(t, tvPresets[0].ID, *workspaceLatestBuild.TemplateVersionPresetID) + 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: secondOptionalParameterValue}) + 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 default` - // but the template version does not define any default preset, - // the CLI returns an appropriate error. - t.Run("FailsWhenDefaultPresetIsRequestedButNotDefined", func(t *testing.T) { + // 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}) @@ -1013,17 +912,17 @@ func TestCreateWithPreset(t *testing.T) { coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) - // When: running the create command with a non-existent default preset + // 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", cli.DefaultPresetName) + 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 default preset was not found - require.Contains(t, err.Error(), "preset \"default\" not found") + // 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 @@ -1179,7 +1078,7 @@ func TestCreateWithPreset(t *testing.T) { 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 + // Given: a template version with a preset that defines one parameter preset := proto.Preset{ Name: "preset-test", Parameters: []*proto.PresetParameter{ @@ -1216,7 +1115,7 @@ func TestCreateWithPreset(t *testing.T) { <-doneChan // Verify if the new workspace uses expected parameters. - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() tvPresets, err := client.TemplateVersionPresets(ctx, version.ID) @@ -1239,6 +1138,82 @@ func TestCreateWithPreset(t *testing.T) { 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 a default preset, + // and the user does not provide the `--preset` flag, + // the CLI prompts the user to select a preset, and the default preset is listed first. + t.Run("PromptsUserToSelectPresetWhenNotSpecified", 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 + presetDefault := proto.Preset{ + Name: "preset-default", + Default: true, + Parameters: []*proto.PresetParameter{ + {Name: firstParameterName, Value: secondOptionalParameterValue}, + {Name: thirdParameterName, Value: thirdParameterValue}, + }, + } + presetTest := proto.Preset{ + Name: "preset-test", + Parameters: []*proto.PresetParameter{ + {Name: firstParameterName, Value: firstOptionalParameterValue}, + }, + } + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&presetDefault, &presetTest)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + _ = 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) + 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:") + time.Sleep(10 * time.Second) + pty.WriteLine("\n") + pty.ExpectMatch("Preset 'preset-default' (default) 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, 2) + + 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) { diff --git a/enterprise/cli/create_test.go b/enterprise/cli/create_test.go index 79d6b6e24bbff..07e92bab3e626 100644 --- a/enterprise/cli/create_test.go +++ b/enterprise/cli/create_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/coder/coder/v2/cli" + "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/google/uuid" @@ -446,9 +448,10 @@ func TestEnterpriseCreateWithPreset(t *testing.T) { require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: thirdParameterName, Value: thirdParameterValue}) }) - // This test verifies that when no preset is selected, no prebuilt workspace - // is claimed, and instead a new regular workspace is created. - t.Run("NoPresetFlagDoesNotClaimPrebuiltWorkspace", func(t *testing.T) { + // 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 @@ -488,7 +491,7 @@ func TestEnterpriseCreateWithPreset(t *testing.T) { presetWithPrebuild := proto.Preset{ Name: "preset-test", Parameters: []*proto.PresetParameter{ - {Name: firstParameterName, Value: firstOptionalParameterValue}, + {Name: firstParameterName, Value: secondOptionalParameterValue}, {Name: thirdParameterName, Value: thirdParameterValue}, }, Prebuild: &proto.Prebuild{ @@ -519,6 +522,7 @@ func TestEnterpriseCreateWithPreset(t *testing.T) { // 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) @@ -527,6 +531,7 @@ func TestEnterpriseCreateWithPreset(t *testing.T) { 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) From cffe04909f90e69f72084b3f57aeb4a7fdd59dfd Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Wed, 23 Jul 2025 19:39:05 +0000 Subject: [PATCH 06/10] chore: update preset flag description --- cli/create.go | 2 +- cli/testdata/coder_create_--help.golden | 4 ++-- docs/reference/cli/create.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/create.go b/cli/create.go index 9fbedb3069eb8..93bf896372452 100644 --- a/cli/create.go +++ b/cli/create.go @@ -377,7 +377,7 @@ func (r *RootCmd) create() *serpent.Command { serpent.Option{ Flag: "preset", Env: "CODER_PRESET_NAME", - Description: "Specify a template version preset name. Use 'default' to apply the default preset defined in the template version, if available.", + 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{ diff --git a/cli/testdata/coder_create_--help.golden b/cli/testdata/coder_create_--help.golden index d557ebd97fd25..47e809e8f5af6 100644 --- a/cli/testdata/coder_create_--help.golden +++ b/cli/testdata/coder_create_--help.golden @@ -27,8 +27,8 @@ OPTIONS: Rich parameter default values in the format "name=value". --preset string, $CODER_PRESET_NAME - Specify a template version preset name. Use 'default' to apply the - default preset defined in the template version, if available. + 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 diff --git a/docs/reference/cli/create.md b/docs/reference/cli/create.md index 0075b2b0e7152..d18b4ea5c8e05 100644 --- a/docs/reference/cli/create.md +++ b/docs/reference/cli/create.md @@ -44,7 +44,7 @@ Specify a template version name. | Type | string | | Environment | $CODER_PRESET_NAME | -Specify a template version preset name. Use 'default' to apply the default preset defined in the template version, if available. +Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used. ### --start-at From dbe0685ac9819cfb4b281cdff2a236ae1f95b4ee Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 28 Jul 2025 09:23:08 +0000 Subject: [PATCH 07/10] chore: default to preset if available and none specified --- cli/create.go | 72 +++++----- cli/create_test.go | 247 +++++++++++++++++++++++----------- enterprise/cli/create_test.go | 2 +- 3 files changed, 203 insertions(+), 118 deletions(-) diff --git a/cli/create.go b/cli/create.go index 93bf896372452..d7642cbda32f4 100644 --- a/cli/create.go +++ b/cli/create.go @@ -21,10 +21,10 @@ import ( "github.com/coder/serpent" ) -// PresetNone represents the special preset value "none". -// It is used when a user runs `create --preset none`, +// 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" +const PresetNone = "None" func (r *RootCmd) create() *serpent.Command { var ( @@ -278,18 +278,25 @@ func (r *RootCmd) create() *serpent.Command { 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 the template has no presets, or the user explicitly used --preset None, + // skip applying a preset if len(tvPresets) > 0 && presetName != PresetNone { - // Resolve which preset to use - preset, err = resolvePreset(inv, tvPresets, presetName) + // Attempt to resolve which preset to use + preset, err = resolvePreset(tvPresets, presetName) if err != nil { return xerrors.Errorf("unable to resolve preset: %w", err) } - // Convert preset parameters into workspace build parameters. + // If no preset found, prompt the user to choose a preset + if preset == nil { + 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. + // Inform the user which preset was applied and its parameters displayAppliedPreset(inv, preset, presetParameters) } else { // Inform the user that no preset was applied @@ -377,7 +384,7 @@ func (r *RootCmd) create() *serpent.Command { 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.", + 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{ @@ -431,52 +438,47 @@ type prepWorkspaceBuildArgs struct { RichParameterDefaults []codersdk.WorkspaceBuildParameter } -// resolvePreset determines which preset to use based on the --preset flag, -// or prompts the user to select one if the flag is not provided. -func resolvePreset(inv *serpent.Invocation, presets []codersdk.Preset, presetName string) (*codersdk.Preset, error) { +// resolvePreset returns the preset matching the given presetName (if specified), +// or the default preset (if any). +// Returns nil 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 _, preset := range presets { - if preset.Name == presetName { - return &preset, nil + for _, p := range presets { + if p.Name == presetName { + return &p, nil } } return nil, xerrors.Errorf("preset %q not found", presetName) } - // No preset specified, prompt user to select one - return promptPresetSelection(inv, presets) + // No preset name specified, search for the default preset + for _, p := range presets { + if p.Default { + return &p, nil + } + } + + // No preset found, return nil to indicate no preset found + return nil, nil } // 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 defaultOption string - var options []string + var presetOptions []string - // Process presets, with the default option (if any) listed first. for _, preset := range presets { option := preset.Name - if preset.Default { - option = "(default) " + preset.Name - defaultOption = option - } + presetOptions = append(presetOptions, option) presetMap[option] = &preset } - if defaultOption != "" { - options = append(options, defaultOption) - } - for option := range presetMap { - if option != defaultOption { - options = append(options, option) - } - } - // Show selection UI _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a preset below:")) selected, err := cliui.Select(inv, cliui.SelectOptions{ - Options: options, + Options: presetOptions, HideSearch: true, }) if err != nil { diff --git a/cli/create_test.go b/cli/create_test.go index fddb932579beb..b2bc4d2dfd578 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -717,9 +717,9 @@ func TestCreateWithPreset(t *testing.T) { }, presets...) } - // This test verifies that when a user provides the `--preset` flag, - // the CLI correctly uses the parameters defined in the preset. - // The workspace is created using those preset parameters. + // 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() @@ -727,7 +727,14 @@ func TestCreateWithPreset(t *testing.T) { 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 + // 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{ @@ -735,7 +742,7 @@ func TestCreateWithPreset(t *testing.T) { {Name: thirdParameterName, Value: thirdParameterValue}, }, } - version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&preset)) + 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) @@ -759,6 +766,158 @@ func TestCreateWithPreset(t *testing.T) { 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) + _ = 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) + 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) @@ -852,7 +1011,7 @@ func TestCreateWithPreset(t *testing.T) { coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) - // When: running the create command with flag '--preset none' + // 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), @@ -1138,82 +1297,6 @@ func TestCreateWithPreset(t *testing.T) { 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 a default preset, - // and the user does not provide the `--preset` flag, - // the CLI prompts the user to select a preset, and the default preset is listed first. - t.Run("PromptsUserToSelectPresetWhenNotSpecified", 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 - presetDefault := proto.Preset{ - Name: "preset-default", - Default: true, - Parameters: []*proto.PresetParameter{ - {Name: firstParameterName, Value: secondOptionalParameterValue}, - {Name: thirdParameterName, Value: thirdParameterValue}, - }, - } - presetTest := proto.Preset{ - Name: "preset-test", - Parameters: []*proto.PresetParameter{ - {Name: firstParameterName, Value: firstOptionalParameterValue}, - }, - } - version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&presetDefault, &presetTest)) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - _ = 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) - 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:") - time.Sleep(10 * time.Second) - pty.WriteLine("\n") - pty.ExpectMatch("Preset 'preset-default' (default) 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, 2) - - 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) { diff --git a/enterprise/cli/create_test.go b/enterprise/cli/create_test.go index 07e92bab3e626..44218abb5a58d 100644 --- a/enterprise/cli/create_test.go +++ b/enterprise/cli/create_test.go @@ -448,7 +448,7 @@ func TestEnterpriseCreateWithPreset(t *testing.T) { require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: thirdParameterName, Value: thirdParameterValue}) }) - // This test verifies that when the user provides `--preset none`, + // 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) { From 645ec89f037400e9773e33eb630ebd7fd4b7be09 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 28 Jul 2025 11:06:41 +0000 Subject: [PATCH 08/10] fix: make gen --- cli/create.go | 18 ++++++++++-------- cli/testdata/coder_create_--help.golden | 2 +- docs/reference/cli/create.md | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/cli/create.go b/cli/create.go index d7642cbda32f4..b9b006248ec62 100644 --- a/cli/create.go +++ b/cli/create.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "fmt" "io" "slices" @@ -26,6 +27,8 @@ import ( // 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 @@ -284,11 +287,10 @@ func (r *RootCmd) create() *serpent.Command { // Attempt to resolve which preset to use preset, err = resolvePreset(tvPresets, presetName) if err != nil { - return xerrors.Errorf("unable to resolve preset: %w", err) - } - - // If no preset found, prompt the user to choose a preset - if preset == 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) } @@ -440,7 +442,7 @@ type prepWorkspaceBuildArgs struct { // resolvePreset returns the preset matching the given presetName (if specified), // or the default preset (if any). -// Returns nil if no matching or default preset is found. +// 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 != "" { @@ -459,8 +461,8 @@ func resolvePreset(presets []codersdk.Preset, presetName string) (*codersdk.Pres } } - // No preset found, return nil to indicate no preset found - return nil, nil + // No preset found + return nil, ErrNoPresetFound } // promptPresetSelection shows a CLI selection menu of the presets defined in the template version. diff --git a/cli/testdata/coder_create_--help.golden b/cli/testdata/coder_create_--help.golden index 47e809e8f5af6..07466ee454ffd 100644 --- a/cli/testdata/coder_create_--help.golden +++ b/cli/testdata/coder_create_--help.golden @@ -27,7 +27,7 @@ OPTIONS: 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 + 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 diff --git a/docs/reference/cli/create.md b/docs/reference/cli/create.md index d18b4ea5c8e05..b333a7bc0aa98 100644 --- a/docs/reference/cli/create.md +++ b/docs/reference/cli/create.md @@ -44,7 +44,7 @@ Specify a template version name. | 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. +Specify the name of a template version preset. Use 'None' to explicitly indicate that no preset should be used. ### --start-at From 0b7cedef0899416931ba8593aeeff1ca91a6b9e3 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 28 Jul 2025 12:08:02 +0000 Subject: [PATCH 09/10] chore: minor fixes --- cli/create.go | 12 ++++++------ cli/create_test.go | 15 ++++++++------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/cli/create.go b/cli/create.go index b9b006248ec62..4e0e47b43eaa4 100644 --- a/cli/create.go +++ b/cli/create.go @@ -22,10 +22,10 @@ import ( "github.com/coder/serpent" ) -// PresetNone represents the special preset value "None". -// It is used when a user runs `create --preset None`, +// 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" +const PresetNone = "none" var ErrNoPresetFound = xerrors.New("no preset found") @@ -281,9 +281,9 @@ func (r *RootCmd) create() *serpent.Command { var preset *codersdk.Preset var presetParameters []codersdk.WorkspaceBuildParameter - // If the template has no presets, or the user explicitly used --preset None, + // If the template has no presets, or the user explicitly used --preset none, // skip applying a preset - if len(tvPresets) > 0 && presetName != PresetNone { + if len(tvPresets) > 0 && strings.ToLower(presetName) != PresetNone { // Attempt to resolve which preset to use preset, err = resolvePreset(tvPresets, presetName) if err != nil { @@ -386,7 +386,7 @@ func (r *RootCmd) create() *serpent.Command { 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.", + 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{ diff --git a/cli/create_test.go b/cli/create_test.go index b2bc4d2dfd578..9db2e328c6ce9 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -9,11 +9,10 @@ import ( "testing" "time" - "github.com/coder/coder/v2/cli" - "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" @@ -891,11 +890,13 @@ func TestCreateWithPreset(t *testing.T) { } version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&preset)) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - _ = coderdtest.CreateTemplate(t, client, owner.OrganizationID, 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) + 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) @@ -949,7 +950,7 @@ func TestCreateWithPreset(t *testing.T) { owner := coderdtest.CreateFirstUser(t, client) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - // Given: a template and a template version + // 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) @@ -988,7 +989,7 @@ func TestCreateWithPreset(t *testing.T) { require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: thirdParameterName, Value: thirdParameterValue}) }) - // This test verifies that when the user provides `--preset None`, + // 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) { @@ -1011,7 +1012,7 @@ func TestCreateWithPreset(t *testing.T) { coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) - // When: running the create command with flag '--preset None' + // 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), From 6210f222e8507f5544c161c7d411f8d865f7116a Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 28 Jul 2025 13:27:31 +0000 Subject: [PATCH 10/10] chore: run make gen --- cli/testdata/coder_create_--help.golden | 2 +- docs/reference/cli/create.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/testdata/coder_create_--help.golden b/cli/testdata/coder_create_--help.golden index 07466ee454ffd..47e809e8f5af6 100644 --- a/cli/testdata/coder_create_--help.golden +++ b/cli/testdata/coder_create_--help.golden @@ -27,7 +27,7 @@ OPTIONS: 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 + 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 diff --git a/docs/reference/cli/create.md b/docs/reference/cli/create.md index b333a7bc0aa98..d18b4ea5c8e05 100644 --- a/docs/reference/cli/create.md +++ b/docs/reference/cli/create.md @@ -44,7 +44,7 @@ Specify a template version name. | 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. +Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used. ### --start-at pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

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:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy