Compare commits

...

1 Commits

Author SHA1 Message Date
Asher fbcaad614a feat: add non-interactive flag to create command
This will automatically select the template if there is only one, skip
the preset if there is no default, and use parameter defaults.

If there are multiple templates or required parameters that have no
defaults, the command will error.
2026-01-22 16:00:21 -09:00
7 changed files with 319 additions and 27 deletions
+49 -15
View File
@@ -46,6 +46,7 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
autoUpdates string
copyParametersFrom string
useParameterDefaults bool
nonInteractive bool
// Organization context is only required if more than 1 template
// shares the same name across multiple organizations.
orgContext = NewOrganizationContext()
@@ -75,6 +76,9 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
}
if workspaceName == "" {
if nonInteractive {
return xerrors.New("workspace name must be provided as an argument in non-interactive mode")
}
workspaceName, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Specify a name for your workspace:",
Validate: func(workspaceName string) error {
@@ -122,13 +126,25 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
var templateVersionID uuid.UUID
switch {
case templateName == "":
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:"))
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{})
if err != nil {
return err
}
if len(templates) == 0 {
return xerrors.New("no templates available")
}
if nonInteractive {
if len(templates) == 1 {
_, _ = fmt.Fprintf(inv.Stdout, "Using the only available template: %q\n", templates[0].Name)
template = templates[0]
templateVersionID = template.ActiveVersionID
break
}
return xerrors.New("multiple templates available; use --template to specify which to use")
}
slices.SortFunc(templates, func(a, b codersdk.Template) int {
return slice.Descending(a.ActiveUserCount, b.ActiveUserCount)
})
@@ -167,6 +183,8 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
templateByName[templateName] = template
}
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:"))
// Move the cursor up a single line for nicer display!
option, err := cliui.Select(inv, cliui.SelectOptions{
Options: templateNames,
@@ -297,19 +315,24 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
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)
// If no preset found, prompt the user to choose a preset, unless in
// non-interactive mode, in which case no preset is used.
if !nonInteractive {
if preset, err = promptPresetSelection(inv, tvPresets); err != nil {
return xerrors.Errorf("unable to prompt user for preset: %w", err)
}
}
}
}
if preset == nil {
// Inform the user when no preset will be applied.
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", cliui.Bold("No preset applied."))
} else {
// 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\n", cliui.Bold("No preset applied."))
}
if opts.BeforeCreate != nil {
@@ -332,17 +355,20 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
SourceWorkspaceParameters: sourceWorkspaceParameters,
UseParameterDefaults: useParameterDefaults,
NonInteractive: nonInteractive,
})
if err != nil {
return xerrors.Errorf("prepare build: %w", err)
}
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Confirm create?",
IsConfirm: true,
})
if err != nil {
return err
if !nonInteractive {
_, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Confirm create?",
IsConfirm: true,
})
if err != nil {
return err
}
}
var ttlMillis *int64
@@ -444,6 +470,12 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
Description: "Automatically accept parameter defaults when no value is provided.",
Value: serpent.BoolOf(&useParameterDefaults),
},
serpent.Option{
Flag: "non-interactive",
Env: "CODER_NON_INTERACTIVE",
Description: "Automatically accept all defaults and error when there is no default for a required input.",
Value: serpent.BoolOf(&nonInteractive),
},
cliui.SkipPromptOption(),
)
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
@@ -470,6 +502,7 @@ type prepWorkspaceBuildArgs struct {
RichParameterDefaults []codersdk.WorkspaceBuildParameter
UseParameterDefaults bool
NonInteractive bool
}
// resolvePreset returns the preset matching the given presetName (if specified),
@@ -573,7 +606,8 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
WithRichParameters(args.RichParameters).
WithRichParametersFile(parameterFile).
WithRichParametersDefaults(args.RichParameterDefaults).
WithUseParameterDefaults(args.UseParameterDefaults)
WithUseParameterDefaults(args.UseParameterDefaults).
WithNonInteractive(args.NonInteractive)
buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters)
if err != nil {
return nil, err
+229 -9
View File
@@ -297,6 +297,117 @@ func TestCreate(t *testing.T) {
assert.Nil(t, ws.AutostartSchedule, "expected workspace autostart schedule to be nil")
}
})
tests := []struct {
// name is the name of the test.
name string
// setup runs before the command is started and returns arguments that
// will be appended to the create command.
setup func(client *codersdk.Client, owner codersdk.CreateFirstUserResponse) []string
// handlePty optionally runs after the command is started. It should handle
// all expected prompts from the pty.
handlePty func(ctx context.Context, pty *ptytest.PTY)
// errors contains expected errors. The workspace will not be verified if
// errors are expected.
errors []string
}{
{
name: "NoWorkspaceNameNonInteractive",
setup: func(_ *codersdk.Client, _ codersdk.CreateFirstUserResponse) []string {
return []string{"--non-interactive"}
},
errors: []string{
"workspace name must be provided",
},
},
{
name: "OneTemplateNonInteractive",
setup: func(_ *codersdk.Client, _ codersdk.CreateFirstUserResponse) []string {
return []string{"my-workspace", "--non-interactive"}
},
handlePty: func(ctx context.Context, pty *ptytest.PTY) {
pty.ExpectMatchContext(ctx, "Using the only available template")
},
},
{
name: "MultipleTemplatesNonInteractive",
setup: func(client *codersdk.Client, owner codersdk.CreateFirstUserResponse) []string {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
_ = coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
return []string{"my-workspace", "--non-interactive"}
},
errors: []string{
"multiple templates available; use --template to specify which to use",
},
},
{
name: "MultipleTemplatesInteractive",
setup: func(client *codersdk.Client, owner codersdk.CreateFirstUserResponse) []string {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
_ = coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
return []string{"my-workspace", "--yes"}
},
handlePty: func(ctx context.Context, pty *ptytest.PTY) {
pty.ExpectMatchContext(ctx, "Select a template below")
pty.WriteLine("") // Select whatever is first.
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Set up the template.
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
_ = coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
// Run the command, after running any additional test setup.
args := []string{"create"}
if tt.setup != nil {
args = append(args, tt.setup(client, owner)...)
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan error)
pty := ptytest.New(t).Attach(inv)
go func() {
doneChan <- inv.Run()
}()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// The test may do something with the pty.
if tt.handlePty != nil {
tt.handlePty(ctx, pty)
}
// Wait for the command to exit.
err := <-doneChan
if len(tt.errors) > 0 {
require.Error(t, err)
for _, errstr := range tt.errors {
require.ErrorContains(t, err, errstr)
}
} else {
require.NoError(t, err)
// Verify the workspace was created.
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{Name: "my-workspace"})
require.NoError(t, err, "expected to find created workspace")
require.Len(t, workspaces.Workspaces, 1)
}
})
}
}
func prepareEchoResponses(parameters []*proto.RichParameter, presets ...*proto.Preset) *echo.Responses {
@@ -319,10 +430,11 @@ func prepareEchoResponses(parameters []*proto.RichParameter, presets ...*proto.P
}
type param struct {
name string
ptype string
value string
mutable bool
name string
ptype string
value string
mutable bool
required bool
}
func TestCreateWithRichParameters(t *testing.T) {
@@ -369,7 +481,7 @@ func TestCreateWithRichParameters(t *testing.T) {
tests := []struct {
name string
// setup runs before the command is started and return arguments that will
// setup runs before the command is started and returns arguments that will
// be appended to the create command.
setup func() []string
// handlePty optionally runs after the command is started. It should handle
@@ -538,7 +650,7 @@ func TestCreateWithRichParameters(t *testing.T) {
// Simply accept the defaults.
for _, param := range params {
pty.ExpectMatch(param.name)
pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`)
pty.ExpectMatch(fmt.Sprintf("Enter a value (default: %q)", param.value))
pty.WriteLine("")
}
// Confirm the creation.
@@ -555,7 +667,7 @@ func TestCreateWithRichParameters(t *testing.T) {
handlePty: func(pty *ptytest.PTY) {
// Default values should get printed.
for _, param := range params {
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
pty.ExpectMatch(fmt.Sprintf("%q: '%s'", param.name, param.value))
}
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
@@ -563,6 +675,19 @@ func TestCreateWithRichParameters(t *testing.T) {
},
withDefaults: true,
},
{
name: "ValuesFromTemplateDefaultsNonInteractive",
setup: func() []string {
return []string{"--non-interactive"}
},
handlePty: func(pty *ptytest.PTY) {
// Default values should get printed.
for _, param := range params {
pty.ExpectMatch(fmt.Sprintf("%q: '%s'", param.name, param.value))
}
},
withDefaults: true,
},
{
name: "ValuesFromDefaultFlagsNoPrompt",
setup: func() []string {
@@ -576,13 +701,45 @@ func TestCreateWithRichParameters(t *testing.T) {
handlePty: func(pty *ptytest.PTY) {
// Default values should get printed.
for _, param := range params {
pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value))
pty.ExpectMatch(fmt.Sprintf("%q: '%s'", param.name, param.value))
}
// No prompts, we only need to confirm.
pty.ExpectMatch("Confirm create?")
pty.WriteLine("yes")
},
},
{
name: "ValuesFromDefaultFlagsNonInteractive",
setup: func() []string {
// Provide the defaults on the command line.
args := []string{"--non-interactive"}
for _, param := range params {
args = append(args, "--parameter-default", fmt.Sprintf("%s=%s", param.name, param.value))
}
return args
},
handlePty: func(pty *ptytest.PTY) {
// Default values should get printed.
for _, param := range params {
pty.ExpectMatch(fmt.Sprintf("%q: '%s'", param.name, param.value))
}
},
},
{
name: "ValuesMissingNonInteractive",
setup: func() []string {
return []string{"--non-interactive"}
},
inputParameters: []param{
{
name: "required_param",
required: true,
},
},
errors: []string{
"parameter \"required_param\" is required and has no default value",
},
},
{
// File and flags should override template defaults. Additionally, if a
// value has no default value we should still get a prompt for it.
@@ -671,6 +828,7 @@ cli_param: from file`)
Name: param.name,
Type: param.ptype,
Mutable: param.mutable,
Required: param.required,
DefaultValue: defaultValue,
Order: int32(i), //nolint:gosec
})
@@ -721,7 +879,7 @@ cli_param: from file`)
if len(tt.errors) > 0 {
require.Error(t, err)
for _, errstr := range tt.errors {
assert.ErrorContains(t, err, errstr)
require.ErrorContains(t, err, errstr)
}
} else {
require.NoError(t, err)
@@ -1027,6 +1185,68 @@ func TestCreateWithPreset(t *testing.T) {
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: thirdParameterName, Value: thirdParameterValue})
})
t.Run("NoDefaultPresetNonInteractive", 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 but no default.
preset := proto.Preset{
Name: "preset-test",
Description: "Preset Test.",
Parameters: []*proto.PresetParameter{
{Name: firstParameterName, Value: secondOptionalParameterValue},
{Name: thirdParameterName, Value: thirdParameterValue},
},
}
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses(&preset))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
// When: running the create command without specifying a preset
workspaceName := "my-workspace"
inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name,
"--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue),
"--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue),
"--non-interactive")
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)
}()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
// Should not prompt the user for the preset.
pty.ExpectMatchContext(ctx, "No preset applied")
<-doneChan
tvPresets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
require.Len(t, tvPresets, 1)
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{Name: workspaceName})
require.NoError(t, err)
require.Len(t, workspaces.Workspaces, 1)
// Should: create a workspace using the expected template version and no preset
workspaceLatestBuild := workspaces.Workspaces[0].LatestBuild
require.Equal(t, version.ID, workspaceLatestBuild.TemplateVersionID)
require.Nil(t, workspaceLatestBuild.TemplateVersionPresetID)
buildParameters, err := client.WorkspaceBuildParameters(ctx, workspaceLatestBuild.ID)
require.NoError(t, err)
require.Len(t, buildParameters, 2)
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: firstParameterName, Value: firstOptionalParameterValue})
require.Contains(t, buildParameters, codersdk.WorkspaceBuildParameter{Name: thirdParameterName, Value: thirdParameterValue})
})
// This test verifies that 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.
+15 -3
View File
@@ -35,6 +35,7 @@ type ParameterResolver struct {
promptRichParameters bool
promptEphemeralParameters bool
useParameterDefaults bool
nonInteractive bool
}
func (pr *ParameterResolver) WithLastBuildParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
@@ -92,6 +93,11 @@ func (pr *ParameterResolver) WithUseParameterDefaults(useParameterDefaults bool)
return pr
}
func (pr *ParameterResolver) WithNonInteractive(nonInteractive bool) *ParameterResolver {
pr.nonInteractive = nonInteractive
return pr
}
// Resolve gathers workspace build parameters in a layered fashion, applying
// values from various sources in order of precedence:
// 1. template defaults (if auto-accepting defaults)
@@ -286,10 +292,16 @@ func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuild
parameterValue = v
}
switch {
// Auto-accept the default if there is one.
if pr.useParameterDefaults && parameterValue != "" {
_, _ = fmt.Fprintf(inv.Stdout, "Using default value for %s: '%s'\n", name, parameterValue)
} else {
case (pr.nonInteractive || pr.useParameterDefaults) && parameterValue != "":
_, _ = fmt.Fprintf(inv.Stdout, "Using default value for %q: '%s'\n", tvp.Name, parameterValue)
case pr.nonInteractive && tvp.Required:
return nil, xerrors.Errorf("parameter %q is required and has no default value", tvp.Name)
case pr.nonInteractive:
_, _ = fmt.Fprintf(inv.Stdout, "Skipping optional parameter %q\n", tvp.Name)
continue
default:
var err error
parameterValue, err = cliui.RichParameter(inv, tvp, name, parameterValue)
if err != nil {
+4
View File
@@ -20,6 +20,10 @@ OPTIONS:
--copy-parameters-from string, $CODER_WORKSPACE_COPY_PARAMETERS_FROM
Specify the source workspace name to copy parameters from.
--non-interactive bool, $CODER_NON_INTERACTIVE
Automatically accept all defaults and error when there is no default
for a required input.
--parameter string-array, $CODER_RICH_PARAMETER
Rich parameter value in the format "name=value".
+9
View File
@@ -92,6 +92,15 @@ Specify the source workspace name to copy parameters from.
Automatically accept parameter defaults when no value is provided.
### --non-interactive
| | |
|-------------|-------------------------------------|
| Type | <code>bool</code> |
| Environment | <code>$CODER_NON_INTERACTIVE</code> |
Automatically accept all defaults and error when there is no default for a required input.
### -y, --yes
| | |
+9
View File
@@ -92,6 +92,15 @@ Specify the source workspace name to copy parameters from.
Automatically accept parameter defaults when no value is provided.
### --non-interactive
| | |
|-------------|-------------------------------------|
| Type | <code>bool</code> |
| Environment | <code>$CODER_NON_INTERACTIVE</code> |
Automatically accept all defaults and error when there is no default for a required input.
### -y, --yes
| | |
@@ -20,6 +20,10 @@ OPTIONS:
--copy-parameters-from string, $CODER_WORKSPACE_COPY_PARAMETERS_FROM
Specify the source workspace name to copy parameters from.
--non-interactive bool, $CODER_NON_INTERACTIVE
Automatically accept all defaults and error when there is no default
for a required input.
--parameter string-array, $CODER_RICH_PARAMETER
Rich parameter value in the format "name=value".