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
+234 -48
View File
@@ -852,54 +852,6 @@ func TestTemplatePush(t *testing.T) {
require.Equal(t, "foobar", templateVariables[1].Value)
})
t.Run("VariableIsRequiredButNotProvided", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
templateVersion := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, createEchoResponsesWithTemplateVariables(initialTemplateVariables))
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, templateVersion.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, templateVersion.ID)
// Test the cli command.
//nolint:gocritic
modifiedTemplateVariables := append(initialTemplateVariables,
&proto.TemplateVariable{
Name: "second_variable",
Description: "This is the second variable.",
Type: "string",
Required: true,
},
)
source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(modifiedTemplateVariables))
inv, root := clitest.New(t, "templates", "push", template.Name, "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho), "--name", "example")
clitest.SetupConfig(t, templateAdmin, root)
pty := ptytest.New(t)
inv.Stdin = pty.Input()
inv.Stdout = pty.Output()
execDone := make(chan error)
go func() {
execDone <- inv.Run()
}()
matches := []struct {
match string
write string
}{
{match: "Upload", write: "yes"},
}
for _, m := range matches {
pty.ExpectMatch(m.match)
pty.WriteLine(m.write)
}
wantErr := <-execDone
require.Error(t, wantErr)
require.Contains(t, wantErr.Error(), "required template variables need values")
})
t.Run("VariableIsOptionalButNotProvided", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
@@ -1115,6 +1067,240 @@ func TestTemplatePush(t *testing.T) {
require.Len(t, templateVersions, 2)
require.Equal(t, "example", templateVersions[1].Name)
})
t.Run("PromptForDifferentRequiredTypes", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
templateVariables := []*proto.TemplateVariable{
{
Name: "string_var",
Description: "A string variable",
Type: "string",
Required: true,
},
{
Name: "number_var",
Description: "A number variable",
Type: "number",
Required: true,
},
{
Name: "bool_var",
Description: "A boolean variable",
Type: "bool",
Required: true,
},
{
Name: "sensitive_var",
Description: "A sensitive variable",
Type: "string",
Required: true,
Sensitive: true,
},
}
source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(templateVariables))
inv, root := clitest.New(t, "templates", "push", "test-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho))
clitest.SetupConfig(t, templateAdmin, root)
pty := ptytest.New(t).Attach(inv)
execDone := make(chan error)
go func() {
execDone <- inv.Run()
}()
// Select "Yes" for the "Upload <template_path>" prompt
pty.ExpectMatch("Upload")
pty.WriteLine("yes")
pty.ExpectMatch("var.string_var")
pty.ExpectMatch("Enter value:")
pty.WriteLine("test-string")
pty.ExpectMatch("var.number_var")
pty.ExpectMatch("Enter value:")
pty.WriteLine("42")
// Boolean variable automatically selects the first option ("true")
pty.ExpectMatch("var.bool_var")
pty.ExpectMatch("var.sensitive_var")
pty.ExpectMatch("Enter value:")
pty.WriteLine("secret-value")
require.NoError(t, <-execDone)
})
t.Run("ValidateNumberInput", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
templateVariables := []*proto.TemplateVariable{
{
Name: "number_var",
Description: "A number that requires validation",
Type: "number",
Required: true,
},
}
source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(templateVariables))
inv, root := clitest.New(t, "templates", "push", "test-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho))
clitest.SetupConfig(t, templateAdmin, root)
pty := ptytest.New(t).Attach(inv)
execDone := make(chan error)
go func() {
execDone <- inv.Run()
}()
// Select "Yes" for the "Upload <template_path>" prompt
pty.ExpectMatch("Upload")
pty.WriteLine("yes")
pty.ExpectMatch("var.number_var")
pty.WriteLine("not-a-number")
pty.ExpectMatch("must be a valid number")
pty.WriteLine("123.45")
require.NoError(t, <-execDone)
})
t.Run("DontPromptForDefaultValues", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
templateVariables := []*proto.TemplateVariable{
{
Name: "with_default",
Type: "string",
Required: true,
DefaultValue: "default-value",
},
{
Name: "without_default",
Type: "string",
Required: true,
},
}
source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(templateVariables))
inv, root := clitest.New(t, "templates", "push", "test-template", "--directory", source, "--test.provisioner", string(database.ProvisionerTypeEcho))
clitest.SetupConfig(t, templateAdmin, root)
pty := ptytest.New(t).Attach(inv)
execDone := make(chan error)
go func() {
execDone <- inv.Run()
}()
// Select "Yes" for the "Upload <template_path>" prompt
pty.ExpectMatch("Upload")
pty.WriteLine("yes")
pty.ExpectMatch("var.without_default")
pty.WriteLine("test-value")
require.NoError(t, <-execDone)
})
t.Run("VariableSourcesPriority", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
templateVariables := []*proto.TemplateVariable{
{
Name: "cli_flag_var",
Description: "Variable provided via CLI flag",
Type: "string",
Required: true,
},
{
Name: "file_var",
Description: "Variable provided via file",
Type: "string",
Required: true,
},
{
Name: "prompt_var",
Description: "Variable provided via prompt",
Type: "string",
Required: true,
},
{
Name: "cli_overrides_file_var",
Description: "Variable in both CLI and file",
Type: "string",
Required: true,
},
}
source := clitest.CreateTemplateVersionSource(t, createEchoResponsesWithTemplateVariables(templateVariables))
// Create a temporary variables file.
tempDir := t.TempDir()
removeTmpDirUntilSuccessAfterTest(t, tempDir)
variablesFile, err := os.CreateTemp(tempDir, "variables*.yaml")
require.NoError(t, err)
_, err = variablesFile.WriteString(`file_var: from-file
cli_overrides_file_var: from-file`)
require.NoError(t, err)
require.NoError(t, variablesFile.Close())
inv, root := clitest.New(t, "templates", "push", "test-template",
"--directory", source,
"--test.provisioner", string(database.ProvisionerTypeEcho),
"--variables-file", variablesFile.Name(),
"--variable", "cli_flag_var=from-cli-flag",
"--variable", "cli_overrides_file_var=from-cli-override",
)
clitest.SetupConfig(t, templateAdmin, root)
pty := ptytest.New(t).Attach(inv)
execDone := make(chan error)
go func() {
execDone <- inv.Run()
}()
// Select "Yes" for the "Upload <template_path>" prompt
pty.ExpectMatch("Upload")
pty.WriteLine("yes")
// Only check for prompt_var, other variables should not prompt
pty.ExpectMatch("var.prompt_var")
pty.ExpectMatch("Enter value:")
pty.WriteLine("from-prompt")
require.NoError(t, <-execDone)
template, err := client.TemplateByName(context.Background(), owner.OrganizationID, "test-template")
require.NoError(t, err)
templateVersionVars, err := client.TemplateVersionVariables(context.Background(), template.ActiveVersionID)
require.NoError(t, err)
require.Len(t, templateVersionVars, 4)
varMap := make(map[string]string)
for _, tv := range templateVersionVars {
varMap[tv.Name] = tv.Value
}
require.Equal(t, "from-cli-flag", varMap["cli_flag_var"])
require.Equal(t, "from-file", varMap["file_var"])
require.Equal(t, "from-prompt", varMap["prompt_var"])
require.Equal(t, "from-cli-override", varMap["cli_overrides_file_var"])
})
})
}