feat(cli): add CLI support for creating a workspace with preset (#18912)

## Description 

This PR introduces a `--preset` flag for the `create` command to allow
users to apply a predefined preset to their workspace build.

## Changes

- The `--preset` flag on the `create` command integrates with the
parameter resolution logic and takes precedence over other sources
(e.g., CLI/env vars, last build, etc.).
- Added internal logic to ensure that preset parameters override
parameters values during resolution.
- Updated tests and added new ones to cover these flows.

## Implementation logic

* If a template has presets and includes a default, the CLI will
automatically use the default when `--preset` is not specified.
* If a template has presets but no default, the CLI will prompt the user
to select one when `--preset` is not specified.
* If a template does not have presets, the CLI will not prompt the user
for a preset.
* If the user specifies a preset using the `--preset` flag, that preset
will be used.
* If the user passes `--preset None`, no preset will be applied.

This logic aligns with the behavior in the UI for consistency.

```
> coder create --help

USAGE:
  coder create [flags] [workspace]

  Create a workspace

    - Create a workspace for another user (if you have permission):

        $ coder create <username>/<workspace_name>

OPTIONS:
      (...)

      --preset string, $CODER_PRESET_NAME
          Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used.

      (...)

  -y, --yes bool
          Bypass prompts.
```

## Breaking change

**Note:** This is a breaking change to the create CLI command. If a
template includes presets and the user does not provide a `--preset`
flag, the CLI will now prompt the user to select one. This behavior may
break non-interactive scripts or automated workflows.


Relates to PR: https://github.com/coder/coder/pull/18910 - please
consider both PRs together as they’re part of the same workflow
Relates to issue: https://github.com/coder/coder/issues/16594
This commit is contained in:
Susana Ferreira
2025-07-28 14:46:04 +01:00
committed by GitHub
parent 66cf90c736
commit b975d6d9b3
7 changed files with 1197 additions and 3 deletions
+123 -2
View File
@@ -2,6 +2,7 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"slices"
@@ -21,10 +22,18 @@ import (
"github.com/coder/serpent"
)
// PresetNone represents the special preset value "none".
// It is used when a user runs `create --preset none`,
// indicating that the CLI should not apply any preset.
const PresetNone = "none"
var ErrNoPresetFound = xerrors.New("no preset found")
func (r *RootCmd) create() *serpent.Command {
var (
templateName string
templateVersion string
presetName string
startAt string
stopAfter time.Duration
workspaceName string
@@ -263,11 +272,45 @@ func (r *RootCmd) create() *serpent.Command {
}
}
// Get presets for the template version
tvPresets, err := client.TemplateVersionPresets(inv.Context(), templateVersionID)
if err != nil {
return xerrors.Errorf("failed to get presets: %w", err)
}
var preset *codersdk.Preset
var presetParameters []codersdk.WorkspaceBuildParameter
// If the template has no presets, or the user explicitly used --preset none,
// skip applying a preset
if len(tvPresets) > 0 && strings.ToLower(presetName) != PresetNone {
// Attempt to resolve which preset to use
preset, err = resolvePreset(tvPresets, presetName)
if err != nil {
if !errors.Is(err, ErrNoPresetFound) {
return xerrors.Errorf("unable to resolve preset: %w", err)
}
// If no preset found, prompt the user to choose a preset
if preset, err = promptPresetSelection(inv, tvPresets); err != nil {
return xerrors.Errorf("unable to prompt user for preset: %w", err)
}
}
// Convert preset parameters into workspace build parameters
presetParameters = presetParameterAsWorkspaceBuildParameters(preset.Parameters)
// Inform the user which preset was applied and its parameters
displayAppliedPreset(inv, preset, presetParameters)
} else {
// Inform the user that no preset was applied
_, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied."))
}
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
Action: WorkspaceCreate,
TemplateVersionID: templateVersionID,
NewWorkspaceName: workspaceName,
PresetParameters: presetParameters,
RichParameterFile: parameterFlags.richParameterFile,
RichParameters: cliBuildParameters,
RichParameterDefaults: cliBuildParameterDefaults,
@@ -291,14 +334,21 @@ func (r *RootCmd) create() *serpent.Command {
ttlMillis = ptr.Ref(stopAfter.Milliseconds())
}
workspace, err := client.CreateUserWorkspace(inv.Context(), workspaceOwner, codersdk.CreateWorkspaceRequest{
req := codersdk.CreateWorkspaceRequest{
TemplateVersionID: templateVersionID,
Name: workspaceName,
AutostartSchedule: schedSpec,
TTLMillis: ttlMillis,
RichParameterValues: richParameters,
AutomaticUpdates: codersdk.AutomaticUpdates(autoUpdates),
})
}
// If a preset exists, update the create workspace request's preset ID
if preset != nil {
req.TemplateVersionPresetID = preset.ID
}
workspace, err := client.CreateUserWorkspace(inv.Context(), workspaceOwner, req)
if err != nil {
return xerrors.Errorf("create workspace: %w", err)
}
@@ -333,6 +383,12 @@ func (r *RootCmd) create() *serpent.Command {
Description: "Specify a template version name.",
Value: serpent.StringOf(&templateVersion),
},
serpent.Option{
Flag: "preset",
Env: "CODER_PRESET_NAME",
Description: "Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used.",
Value: serpent.StringOf(&presetName),
},
serpent.Option{
Flag: "start-at",
Env: "CODER_WORKSPACE_START_AT",
@@ -377,12 +433,76 @@ type prepWorkspaceBuildArgs struct {
PromptEphemeralParameters bool
EphemeralParameters []codersdk.WorkspaceBuildParameter
PresetParameters []codersdk.WorkspaceBuildParameter
PromptRichParameters bool
RichParameters []codersdk.WorkspaceBuildParameter
RichParameterFile string
RichParameterDefaults []codersdk.WorkspaceBuildParameter
}
// resolvePreset returns the preset matching the given presetName (if specified),
// or the default preset (if any).
// Returns ErrNoPresetFound if no matching or default preset is found.
func resolvePreset(presets []codersdk.Preset, presetName string) (*codersdk.Preset, error) {
// If preset name is specified, find it
if presetName != "" {
for _, p := range presets {
if p.Name == presetName {
return &p, nil
}
}
return nil, xerrors.Errorf("preset %q not found", presetName)
}
// No preset name specified, search for the default preset
for _, p := range presets {
if p.Default {
return &p, nil
}
}
// No preset found
return nil, ErrNoPresetFound
}
// promptPresetSelection shows a CLI selection menu of the presets defined in the template version.
// Returns the selected preset
func promptPresetSelection(inv *serpent.Invocation, presets []codersdk.Preset) (*codersdk.Preset, error) {
presetMap := make(map[string]*codersdk.Preset)
var presetOptions []string
for _, preset := range presets {
option := preset.Name
presetOptions = append(presetOptions, option)
presetMap[option] = &preset
}
// Show selection UI
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a preset below:"))
selected, err := cliui.Select(inv, cliui.SelectOptions{
Options: presetOptions,
HideSearch: true,
})
if err != nil {
return nil, xerrors.Errorf("failed to select preset: %w", err)
}
return presetMap[selected], nil
}
// displayAppliedPreset shows the user which preset was applied and its parameters
func displayAppliedPreset(inv *serpent.Invocation, preset *codersdk.Preset, parameters []codersdk.WorkspaceBuildParameter) {
label := fmt.Sprintf("Preset '%s'", preset.Name)
if preset.Default {
label += " (default)"
}
_, _ = fmt.Fprintf(inv.Stdout, "%s applied:\n", cliui.Bold(label))
for _, param := range parameters {
_, _ = fmt.Fprintf(inv.Stdout, " %s: '%s'\n", cliui.Bold(param.Name), param.Value)
}
}
// prepWorkspaceBuild will ensure a workspace build will succeed on the latest template version.
// Any missing params will be prompted to the user. It supports rich parameters.
func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.WorkspaceBuildParameter, error) {
@@ -411,6 +531,7 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
WithSourceWorkspaceParameters(args.SourceWorkspaceParameters).
WithPromptEphemeralParameters(args.PromptEphemeralParameters).
WithEphemeralParameters(args.EphemeralParameters).
WithPresetParameters(args.PresetParameters).
WithPromptRichParameters(args.PromptRichParameters).
WithRichParameters(args.RichParameters).
WithRichParametersFile(parameterFile).