feat(cli): prompt for missing required template variables on template creation (#19973)

## Summary

In this pull request we're adding support in the CLI for prompting the
user for any missing required template variables in the `coder templates
push` command and automatically retrying the template build once a user
has provided any missing variable values.

Closes: https://github.com/coder/coder/issues/19782

### Demo

In the following recording I created a simple template terraform file
that used different variable types (string, number, boolean, and
sensitive) and prompted the user to enter a value for each variable.

<details>
<summary>See example template terraform file</summary>

```tf
...

# Required variables for testing interactive prompting
variable "docker_image" {
  description = "Docker image to use for the workspace"
  type        = string
}

variable "workspace_name" {
  description = "Name of the workspace"
  type        = string
}

variable "cpu_limit" {
  description = "CPU limit for the container (number of cores)"
  type        = number
}

variable "enable_gpu" {
  description = "Enable GPU access for the container"
  type        = bool
}

variable "api_key" {
  description = "API key for external services (sensitive)"
  type        = string
  sensitive   = true
}

# Optional variable with default
variable "docker_socket" {
  default     = "/var/run/docker.sock"
  description = "Docker socket path"
  type        = string
}

...
```

</details>

Once the user entered a valid value for each variable, the template
build would be retried.


https://github.com/user-attachments/assets/770cf954-3cbc-4464-925e-2be4e32a97de

<details>
<summary>See output from recording</summary>

```shell
$ ./scripts/coder-dev.sh templates push test-required-params -d examples/templates/test-required-params/
INFO : Overriding codersdk.SessionTokenCookie as we are developing inside a Coder workspace.
/home/coder/coder/build/coder-slim_2.26.0-devel+a68122ca3_linux_amd64
Provisioner tags:  <none>
WARN: No .terraform.lock.hcl file found
  | When provisioning, Coder will be unable to cache providers without a lockfile and must download them from the internet each time.
  | Create one by running  terraform init  in your template directory.
> Upload "examples/templates/test-required-params"? (yes/no) yes
=== ✔ Queued [0ms]
==> ⧗ Running
==> ⧗ Running
=== ✔ Running [4ms]
==> ⧗ Setting up
=== ✔ Setting up [0ms]
==> ⧗ Parsing template parameters
=== ✔ Parsing template parameters [8ms]
==> ⧗ Cleaning Up
=== ✘ Cleaning Up [4ms]
=== ✘ Cleaning Up [8ms]
Found 5 missing required variables:
  - docker_image (string): Docker image to use for the workspace
  - workspace_name (string): Name of the workspace
  - cpu_limit (number): CPU limit for the container (number of cores)
  - enable_gpu (bool): Enable GPU access for the container
  - api_key (string): API key for external services (sensitive)

The template requires values for the following variables:
var.docker_image (required)
  Description: Docker image to use for the workspace
  Type: string
  Current value: <empty>
> Enter value: image-name
var.workspace_name (required)
  Description: Name of the workspace
  Type: string
  Current value: <empty>
> Enter value: workspace-name
var.cpu_limit (required)
  Description: CPU limit for the container (number of cores)
  Type: number
  Current value: <empty>
> Enter value: 1
var.enable_gpu (required)
  Description: Enable GPU access for the container
  Type: bool
  Current value: <empty>
? Select value: false
var.api_key (required), sensitive
  Description: API key for external services (sensitive)
  Type: string
  Current value: <empty>
> Enter value: (*redacted*) ******

Retrying template build with provided variables...
=== ✔ Queued [0ms]
==> ⧗ Running
==> ⧗ Running
=== ✔ Running [2ms]
==> ⧗ Setting up
=== ✔ Setting up [0ms]
==> ⧗ Parsing template parameters
=== ✔ Parsing template parameters [7ms]
==> ⧗ Detecting persistent resources
2025-09-25 22:34:14.731Z Terraform 1.13.0
2025-09-25 22:34:15.140Z data.coder_provisioner.me: Refreshing...
2025-09-25 22:34:15.140Z data.coder_workspace.me: Refreshing...
2025-09-25 22:34:15.140Z data.coder_workspace_owner.me: Refreshing...
2025-09-25 22:34:15.141Z data.coder_provisioner.me: Refresh complete after 0s [id=2bd73098-d127-4362-b3a5-628e5bce6998]
2025-09-25 22:34:15.141Z data.coder_workspace_owner.me: Refresh complete after 0s [id=c2006933-4f3e-4c45-9e04-79612c3a5eca]
2025-09-25 22:34:15.141Z data.coder_workspace.me: Refresh complete after 0s [id=36f2dc6f-0bf2-43bd-bc4d-b29768334e02]
2025-09-25 22:34:15.186Z coder_agent.main: Plan to create
2025-09-25 22:34:15.186Z module.code-server[0].coder_app.code-server: Plan to create
2025-09-25 22:34:15.186Z docker_volume.home_volume: Plan to create
2025-09-25 22:34:15.186Z module.code-server[0].coder_script.code-server: Plan to create
2025-09-25 22:34:15.187Z docker_container.workspace[0]: Plan to create
2025-09-25 22:34:15.187Z Plan: 5 to add, 0 to change, 0 to destroy.
=== ✔ Detecting persistent resources [3104ms]
==> ⧗ Detecting ephemeral resources
2025-09-25 22:34:16.033Z Terraform 1.13.0
2025-09-25 22:34:16.428Z data.coder_workspace.me: Refreshing...
2025-09-25 22:34:16.428Z data.coder_provisioner.me: Refreshing...
2025-09-25 22:34:16.429Z data.coder_workspace_owner.me: Refreshing...
2025-09-25 22:34:16.429Z data.coder_provisioner.me: Refresh complete after 0s [id=2d2f7083-88e6-425c-9df3-856a3bf4cc73]
2025-09-25 22:34:16.429Z data.coder_workspace.me: Refresh complete after 0s [id=c723575e-c7d3-43d7-bf54-0e34d0959dc3]
2025-09-25 22:34:16.431Z data.coder_workspace_owner.me: Refresh complete after 0s [id=d43470c2-236e-4ae9-a977-6b53688c2cb1]
2025-09-25 22:34:16.453Z coder_agent.main: Plan to create
2025-09-25 22:34:16.453Z docker_volume.home_volume: Plan to create
2025-09-25 22:34:16.454Z Plan: 2 to add, 0 to change, 0 to destroy.
=== ✔ Detecting ephemeral resources [1278ms]
==> ⧗ Cleaning Up
=== ✔ Cleaning Up [6ms]
┌──────────────────────────────────┐
│ Template Preview                 │
├──────────────────────────────────┤
│ RESOURCE                         │
├──────────────────────────────────┤
│ docker_container.workspace       │
│ └─ main (linux, amd64)           │
├──────────────────────────────────┤
│ docker_volume.home_volume        │
└──────────────────────────────────┘

The test-required-params template has been created at Sep 25
22:34:16! Developers can provision a workspace with this template using:

Updated version at Sep 25 22:34:16!
```

</details>

### Changes

- Added a new function to check if the provisioner failed due to a
template missing required variables
- Added a handler function that is called when a provisioner fails due
to the "missing required variables" error. The handler function will:
- Check for provided template variables and identify any missing
variables
- Prompt the user for any missing variables (prompt is adapted based on
the variable type)
  - Validate user input for missing variables
- Retry the template build when all variables have been provided by the
user

### Testing

Added tests for the following scenarios:

- Ensure validation based on variable type
- Ensure users are not prompted for variables with a default value
- Ensure variables provided via a variables files (`--variables-file`)
or a variable flag (`--variable`) take precedence over a template
This commit is contained in:
Rafael Rodriguez
2025-10-03 10:20:06 -05:00
committed by GitHub
parent a360785199
commit bb5884467d
4 changed files with 400 additions and 53 deletions
+158 -3
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
@@ -461,10 +462,14 @@ func createValidTemplateVersion(inv *serpent.Invocation, args createValidTemplat
})
if err != nil {
var jobErr *cliui.ProvisionerJobError
if errors.As(err, &jobErr) && !codersdk.JobIsMissingParameterErrorCode(jobErr.Code) {
return nil, err
if errors.As(err, &jobErr) {
if codersdk.JobIsMissingRequiredTemplateVariableErrorCode(jobErr.Code) {
return handleMissingTemplateVariables(inv, args, version.ID)
}
if !codersdk.JobIsMissingParameterErrorCode(jobErr.Code) {
return nil, err
}
}
return nil, err
}
version, err = client.TemplateVersion(inv.Context(), version.ID)
@@ -528,3 +533,153 @@ func prettyDirectoryPath(dir string) string {
}
return prettyDir
}
func handleMissingTemplateVariables(inv *serpent.Invocation, args createValidTemplateVersionArgs, failedVersionID uuid.UUID) (*codersdk.TemplateVersion, error) {
client := args.Client
templateVariables, err := client.TemplateVersionVariables(inv.Context(), failedVersionID)
if err != nil {
return nil, xerrors.Errorf("fetch template variables: %w", err)
}
existingValues := make(map[string]string)
for _, v := range args.UserVariableValues {
existingValues[v.Name] = v.Value
}
var missingVariables []codersdk.TemplateVersionVariable
for _, variable := range templateVariables {
if !variable.Required {
continue
}
if existingValue, exists := existingValues[variable.Name]; exists && existingValue != "" {
continue
}
// Only prompt for variables that don't have a default value or have a redacted default
// Sensitive variables have a default value of "*redacted*"
// See: https://github.com/coder/coder/blob/a78790c632974e04babfef6de0e2ddf044787a7a/coderd/provisionerdserver/provisionerdserver.go#L3206
if variable.DefaultValue == "" || (variable.Sensitive && variable.DefaultValue == "*redacted*") {
missingVariables = append(missingVariables, variable)
}
}
if len(missingVariables) == 0 {
return nil, xerrors.New("no missing required variables found")
}
_, _ = fmt.Fprintf(inv.Stderr, "Found %d missing required variables:\n", len(missingVariables))
for _, v := range missingVariables {
_, _ = fmt.Fprintf(inv.Stderr, " - %s (%s): %s\n", v.Name, v.Type, v.Description)
}
_, _ = fmt.Fprintln(inv.Stderr, "\nThe template requires values for the following variables:")
var promptedValues []codersdk.VariableValue
for _, variable := range missingVariables {
value, err := promptForTemplateVariable(inv, variable)
if err != nil {
return nil, xerrors.Errorf("prompt for variable %q: %w", variable.Name, err)
}
promptedValues = append(promptedValues, codersdk.VariableValue{
Name: variable.Name,
Value: value,
})
}
combinedValues := codersdk.CombineVariableValues(args.UserVariableValues, promptedValues)
_, _ = fmt.Fprintln(inv.Stderr, "\nRetrying template build with provided variables...")
retryArgs := args
retryArgs.UserVariableValues = combinedValues
return createValidTemplateVersion(inv, retryArgs)
}
func promptForTemplateVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
displayVariableInfo(inv, variable)
switch variable.Type {
case "bool":
return promptForBoolVariable(inv, variable)
case "number":
return promptForNumberVariable(inv, variable)
default:
return promptForStringVariable(inv, variable)
}
}
func displayVariableInfo(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) {
_, _ = fmt.Fprintf(inv.Stderr, "var.%s", cliui.Bold(variable.Name))
if variable.Required {
_, _ = fmt.Fprint(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Error, " (required)"))
}
if variable.Sensitive {
_, _ = fmt.Fprint(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Warn, ", sensitive"))
}
_, _ = fmt.Fprintln(inv.Stderr, "")
if variable.Description != "" {
_, _ = fmt.Fprintf(inv.Stderr, " Description: %s\n", variable.Description)
}
_, _ = fmt.Fprintf(inv.Stderr, " Type: %s\n", variable.Type)
_, _ = fmt.Fprintf(inv.Stderr, " Current value: %s\n", pretty.Sprint(cliui.DefaultStyles.Placeholder, "<empty>"))
}
func promptForBoolVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
defaultValue := variable.DefaultValue
if defaultValue == "" {
defaultValue = "false"
}
return cliui.Select(inv, cliui.SelectOptions{
Options: []string{"true", "false"},
Default: defaultValue,
Message: "Select value:",
})
}
func promptForNumberVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
prompt := "Enter value:"
if !variable.Required && variable.DefaultValue != "" {
prompt = fmt.Sprintf("Enter value (default: %q):", variable.DefaultValue)
}
return cliui.Prompt(inv, cliui.PromptOptions{
Text: prompt,
Default: variable.DefaultValue,
Validate: createVariableValidator(variable),
})
}
func promptForStringVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
prompt := "Enter value:"
if !variable.Sensitive {
if !variable.Required && variable.DefaultValue != "" {
prompt = fmt.Sprintf("Enter value (default: %q):", variable.DefaultValue)
}
}
return cliui.Prompt(inv, cliui.PromptOptions{
Text: prompt,
Default: variable.DefaultValue,
Secret: variable.Sensitive,
Validate: createVariableValidator(variable),
})
}
func createVariableValidator(variable codersdk.TemplateVersionVariable) func(string) error {
return func(s string) error {
if variable.Required && s == "" && variable.DefaultValue == "" {
return xerrors.New("value is required")
}
if variable.Type == "number" && s != "" {
if _, err := strconv.ParseFloat(s, 64); err != nil {
return xerrors.Errorf("must be a valid number, got: %q", s)
}
}
return nil
}
}