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:
+234
-48
@@ -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"])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user