mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
bb5884467d
## 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
274 lines
6.7 KiB
Go
274 lines
6.7 KiB
Go
package codersdk
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"golang.org/x/xerrors"
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/hashicorp/hcl/v2/hclparse"
|
|
"github.com/zclconf/go-cty/cty"
|
|
)
|
|
|
|
/**
|
|
* DiscoverVarsFiles function loads vars files in a predefined order:
|
|
* 1. terraform.tfvars
|
|
* 2. terraform.tfvars.json
|
|
* 3. *.auto.tfvars
|
|
* 4. *.auto.tfvars.json
|
|
*/
|
|
func DiscoverVarsFiles(workDir string) ([]string, error) {
|
|
var found []string
|
|
|
|
fi, err := os.Stat(filepath.Join(workDir, "terraform.tfvars"))
|
|
if err == nil {
|
|
found = append(found, filepath.Join(workDir, fi.Name()))
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
|
|
fi, err = os.Stat(filepath.Join(workDir, "terraform.tfvars.json"))
|
|
if err == nil {
|
|
found = append(found, filepath.Join(workDir, fi.Name()))
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
|
|
dirEntries, err := os.ReadDir(workDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, dirEntry := range dirEntries {
|
|
if strings.HasSuffix(dirEntry.Name(), ".auto.tfvars") || strings.HasSuffix(dirEntry.Name(), ".auto.tfvars.json") {
|
|
found = append(found, filepath.Join(workDir, dirEntry.Name()))
|
|
}
|
|
}
|
|
return found, nil
|
|
}
|
|
|
|
func ParseUserVariableValues(varsFiles []string, variablesFile string, commandLineVariables []string) ([]VariableValue, error) {
|
|
fromVars, err := parseVariableValuesFromVarsFiles(varsFiles)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fromFile, err := parseVariableValuesFromFile(variablesFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fromCommandLine, err := parseVariableValuesFromCommandLine(commandLineVariables)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return CombineVariableValues(fromVars, fromFile, fromCommandLine), nil
|
|
}
|
|
|
|
func parseVariableValuesFromVarsFiles(varsFiles []string) ([]VariableValue, error) {
|
|
var parsed []VariableValue
|
|
for _, varsFile := range varsFiles {
|
|
content, err := os.ReadFile(varsFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var t []VariableValue
|
|
ext := filepath.Ext(varsFile)
|
|
switch ext {
|
|
case ".tfvars":
|
|
t, err = parseVariableValuesFromHCL(content)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("unable to parse HCL content: %w", err)
|
|
}
|
|
case ".json":
|
|
t, err = parseVariableValuesFromJSON(content)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("unable to parse JSON content: %w", err)
|
|
}
|
|
default:
|
|
return nil, xerrors.Errorf("unexpected tfvars format: %s", ext)
|
|
}
|
|
|
|
parsed = append(parsed, t...)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func parseVariableValuesFromHCL(content []byte) ([]VariableValue, error) {
|
|
parser := hclparse.NewParser()
|
|
hclFile, diags := parser.ParseHCL(content, "file.hcl")
|
|
if diags.HasErrors() {
|
|
return nil, diags
|
|
}
|
|
|
|
attrs, diags := hclFile.Body.JustAttributes()
|
|
if diags.HasErrors() {
|
|
return nil, diags
|
|
}
|
|
|
|
stringData := map[string]string{}
|
|
for _, attribute := range attrs {
|
|
ctyValue, diags := attribute.Expr.Value(nil)
|
|
if diags.HasErrors() {
|
|
return nil, diags
|
|
}
|
|
|
|
ctyType := ctyValue.Type()
|
|
switch {
|
|
case ctyType.Equals(cty.String):
|
|
stringData[attribute.Name] = ctyValue.AsString()
|
|
case ctyType.Equals(cty.Number):
|
|
stringData[attribute.Name] = ctyValue.AsBigFloat().String()
|
|
case ctyType.IsTupleType():
|
|
// In case of tuples, Coder only supports the list(string) type.
|
|
var items []string
|
|
var err error
|
|
_ = ctyValue.ForEachElement(func(_, val cty.Value) (stop bool) {
|
|
if !val.Type().Equals(cty.String) {
|
|
err = xerrors.Errorf("unsupported tuple item type: %s ", val.GoString())
|
|
return true
|
|
}
|
|
items = append(items, val.AsString())
|
|
return false
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m, err := json.Marshal(items)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stringData[attribute.Name] = string(m)
|
|
default:
|
|
return nil, xerrors.Errorf("unsupported value type (name: %s): %s", attribute.Name, ctyType.GoString())
|
|
}
|
|
}
|
|
|
|
return convertMapIntoVariableValues(stringData), nil
|
|
}
|
|
|
|
// parseVariableValuesFromJSON converts the .tfvars.json content into template variables.
|
|
// The function visits only root-level properties as template variables do not support nested
|
|
// structures.
|
|
func parseVariableValuesFromJSON(content []byte) ([]VariableValue, error) {
|
|
var data map[string]interface{}
|
|
err := json.Unmarshal(content, &data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stringData := map[string]string{}
|
|
for key, value := range data {
|
|
switch value.(type) {
|
|
case string, int, bool:
|
|
stringData[key] = fmt.Sprintf("%v", value)
|
|
default:
|
|
m, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stringData[key] = string(m)
|
|
}
|
|
}
|
|
|
|
return convertMapIntoVariableValues(stringData), nil
|
|
}
|
|
|
|
func convertMapIntoVariableValues(m map[string]string) []VariableValue {
|
|
var parsed []VariableValue
|
|
for key, value := range m {
|
|
parsed = append(parsed, VariableValue{
|
|
Name: key,
|
|
Value: value,
|
|
})
|
|
}
|
|
sort.Slice(parsed, func(i, j int) bool {
|
|
return parsed[i].Name < parsed[j].Name
|
|
})
|
|
return parsed
|
|
}
|
|
|
|
func parseVariableValuesFromFile(variablesFile string) ([]VariableValue, error) {
|
|
var values []VariableValue
|
|
if variablesFile == "" {
|
|
return values, nil
|
|
}
|
|
|
|
variablesMap, err := createVariablesMapFromFile(variablesFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for name, value := range variablesMap {
|
|
values = append(values, VariableValue{
|
|
Name: name,
|
|
Value: value,
|
|
})
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
// Reads a YAML file and populates a string -> string map.
|
|
// Throws an error if the file name is empty.
|
|
func createVariablesMapFromFile(variablesFile string) (map[string]string, error) {
|
|
if variablesFile == "" {
|
|
return nil, xerrors.Errorf("variable file name is not specified")
|
|
}
|
|
|
|
variablesMap := make(map[string]string)
|
|
variablesFileContents, err := os.ReadFile(variablesFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = yaml.Unmarshal(variablesFileContents, &variablesMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return variablesMap, nil
|
|
}
|
|
|
|
func parseVariableValuesFromCommandLine(variables []string) ([]VariableValue, error) {
|
|
var values []VariableValue
|
|
for _, keyValue := range variables {
|
|
split := strings.SplitN(keyValue, "=", 2)
|
|
if len(split) < 2 {
|
|
return nil, xerrors.Errorf("format key=value expected, but got %s", keyValue)
|
|
}
|
|
|
|
values = append(values, VariableValue{
|
|
Name: split[0],
|
|
Value: split[1],
|
|
})
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func CombineVariableValues(valuesSets ...[]VariableValue) []VariableValue {
|
|
combinedValues := make(map[string]string)
|
|
|
|
for _, values := range valuesSets {
|
|
for _, v := range values {
|
|
combinedValues[v.Name] = v.Value
|
|
}
|
|
}
|
|
|
|
var result []VariableValue
|
|
for name, value := range combinedValues {
|
|
result = append(result, VariableValue{Name: name, Value: value})
|
|
}
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].Name < result[j].Name
|
|
})
|
|
return result
|
|
}
|