mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
02b123518c
## Problem
The CLI does not honor `default` values on template parameters in two
ways:
1. **`--use-parameter-defaults` rejects empty-string defaults.** The
check `parameterValue != ""` means `default = ""` in Terraform falls
through to an interactive prompt. In CI this causes an EOF error.
2. **`--use-parameter-defaults` only exists on `coder create`.** The
`start`, `update`, and `restart` commands never wire it through. SSH
auto-start passes empty `workspaceParameterFlags{}`, so users SSH-ing
into a stopped workspace with new template parameters get stuck in an
interactive prompt they cannot complete.
## Fix
### 1. Fix empty-string default detection and expose flag on all
commands
Replace `parameterValue != ""` with a check based on `!tvp.Required`. A
parameter with `Required==false` always has a valid default in
Terraform, even if that default is `""`. Also respect CLI defaults
provided via `--parameter-default`.
Move `--use-parameter-defaults` from a standalone option on `create`
into the shared `workspaceParameterFlags` struct. This exposes the flag
(and `CODER_WORKSPACE_USE_PARAMETER_DEFAULTS`) on `start`, `update`, and
`restart` via `allOptions()`. Wire it through
`buildWorkspaceStartRequest` so the resolver receives it.
### 2. SSH auto-start always uses defaults
Set `useParameterDefaults: true` on both `startWorkspace` calls in the
SSH auto-start path (initial start and the forbidden/upgrade fallback).
SSH is non-interactive and should never prompt.
Fixes https://linear.app/codercom/issue/DEVEX-180
Fixes https://github.com/coder/coder/issues/22272
<details><summary>Implementation notes</summary>
### Scoping decisions
- **`--yes` does not imply `--use-parameter-defaults`**: Making `--yes`
auto-accept defaults exposes a validation gap in the dynamic parameter
path (client-side validation happens during prompting, and skipping
prompts bypasses it). This is deferred to a follow-up that also
addresses `codersdk.ValidateWorkspaceBuildParameter` integration in the
resolver. Tracked in PLAT-114.
- **Explicit overrides always win**: `--parameter`,
`--rich-parameter-file`, and `--preset` are resolved in stages 1-5 of
the resolver, before `resolveWithInput` runs. No change needed for
precedence.
- **`!tvp.Required` vs `parameterValue != ""`**: The `Required` field is
set by the Terraform provider based on whether a `default` is present.
This is the canonical signal for "has a default," not the string value
itself.
</details>
> Generated with [Coder Agents](https://coder.com/agents)
447 lines
14 KiB
Go
447 lines
14 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/cli/cliui"
|
|
"github.com/coder/coder/v2/cli/cliutil/levenshtein"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/pretty"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
type WorkspaceCLIAction int
|
|
|
|
const (
|
|
WorkspaceCreate WorkspaceCLIAction = iota
|
|
WorkspaceStart
|
|
WorkspaceUpdate
|
|
WorkspaceRestart
|
|
)
|
|
|
|
type ParameterResolver struct {
|
|
lastBuildParameters []codersdk.WorkspaceBuildParameter
|
|
sourceWorkspaceParameters []codersdk.WorkspaceBuildParameter
|
|
|
|
presetParameters []codersdk.WorkspaceBuildParameter
|
|
richParameters []codersdk.WorkspaceBuildParameter
|
|
richParametersDefaults map[string]string
|
|
richParametersFile map[string]string
|
|
ephemeralParameters []codersdk.WorkspaceBuildParameter
|
|
|
|
promptRichParameters bool
|
|
promptEphemeralParameters bool
|
|
useParameterDefaults bool
|
|
}
|
|
|
|
func (pr *ParameterResolver) WithLastBuildParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
|
|
pr.lastBuildParameters = params
|
|
return pr
|
|
}
|
|
|
|
func (pr *ParameterResolver) WithSourceWorkspaceParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
|
|
pr.sourceWorkspaceParameters = params
|
|
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
|
|
}
|
|
|
|
func (pr *ParameterResolver) WithEphemeralParameters(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
|
|
pr.ephemeralParameters = params
|
|
return pr
|
|
}
|
|
|
|
func (pr *ParameterResolver) WithRichParametersFile(fileMap map[string]string) *ParameterResolver {
|
|
pr.richParametersFile = fileMap
|
|
return pr
|
|
}
|
|
|
|
func (pr *ParameterResolver) WithRichParametersDefaults(params []codersdk.WorkspaceBuildParameter) *ParameterResolver {
|
|
if pr.richParametersDefaults == nil {
|
|
pr.richParametersDefaults = make(map[string]string)
|
|
}
|
|
for _, p := range params {
|
|
pr.richParametersDefaults[p.Name] = p.Value
|
|
}
|
|
return pr
|
|
}
|
|
|
|
func (pr *ParameterResolver) WithPromptRichParameters(promptRichParameters bool) *ParameterResolver {
|
|
pr.promptRichParameters = promptRichParameters
|
|
return pr
|
|
}
|
|
|
|
func (pr *ParameterResolver) WithPromptEphemeralParameters(promptEphemeralParameters bool) *ParameterResolver {
|
|
pr.promptEphemeralParameters = promptEphemeralParameters
|
|
return pr
|
|
}
|
|
|
|
func (pr *ParameterResolver) WithUseParameterDefaults(useParameterDefaults bool) *ParameterResolver {
|
|
pr.useParameterDefaults = useParameterDefaults
|
|
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)
|
|
// 2. cli parameter defaults (if auto-accepting defaults)
|
|
// 3. parameter file
|
|
// 4. CLI/ENV
|
|
// 5. source build
|
|
// 6. last build
|
|
// 7. preset
|
|
// 8. user input (unless auto-accepting defaults)
|
|
func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) {
|
|
var staged []codersdk.WorkspaceBuildParameter
|
|
var err error
|
|
|
|
staged = pr.resolveWithParametersMapFile(staged)
|
|
staged = pr.resolveWithCommandLineOrEnv(staged)
|
|
staged = pr.resolveWithSourceBuildParametersInParameters(staged, templateVersionParameters)
|
|
staged = pr.resolveWithLastBuildParametersInParameters(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
|
|
}
|
|
if staged, err = pr.resolveWithInput(staged, inv, action, templateVersionParameters); err != nil {
|
|
return nil, err
|
|
}
|
|
return staged, nil
|
|
}
|
|
|
|
func (pr *ParameterResolver) InitialValues() []codersdk.WorkspaceBuildParameter {
|
|
var staged []codersdk.WorkspaceBuildParameter
|
|
|
|
staged = pr.resolveWithParametersMapFile(staged)
|
|
staged = pr.resolveWithCommandLineOrEnv(staged)
|
|
staged = pr.resolveWithSourceBuildParameters(staged)
|
|
staged = pr.resolveWithLastBuildParameters(staged)
|
|
staged = pr.resolveWithPreset(staged) // Preset parameters take precedence from all other parameters
|
|
|
|
return staged
|
|
}
|
|
|
|
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 {
|
|
for i, r := range resolved {
|
|
if r.Name == name {
|
|
resolved[i].Value = value
|
|
continue next
|
|
}
|
|
}
|
|
|
|
resolved = append(resolved, codersdk.WorkspaceBuildParameter{
|
|
Name: name,
|
|
Value: value,
|
|
})
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func (pr *ParameterResolver) resolveWithCommandLineOrEnv(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
|
|
nextRichParameter:
|
|
for _, richParameter := range pr.richParameters {
|
|
for i, r := range resolved {
|
|
if r.Name == richParameter.Name {
|
|
resolved[i].Value = richParameter.Value
|
|
continue nextRichParameter
|
|
}
|
|
}
|
|
|
|
resolved = append(resolved, richParameter)
|
|
}
|
|
|
|
nextEphemeralParameter:
|
|
for _, ephemeralParameter := range pr.ephemeralParameters {
|
|
for i, r := range resolved {
|
|
if r.Name == ephemeralParameter.Name {
|
|
resolved[i].Value = ephemeralParameter.Value
|
|
continue nextEphemeralParameter
|
|
}
|
|
}
|
|
|
|
resolved = append(resolved, ephemeralParameter)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func (pr *ParameterResolver) resolveWithLastBuildParameters(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
|
|
if pr.promptRichParameters {
|
|
return resolved // don't pull parameters from last build
|
|
}
|
|
|
|
next:
|
|
for _, buildParameter := range pr.lastBuildParameters {
|
|
for i, r := range resolved {
|
|
if r.Name == buildParameter.Name {
|
|
resolved[i].Value = buildParameter.Value
|
|
continue next
|
|
}
|
|
}
|
|
|
|
resolved = append(resolved, buildParameter)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func (pr *ParameterResolver) resolveWithLastBuildParametersInParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter {
|
|
if pr.promptRichParameters {
|
|
return resolved // don't pull parameters from last build
|
|
}
|
|
|
|
next:
|
|
for _, buildParameter := range pr.lastBuildParameters {
|
|
tvp := findTemplateVersionParameter(buildParameter, templateVersionParameters)
|
|
if tvp == nil {
|
|
continue // it looks like this parameter is not present anymore
|
|
}
|
|
|
|
if tvp.Ephemeral {
|
|
continue // ephemeral parameters should not be passed to consecutive builds
|
|
}
|
|
|
|
if !tvp.Mutable {
|
|
continue // immutables should not be passed to consecutive builds
|
|
}
|
|
|
|
if len(tvp.Options) > 0 && !isValidTemplateParameterOption(buildParameter, *tvp) {
|
|
continue // do not propagate invalid options
|
|
}
|
|
|
|
for i, r := range resolved {
|
|
if r.Name == buildParameter.Name {
|
|
resolved[i].Value = buildParameter.Value
|
|
continue next
|
|
}
|
|
}
|
|
|
|
resolved = append(resolved, buildParameter)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func (pr *ParameterResolver) resolveWithSourceBuildParameters(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
|
|
next:
|
|
for _, buildParameter := range pr.sourceWorkspaceParameters {
|
|
for i, r := range resolved {
|
|
if r.Name == buildParameter.Name {
|
|
resolved[i].Value = buildParameter.Value
|
|
continue next
|
|
}
|
|
}
|
|
|
|
resolved = append(resolved, buildParameter)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func (pr *ParameterResolver) resolveWithSourceBuildParametersInParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter {
|
|
next:
|
|
for _, buildParameter := range pr.sourceWorkspaceParameters {
|
|
tvp := findTemplateVersionParameter(buildParameter, templateVersionParameters)
|
|
if tvp == nil {
|
|
continue // it looks like this parameter is not present anymore
|
|
}
|
|
|
|
if tvp.Ephemeral {
|
|
continue // ephemeral parameters should not be passed to consecutive builds
|
|
}
|
|
|
|
for i, r := range resolved {
|
|
if r.Name == buildParameter.Name {
|
|
resolved[i].Value = buildParameter.Value
|
|
continue next
|
|
}
|
|
}
|
|
|
|
resolved = append(resolved, buildParameter)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func (pr *ParameterResolver) verifyConstraints(resolved []codersdk.WorkspaceBuildParameter, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) error {
|
|
for _, r := range resolved {
|
|
tvp := findTemplateVersionParameter(r, templateVersionParameters)
|
|
if tvp == nil {
|
|
return templateVersionParametersNotFound(r.Name, templateVersionParameters)
|
|
}
|
|
|
|
if tvp.Ephemeral && !pr.promptEphemeralParameters && findWorkspaceBuildParameter(tvp.Name, pr.ephemeralParameters) == nil {
|
|
return xerrors.Errorf("ephemeral parameter %q can be used only with --prompt-ephemeral-parameters or --ephemeral-parameter flag", r.Name)
|
|
}
|
|
|
|
if !tvp.Mutable && action != WorkspaceCreate && !pr.isFirstTimeUse(r.Name) {
|
|
return xerrors.Errorf("parameter %q is immutable and cannot be updated", r.Name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuildParameter, inv *serpent.Invocation, action WorkspaceCLIAction, templateVersionParameters []codersdk.TemplateVersionParameter) ([]codersdk.WorkspaceBuildParameter, error) {
|
|
for _, tvp := range templateVersionParameters {
|
|
p := findWorkspaceBuildParameter(tvp.Name, resolved)
|
|
if p != nil {
|
|
continue
|
|
}
|
|
// PreviewParameter 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) ||
|
|
(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) {
|
|
name := tvp.Name
|
|
if tvp.DisplayName != "" {
|
|
name = tvp.DisplayName
|
|
}
|
|
|
|
parameterValue := tvp.DefaultValue
|
|
cliDefault, cliDefaultProvided := pr.richParametersDefaults[tvp.Name]
|
|
if cliDefaultProvided {
|
|
parameterValue = cliDefault
|
|
}
|
|
|
|
// Auto-accept the default value when one exists.
|
|
// A parameter has a usable default if a CLI
|
|
// default was provided via --parameter-default, or
|
|
// the template parameter is not required (meaning
|
|
// a default was set in Terraform, even if it is
|
|
// an empty string).
|
|
hasDefault := cliDefaultProvided || !tvp.Required
|
|
if pr.useParameterDefaults && hasDefault {
|
|
_, _ = fmt.Fprintf(inv.Stdout, "Using default value for %s: '%s'\n", name, parameterValue)
|
|
} else {
|
|
var err error
|
|
parameterValue, err = cliui.RichParameter(inv, tvp, name, parameterValue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
resolved = append(resolved, codersdk.WorkspaceBuildParameter{
|
|
Name: tvp.Name,
|
|
Value: parameterValue,
|
|
})
|
|
} else if action == WorkspaceUpdate && !tvp.Mutable && !firstTimeUse {
|
|
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Warn, fmt.Sprintf("Parameter %q is not mutable, and cannot be customized after workspace creation.", tvp.Name)))
|
|
}
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func (pr *ParameterResolver) isFirstTimeUse(parameterName string) bool {
|
|
return findWorkspaceBuildParameter(parameterName, pr.lastBuildParameters) == nil
|
|
}
|
|
|
|
func (pr *ParameterResolver) isLastBuildParameterInvalidOption(templateVersionParameter codersdk.TemplateVersionParameter) bool {
|
|
if len(templateVersionParameter.Options) == 0 {
|
|
return false
|
|
}
|
|
|
|
for _, buildParameter := range pr.lastBuildParameters {
|
|
if buildParameter.Name == templateVersionParameter.Name {
|
|
return !isValidTemplateParameterOption(buildParameter, templateVersionParameter)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func findTemplateVersionParameter(workspaceBuildParameter codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) *codersdk.TemplateVersionParameter {
|
|
for _, tvp := range templateVersionParameters {
|
|
if tvp.Name == workspaceBuildParameter.Name {
|
|
return &tvp
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findWorkspaceBuildParameter(parameterName string, params []codersdk.WorkspaceBuildParameter) *codersdk.WorkspaceBuildParameter {
|
|
for _, p := range params {
|
|
if p.Name == parameterName {
|
|
return &p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isValidTemplateParameterOption(buildParameter codersdk.WorkspaceBuildParameter, templateVersionParameter codersdk.TemplateVersionParameter) bool {
|
|
// Multi-select parameters store values as a JSON array (e.g.
|
|
// '["vim","emacs"]'), so we need to parse the array and validate
|
|
// each element individually against the allowed options.
|
|
if templateVersionParameter.Type == "list(string)" {
|
|
var values []string
|
|
if err := json.Unmarshal([]byte(buildParameter.Value), &values); err != nil {
|
|
return false
|
|
}
|
|
for _, v := range values {
|
|
found := false
|
|
for _, opt := range templateVersionParameter.Options {
|
|
if opt.Value == v {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
for _, opt := range templateVersionParameter.Options {
|
|
if opt.Value == buildParameter.Value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func templateVersionParametersNotFound(unknown string, params []codersdk.TemplateVersionParameter) error {
|
|
var sb strings.Builder
|
|
_, _ = sb.WriteString(fmt.Sprintf("parameter %q is not present in the template.", unknown))
|
|
// Going with a fairly generous edit distance
|
|
maxDist := len(unknown) / 2
|
|
var paramNames []string
|
|
for _, p := range params {
|
|
paramNames = append(paramNames, p.Name)
|
|
}
|
|
matches := levenshtein.Matches(unknown, maxDist, paramNames...)
|
|
if len(matches) > 0 {
|
|
_, _ = sb.WriteString(fmt.Sprintf("\nDid you mean: %s", strings.Join(matches, ", ")))
|
|
}
|
|
return xerrors.Errorf(sb.String())
|
|
}
|