Skip to content

Commit bb58844

Browse files
author
Rafael Rodriguez
authored
feat(cli): prompt for missing required template variables on template creation (coder#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: coder#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
1 parent a360785 commit bb58844

File tree

4 files changed

+400
-53
lines changed

4 files changed

+400
-53
lines changed

cli/templatepush.go

Lines changed: 158 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"path/filepath"
1111
"slices"
12+
"strconv"
1213
"strings"
1314
"time"
1415

@@ -461,10 +462,14 @@ func createValidTemplateVersion(inv *serpent.Invocation, args createValidTemplat
461462
})
462463
if err != nil {
463464
var jobErr *cliui.ProvisionerJobError
464-
if errors.As(err, &jobErr) && !codersdk.JobIsMissingParameterErrorCode(jobErr.Code) {
465-
return nil, err
465+
if errors.As(err, &jobErr) {
466+
if codersdk.JobIsMissingRequiredTemplateVariableErrorCode(jobErr.Code) {
467+
return handleMissingTemplateVariables(inv, args, version.ID)
468+
}
469+
if !codersdk.JobIsMissingParameterErrorCode(jobErr.Code) {
470+
return nil, err
471+
}
466472
}
467-
468473
return nil, err
469474
}
470475
version, err = client.TemplateVersion(inv.Context(), version.ID)
@@ -528,3 +533,153 @@ func prettyDirectoryPath(dir string) string {
528533
}
529534
return prettyDir
530535
}
536+
537+
func handleMissingTemplateVariables(inv *serpent.Invocation, args createValidTemplateVersionArgs, failedVersionID uuid.UUID) (*codersdk.TemplateVersion, error) {
538+
client := args.Client
539+
540+
templateVariables, err := client.TemplateVersionVariables(inv.Context(), failedVersionID)
541+
if err != nil {
542+
return nil, xerrors.Errorf("fetch template variables: %w", err)
543+
}
544+
545+
existingValues := make(map[string]string)
546+
for _, v := range args.UserVariableValues {
547+
existingValues[v.Name] = v.Value
548+
}
549+
550+
var missingVariables []codersdk.TemplateVersionVariable
551+
for _, variable := range templateVariables {
552+
if !variable.Required {
553+
continue
554+
}
555+
556+
if existingValue, exists := existingValues[variable.Name]; exists && existingValue != "" {
557+
continue
558+
}
559+
560+
// Only prompt for variables that don't have a default value or have a redacted default
561+
// Sensitive variables have a default value of "*redacted*"
562+
// See: https://github.com/coder/coder/blob/a78790c632974e04babfef6de0e2ddf044787a7a/coderd/provisionerdserver/provisionerdserver.go#L3206
563+
if variable.DefaultValue == "" || (variable.Sensitive && variable.DefaultValue == "*redacted*") {
564+
missingVariables = append(missingVariables, variable)
565+
}
566+
}
567+
568+
if len(missingVariables) == 0 {
569+
return nil, xerrors.New("no missing required variables found")
570+
}
571+
572+
_, _ = fmt.Fprintf(inv.Stderr, "Found %d missing required variables:\n", len(missingVariables))
573+
for _, v := range missingVariables {
574+
_, _ = fmt.Fprintf(inv.Stderr, " - %s (%s): %s\n", v.Name, v.Type, v.Description)
575+
}
576+
577+
_, _ = fmt.Fprintln(inv.Stderr, "\nThe template requires values for the following variables:")
578+
579+
var promptedValues []codersdk.VariableValue
580+
for _, variable := range missingVariables {
581+
value, err := promptForTemplateVariable(inv, variable)
582+
if err != nil {
583+
return nil, xerrors.Errorf("prompt for variable %q: %w", variable.Name, err)
584+
}
585+
promptedValues = append(promptedValues, codersdk.VariableValue{
586+
Name: variable.Name,
587+
Value: value,
588+
})
589+
}
590+
591+
combinedValues := codersdk.CombineVariableValues(args.UserVariableValues, promptedValues)
592+
593+
_, _ = fmt.Fprintln(inv.Stderr, "\nRetrying template build with provided variables...")
594+
595+
retryArgs := args
596+
retryArgs.UserVariableValues = combinedValues
597+
598+
return createValidTemplateVersion(inv, retryArgs)
599+
}
600+
601+
func promptForTemplateVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
602+
displayVariableInfo(inv, variable)
603+
604+
switch variable.Type {
605+
case "bool":
606+
return promptForBoolVariable(inv, variable)
607+
case "number":
608+
return promptForNumberVariable(inv, variable)
609+
default:
610+
return promptForStringVariable(inv, variable)
611+
}
612+
}
613+
614+
func displayVariableInfo(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) {
615+
_, _ = fmt.Fprintf(inv.Stderr, "var.%s", cliui.Bold(variable.Name))
616+
if variable.Required {
617+
_, _ = fmt.Fprint(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Error, " (required)"))
618+
}
619+
if variable.Sensitive {
620+
_, _ = fmt.Fprint(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Warn, ", sensitive"))
621+
}
622+
_, _ = fmt.Fprintln(inv.Stderr, "")
623+
624+
if variable.Description != "" {
625+
_, _ = fmt.Fprintf(inv.Stderr, " Description: %s\n", variable.Description)
626+
}
627+
_, _ = fmt.Fprintf(inv.Stderr, " Type: %s\n", variable.Type)
628+
_, _ = fmt.Fprintf(inv.Stderr, " Current value: %s\n", pretty.Sprint(cliui.DefaultStyles.Placeholder, "<empty>"))
629+
}
630+
631+
func promptForBoolVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
632+
defaultValue := variable.DefaultValue
633+
if defaultValue == "" {
634+
defaultValue = "false"
635+
}
636+
637+
return cliui.Select(inv, cliui.SelectOptions{
638+
Options: []string{"true", "false"},
639+
Default: defaultValue,
640+
Message: "Select value:",
641+
})
642+
}
643+
644+
func promptForNumberVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
645+
prompt := "Enter value:"
646+
if !variable.Required && variable.DefaultValue != "" {
647+
prompt = fmt.Sprintf("Enter value (default: %q):", variable.DefaultValue)
648+
}
649+
650+
return cliui.Prompt(inv, cliui.PromptOptions{
651+
Text: prompt,
652+
Default: variable.DefaultValue,
653+
Validate: createVariableValidator(variable),
654+
})
655+
}
656+
657+
func promptForStringVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
658+
prompt := "Enter value:"
659+
if !variable.Sensitive {
660+
if !variable.Required && variable.DefaultValue != "" {
661+
prompt = fmt.Sprintf("Enter value (default: %q):", variable.DefaultValue)
662+
}
663+
}
664+
665+
return cliui.Prompt(inv, cliui.PromptOptions{
666+
Text: prompt,
667+
Default: variable.DefaultValue,
668+
Secret: variable.Sensitive,
669+
Validate: createVariableValidator(variable),
670+
})
671+
}
672+
673+
func createVariableValidator(variable codersdk.TemplateVersionVariable) func(string) error {
674+
return func(s string) error {
675+
if variable.Required && s == "" && variable.DefaultValue == "" {
676+
return xerrors.New("value is required")
677+
}
678+
if variable.Type == "number" && s != "" {
679+
if _, err := strconv.ParseFloat(s, 64); err != nil {
680+
return xerrors.Errorf("must be a valid number, got: %q", s)
681+
}
682+
}
683+
return nil
684+
}
685+
}

0 commit comments

Comments
 (0)