mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+158
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user