Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
121 changes: 119 additions & 2 deletions cli/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,16 @@ 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"

func (r *RootCmd) create() *serpent.Command {
var (
templateName string
templateVersion string
presetName string
startAt string
stopAfter time.Duration
workspaceName string
Expand Down Expand Up @@ -263,11 +269,39 @@ 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 && 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.
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,
Expand All @@ -291,14 +325,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)
}
Expand Down Expand Up @@ -333,6 +374,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",
Expand Down Expand Up @@ -377,12 +424,81 @@ type prepWorkspaceBuildArgs struct {
PromptEphemeralParameters bool
EphemeralParameters []codersdk.WorkspaceBuildParameter

PresetParameters []codersdk.WorkspaceBuildParameter
PromptRichParameters bool
RichParameters []codersdk.WorkspaceBuildParameter
RichParameterFile string
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nonblocking: chaining this call to promptPresetSelection here has a few coupling related downsides:

  • It locks any use and testing of this parameter resolution into preset prompting as well.
  • It requires an additional parameters (inv) that is not relevant to the main body of this function and is only passed through.

If possible, it would perhaps be better to return something here that means "no preset resolved" and then call the prompt outside.

}

// 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) {
Expand Down Expand Up @@ -411,6 +527,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).
Expand Down
Loading
Loading