Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions cli/cliui/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ import (
"time"

"github.com/briandowns/spinner"
"golang.org/x/xerrors"

"github.com/coder/coder/v2/codersdk"
)

type ExternalAuthOptions struct {
Fetch func(context.Context) ([]codersdk.TemplateVersionExternalAuth, error)
FetchInterval time.Duration
// NonInteractive, when true, will cause the function to fail immediately
// if required external authentication is not already complete, rather than
// waiting for the user to authenticate via browser.
NonInteractive bool
}

func ExternalAuth(ctx context.Context, writer io.Writer, opts ExternalAuthOptions) error {
Expand Down Expand Up @@ -41,6 +46,14 @@ func ExternalAuth(ctx context.Context, writer io.Writer, opts ExternalAuthOption
continue
}

// In non-interactive mode, fail immediately if required auth is missing.
if opts.NonInteractive {
return xerrors.Errorf(
"external authentication with %q is required but not authenticated; "+
"authenticate via browser first or ensure the template marks it as optional",
auth.DisplayName)
}

_, _ = fmt.Fprintf(writer, "You must authenticate with %s to create a workspace with this template. Visit:\n\n\t%s\n\n", auth.DisplayName, auth.AuthenticateURL)

ticker.Reset(opts.FetchInterval)
Expand Down
7 changes: 4 additions & 3 deletions cli/cliui/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ type PromptOptions struct {

const skipPromptFlag = "yes"

// SkipPromptOption adds a "--yes/-y" flag to the cmd that can be used to skip
// prompts.
// SkipPromptOption adds a "--yes/-y" flag to the cmd that can be used to run
// in non-interactive mode. When enabled, prompts are skipped, default values
// are used where available, and the command fails if required inputs are missing.
func SkipPromptOption() serpent.Option {
return serpent.Option{
Flag: skipPromptFlag,
FlagShorthand: "y",
Description: "Bypass prompts.",
Description: "Run in non-interactive mode. Accepts default values and fails on required inputs.",
// Discard
Value: serpent.BoolOf(new(bool)),
}
Expand Down
149 changes: 96 additions & 53 deletions cli/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ const PresetNone = "none"

var ErrNoPresetFound = xerrors.New("no preset found")

// isNonInteractive checks if the command is running in non-interactive mode
// (i.e., the --yes/-y flag was provided).
func isNonInteractive(inv *serpent.Invocation) bool {
Copy link
Member

Choose a reason for hiding this comment

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

This should probably go into the cliui package beside the SkipPromptOption function

if inv.ParsedFlags().Lookup("yes") != nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

not sure if that’s a pattern or not, but can we not do a double look up of string “yes”, there’s gotta be a better way to handle this

if skip, _ := inv.ParsedFlags().GetBool("yes"); skip {
return true
}
}
return false
}

type CreateOptions struct {
BeforeCreate func(ctx context.Context, client *codersdk.Client, template codersdk.Template, templateVersionID uuid.UUID) error
AfterCreate func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error
Expand Down Expand Up @@ -75,6 +86,9 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
}

if workspaceName == "" {
if isNonInteractive(inv) {
return xerrors.New("workspace name is required in non-interactive mode; provide it as an argument")
}
workspaceName, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Specify a name for your workspace:",
Validate: func(workspaceName string) error {
Expand Down Expand Up @@ -122,62 +136,76 @@ 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
}

slices.SortFunc(templates, func(a, b codersdk.Template) int {
return slice.Descending(a.ActiveUserCount, b.ActiveUserCount)
})

templateNames := make([]string, 0, len(templates))
templateByName := make(map[string]codersdk.Template, len(templates))
// In non-interactive mode, auto-select if only one template exists,
// otherwise require explicit --template flag.
if isNonInteractive(inv) {
if len(templates) == 0 {
return xerrors.New("no templates available")
}
if len(templates) > 1 {
return xerrors.New("multiple templates available; use the --template flag to specify which one")
}
// Only one template available - auto-select it
template = templates[0]
templateVersionID = template.ActiveVersionID
} else {
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:"))

slices.SortFunc(templates, func(a, b codersdk.Template) int {
return slice.Descending(a.ActiveUserCount, b.ActiveUserCount)
})

// If more than 1 organization exists in the list of templates,
// then include the organization name in the select options.
uniqueOrganizations := make(map[uuid.UUID]bool)
for _, template := range templates {
uniqueOrganizations[template.OrganizationID] = true
}
templateNames := make([]string, 0, len(templates))
templateByName := make(map[string]codersdk.Template, len(templates))

for _, template := range templates {
templateName := template.Name
if len(uniqueOrganizations) > 1 {
templateName += cliui.Placeholder(
fmt.Sprintf(
" (%s)",
template.OrganizationName,
),
)
// If more than 1 organization exists in the list of templates,
// then include the organization name in the select options.
uniqueOrganizations := make(map[uuid.UUID]bool)
for _, tpl := range templates {
uniqueOrganizations[tpl.OrganizationID] = true
}

if template.ActiveUserCount > 0 {
templateName += cliui.Placeholder(
fmt.Sprintf(
" used by %s",
formatActiveDevelopers(template.ActiveUserCount),
),
)
for _, tpl := range templates {
tplName := tpl.Name
if len(uniqueOrganizations) > 1 {
tplName += cliui.Placeholder(
fmt.Sprintf(
" (%s)",
tpl.OrganizationName,
),
)
}

if tpl.ActiveUserCount > 0 {
tplName += cliui.Placeholder(
fmt.Sprintf(
" used by %s",
formatActiveDevelopers(tpl.ActiveUserCount),
),
)
}

templateNames = append(templateNames, tplName)
templateByName[tplName] = tpl
}

templateNames = append(templateNames, templateName)
templateByName[templateName] = template
}
// Move the cursor up a single line for nicer display!
option, err := cliui.Select(inv, cliui.SelectOptions{
Options: templateNames,
HideSearch: true,
})
if err != nil {
return err
}

// Move the cursor up a single line for nicer display!
option, err := cliui.Select(inv, cliui.SelectOptions{
Options: templateNames,
HideSearch: true,
})
if err != nil {
return err
template = templateByName[option]
templateVersionID = template.ActiveVersionID
}

template = templateByName[option]
templateVersionID = template.ActiveVersionID
case sourceWorkspace.LatestBuild.TemplateVersionID != uuid.Nil:
template, err = client.Template(inv.Context(), sourceWorkspace.TemplateID)
if err != nil {
Expand Down Expand Up @@ -297,19 +325,28 @@ 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)
// No preset found - in non-interactive mode, skip presets instead of prompting
if isNonInteractive(inv) {
// Leave preset as nil, effectively skipping presets
preset = nil
} else {
// Interactive mode - 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 {
// Convert preset parameters into workspace build parameters (if a preset was selected)
if preset != nil {
presetParameters = presetParameterAsWorkspaceBuildParameters(preset.Parameters)
// Inform the user which preset was applied and its parameters
displayAppliedPreset(inv, preset, presetParameters)
}
}
if preset == nil {
// Inform the user that no preset was applied
_, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied."))
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", cliui.Bold("No preset applied."))
}

if opts.BeforeCreate != nil {
Expand All @@ -330,6 +367,8 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
RichParameterDefaults: cliBuildParameterDefaults,

SourceWorkspaceParameters: sourceWorkspaceParameters,

NonInteractive: isNonInteractive(inv),
})
if err != nil {
return xerrors.Errorf("prepare build: %w", err)
Expand Down Expand Up @@ -460,6 +499,8 @@ type prepWorkspaceBuildArgs struct {
RichParameters []codersdk.WorkspaceBuildParameter
RichParameterFile string
RichParameterDefaults []codersdk.WorkspaceBuildParameter

NonInteractive bool
}

// resolvePreset returns the preset matching the given presetName (if specified),
Expand Down Expand Up @@ -562,7 +603,8 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
WithPromptRichParameters(args.PromptRichParameters).
WithRichParameters(args.RichParameters).
WithRichParametersFile(parameterFile).
WithRichParametersDefaults(args.RichParameterDefaults)
WithRichParametersDefaults(args.RichParameterDefaults).
WithNonInteractive(args.NonInteractive)
buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters)
if err != nil {
return nil, err
Expand All @@ -572,6 +614,7 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
Fetch: func(ctx context.Context) ([]codersdk.TemplateVersionExternalAuth, error) {
return client.TemplateVersionExternalAuth(ctx, templateVersion.ID)
},
NonInteractive: args.NonInteractive,
})
if err != nil {
return nil, xerrors.Errorf("template version git auth: %w", err)
Expand Down
33 changes: 30 additions & 3 deletions cli/parameterresolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type ParameterResolver struct {

promptRichParameters bool
promptEphemeralParameters bool
nonInteractive bool
}

func (pr *ParameterResolver) WithLastBuildParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
Expand Down Expand Up @@ -86,6 +87,11 @@ func (pr *ParameterResolver) WithPromptEphemeralParameters(promptEphemeralParame
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: 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) {
Expand Down Expand Up @@ -250,18 +256,39 @@ func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuild
if p != nil {
continue
}
// PreviewParameter has not been resolved yet, so CLI needs to determine if user should input it.
// Parameter has not been resolved yet, so CLI needs to determine if user should input it.

firstTimeUse := pr.isFirstTimeUse(tvp.Name)
promptParameterOption := pr.isLastBuildParameterInvalidOption(tvp)

if (tvp.Ephemeral && pr.promptEphemeralParameters) ||
needsInput := (tvp.Ephemeral && pr.promptEphemeralParameters) ||
(action == WorkspaceCreate && tvp.Required) ||
(action == WorkspaceCreate && !tvp.Ephemeral) ||
(action == WorkspaceUpdate && promptParameterOption) ||
(action == WorkspaceUpdate && tvp.Mutable && tvp.Required) ||
(action == WorkspaceUpdate && !tvp.Mutable && firstTimeUse) ||
(tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) {
(tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters)

if needsInput {
// In non-interactive mode, use default values or fail if required without default.
if pr.nonInteractive {
if tvp.DefaultValue != "" {
// Use default value
resolved = append(resolved, codersdk.WorkspaceBuildParameter{
Name: tvp.Name,
Value: tvp.DefaultValue,
})
} else if tvp.Required {
// Required parameter with no default - fail
return nil, xerrors.Errorf(
"parameter %q is required but has no default value; provide it with --parameter %s=<value>",
tvp.Name, tvp.Name)
}
// Optional parameter with no default - skip (will use empty/server default)
continue
}

// Interactive mode - prompt user for input
parameterValue, err := cliui.RichParameter(inv, tvp, pr.richParametersDefaults)
if err != nil {
return nil, err
Expand Down
3 changes: 2 additions & 1 deletion cli/testdata/coder_autoupdate_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ USAGE:

OPTIONS:
-y, --yes bool
Bypass prompts.
Run in non-interactive mode. Accepts default values and fails on
required inputs.

———
Run `coder --help` for a list of global options.
3 changes: 2 additions & 1 deletion cli/testdata/coder_config-ssh_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ OPTIONS:
configured in the workspace template is used.

-y, --yes bool
Bypass prompts.
Run in non-interactive mode. Accepts default values and fails on
required inputs.

———
Run `coder --help` for a list of global options.
3 changes: 2 additions & 1 deletion cli/testdata/coder_create_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ OPTIONS:
Specify a template version name.

-y, --yes bool
Bypass prompts.
Run in non-interactive mode. Accepts default values and fails on
required inputs.

———
Run `coder --help` for a list of global options.
3 changes: 2 additions & 1 deletion cli/testdata/coder_delete_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ OPTIONS:
resources.

-y, --yes bool
Bypass prompts.
Run in non-interactive mode. Accepts default values and fails on
required inputs.

———
Run `coder --help` for a list of global options.
3 changes: 2 additions & 1 deletion cli/testdata/coder_dotfiles_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ OPTIONS:
empty, will use $HOME.

-y, --yes bool
Bypass prompts.
Run in non-interactive mode. Accepts default values and fails on
required inputs.

———
Run `coder --help` for a list of global options.
3 changes: 2 additions & 1 deletion cli/testdata/coder_logout_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ USAGE:

OPTIONS:
-y, --yes bool
Bypass prompts.
Run in non-interactive mode. Accepts default values and fails on
required inputs.

———
Run `coder --help` for a list of global options.
Loading
Loading