diff --git a/hcl2template/addrs/input_variable.go b/hcl2template/addrs/input_variable.go new file mode 100644 index 000000000..ad4370bb8 --- /dev/null +++ b/hcl2template/addrs/input_variable.go @@ -0,0 +1,11 @@ +package addrs + +// InputVariable is the address of an input variable. +type InputVariable struct { + referenceable + Name string +} + +func (v InputVariable) String() string { + return "var." + v.Name +} diff --git a/hcl2template/addrs/parse_ref.go b/hcl2template/addrs/parse_ref.go new file mode 100644 index 000000000..ee3f238ef --- /dev/null +++ b/hcl2template/addrs/parse_ref.go @@ -0,0 +1,93 @@ +package addrs + +import ( + "fmt" + + "github.com/hashicorp/hcl/v2" +) + +// Reference describes a reference to an address with source location +// information. +type Reference struct { + Subject Referenceable + SourceRange hcl.Range + Remaining hcl.Traversal +} + +// ParseRef attempts to extract a referencable address from the prefix of the +// given traversal, which must be an absolute traversal or this function +// will panic. +// +// If no error diagnostics are returned, the returned reference includes the +// address that was extracted, the source range it was extracted from, and any +// remaining relative traversal that was not consumed as part of the +// reference. +// +// If error diagnostics are returned then the Reference value is invalid and +// must not be used. +func ParseRef(traversal hcl.Traversal) (*Reference, hcl.Diagnostics) { + ref, diags := parseRef(traversal) + + // Normalize a little to make life easier for callers. + if ref != nil { + if len(ref.Remaining) == 0 { + ref.Remaining = nil + } + } + + return ref, diags +} + +func parseRef(traversal hcl.Traversal) (*Reference, hcl.Diagnostics) { + var diags hcl.Diagnostics + + root := traversal.RootName() + rootRange := traversal[0].SourceRange() + + switch root { + + case "var": + name, rng, remain, diags := parseSingleAttrRef(traversal) + return &Reference{ + Subject: InputVariable{Name: name}, + SourceRange: rng, + Remaining: remain, + }, diags + + default: + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unhandled reference type", + Detail: `Currently parseRef can only parse "var" references.`, + Subject: &rootRange, + }) + } + return nil, diags +} + +func parseSingleAttrRef(traversal hcl.Traversal) (string, hcl.Range, hcl.Traversal, hcl.Diagnostics) { + var diags hcl.Diagnostics + + root := traversal.RootName() + rootRange := traversal[0].SourceRange() + + if len(traversal) < 2 { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: fmt.Sprintf("The %q object cannot be accessed directly. Instead, access one of its attributes.", root), + Subject: &rootRange, + }) + return "", hcl.Range{}, nil, diags + } + if attrTrav, ok := traversal[1].(hcl.TraverseAttr); ok { + return attrTrav.Name, hcl.RangeBetween(rootRange, attrTrav.SrcRange), traversal[2:], diags + } + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: fmt.Sprintf("The %q object does not support this operation.", root), + Subject: traversal[1].SourceRange().Ptr(), + }) + return "", hcl.Range{}, nil, diags +} diff --git a/hcl2template/addrs/referenceable.go b/hcl2template/addrs/referenceable.go new file mode 100644 index 000000000..8c4925c40 --- /dev/null +++ b/hcl2template/addrs/referenceable.go @@ -0,0 +1,18 @@ +package addrs + +// Referenceable is an interface implemented by all address types that can +// appear as references in configuration language expressions. +type Referenceable interface { + referenceableSigil() + + // String produces a string representation of the address that could be + // parsed as a HCL traversal and passed to ParseRef to produce an identical + // result. + String() string +} + +type referenceable struct { +} + +func (r referenceable) referenceableSigil() { +} diff --git a/hcl2template/types.variables.go b/hcl2template/types.variables.go index 2598eacfc..d742f44c6 100644 --- a/hcl2template/types.variables.go +++ b/hcl2template/types.variables.go @@ -3,15 +3,20 @@ package hcl2template import ( "fmt" "strings" + "unicode" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/ext/typeexpr" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/packer/hcl2template/addrs" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/convert" ) +// A consistent detail message for all "not a valid identifier" diagnostics. +const badIdentifierDetail = "A name must start with a letter or underscore and may contain only letters, digits, underscores, and dashes." + // Local represents a single entry from a "locals" block in a file. // The "locals" block itself is not represented, because it serves only to // provide context for us to interpret its contents. @@ -47,6 +52,8 @@ type Variable struct { // the variable from the output stream. By replacing the text. Sensitive bool + Validations []*VariableValidation + Range hcl.Range } @@ -139,6 +146,28 @@ func (variables *Variables) decodeVariable(key string, attr *hcl.Attribute, ectx return diags } +var variableBlockSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "description", + }, + { + Name: "default", + }, + { + Name: "type", + }, + { + Name: "sensitive", + }, + }, + Blocks: []hcl.BlockHeaderSchema{ + { + Type: "validation", + }, + }, +} + // decodeVariableBlock decodes a "variables" section the way packer 1 used to func (variables *Variables) decodeVariableBlock(block *hcl.Block, ectx *hcl.EvalContext) hcl.Diagnostics { if (*variables) == nil { @@ -155,51 +184,53 @@ func (variables *Variables) decodeVariableBlock(block *hcl.Block, ectx *hcl.Eval }} } - var b struct { - Description string `hcl:"description,optional"` - Sensitive bool `hcl:"sensitive,optional"` - Rest hcl.Body `hcl:",remain"` - } - diags := gohcl.DecodeBody(block.Body, nil, &b) + name := block.Labels[0] - if diags.HasErrors() { - return diags + content, diags := block.Body.Content(variableBlockSchema) + if !hclsyntax.ValidIdentifier(name) { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid variable name", + Detail: badIdentifierDetail, + Subject: &block.LabelRanges[0], + }) } - name := block.Labels[0] - - res := &Variable{ - Name: name, - Description: b.Description, - Sensitive: b.Sensitive, - Range: block.DefRange, + v := &Variable{ + Name: name, + Range: block.DefRange, } - attrs, moreDiags := b.Rest.JustAttributes() - diags = append(diags, moreDiags...) + if attr, exists := content.Attributes["description"]; exists { + valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Description) + diags = append(diags, valDiags...) + } - if t, ok := attrs["type"]; ok { - delete(attrs, "type") + if t, ok := content.Attributes["type"]; ok { tp, moreDiags := typeexpr.Type(t.Expr) diags = append(diags, moreDiags...) if moreDiags.HasErrors() { return diags } - res.Type = tp + v.Type = tp } - if def, ok := attrs["default"]; ok { - delete(attrs, "default") + if attr, exists := content.Attributes["sensitive"]; exists { + valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Sensitive) + diags = append(diags, valDiags...) + } + + if def, ok := content.Attributes["default"]; ok { defaultValue, moreDiags := def.Expr.Value(ectx) diags = append(diags, moreDiags...) if moreDiags.HasErrors() { return diags } - if res.Type != cty.NilType { + if v.Type != cty.NilType { var err error - defaultValue, err = convert.Convert(defaultValue, res.Type) + defaultValue, err = convert.Convert(defaultValue, v.Type) if err != nil { diags = append(diags, &hcl.Diagnostic{ Severity: hcl.DiagError, @@ -211,32 +242,177 @@ func (variables *Variables) decodeVariableBlock(block *hcl.Block, ectx *hcl.Eval } } - res.DefaultValue = defaultValue + v.DefaultValue = defaultValue // It's possible no type attribute was assigned so lets make sure we // have a valid type otherwise there could be issues parsing the value. - if res.Type == cty.NilType { - res.Type = res.DefaultValue.Type() + if v.Type == cty.NilType { + v.Type = v.DefaultValue.Type() } } - if len(attrs) > 0 { - keys := []string{} - for k := range attrs { - keys = append(keys, k) + + for _, block := range content.Blocks { + switch block.Type { + case "validation": + vv, moreDiags := decodeVariableValidationBlock(v.Name, block) + diags = append(diags, moreDiags...) + v.Validations = append(v.Validations, vv) } - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagWarning, - Summary: "Unknown keys", - Detail: fmt.Sprintf("unknown variable setting(s): %s", keys), - Context: block.DefRange.Ptr(), - }) } - (*variables)[name] = res + (*variables)[name] = v return diags } +var variableValidationBlockSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "condition", + Required: true, + }, + { + Name: "error_message", + Required: true, + }, + }, +} + +// VariableValidation represents a configuration-defined validation rule +// for a particular input variable, given as a "validation" block inside +// a "variable" block. +type VariableValidation struct { + // Condition is an expression that refers to the variable being tested and + // contains no other references. The expression must return true to + // indicate that the value is valid or false to indicate that it is + // invalid. If the expression produces an error, that's considered a bug in + // the block defining the validation rule, not an error in the caller. + Condition hcl.Expression + + // ErrorMessage is one or more full sentences, which would need to be in + // English for consistency with the rest of the error message output but + // can in practice be in any language as long as it ends with a period. + // The message should describe what is required for the condition to return + // true in a way that would make sense to a caller of the module. + ErrorMessage string + + DeclRange hcl.Range +} + +func decodeVariableValidationBlock(varName string, block *hcl.Block) (*VariableValidation, hcl.Diagnostics) { + var diags hcl.Diagnostics + vv := &VariableValidation{ + DeclRange: block.DefRange, + } + + content, moreDiags := block.Body.Content(variableValidationBlockSchema) + diags = append(diags, moreDiags...) + + if attr, exists := content.Attributes["condition"]; exists { + vv.Condition = attr.Expr + + // The validation condition must refer to the variable itself and + // nothing else; to ensure that the variable declaration can't create + // additional edges in the dependency graph. + goodRefs := 0 + for _, traversal := range vv.Condition.Variables() { + + ref, moreDiags := addrs.ParseRef(traversal) + if !moreDiags.HasErrors() { + if addr, ok := ref.Subject.(addrs.InputVariable); ok { + if addr.Name == varName { + goodRefs++ + continue // Reference is valid + } + } + } + + // If we fall out here then the reference is invalid. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference in variable validation", + Detail: fmt.Sprintf("The condition for variable %q can only refer to the variable itself, using var.%s.", varName, varName), + Subject: traversal.SourceRange().Ptr(), + }) + } + if goodRefs < 1 { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid variable validation condition", + Detail: fmt.Sprintf("The condition for variable %q must refer to var.%s in order to test incoming values.", varName, varName), + Subject: attr.Expr.Range().Ptr(), + }) + } + } + + if attr, exists := content.Attributes["error_message"]; exists { + moreDiags := gohcl.DecodeExpression(attr.Expr, nil, &vv.ErrorMessage) + diags = append(diags, moreDiags...) + if !moreDiags.HasErrors() { + const errSummary = "Invalid validation error message" + switch { + case vv.ErrorMessage == "": + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: errSummary, + Detail: "An empty string is not a valid nor useful error message.", + Subject: attr.Expr.Range().Ptr(), + }) + case !looksLikeSentences(vv.ErrorMessage): + // Because we're going to include this string verbatim as part + // of a bigger error message written in our usual style in + // English, we'll require the given error message to conform + // to that. We might relax this in future if e.g. we start + // presenting these error messages in a different way, or if + // Terraform starts supporting producing error messages in + // other human languages, etc. + // For pragmatism we also allow sentences ending with + // exclamation points, but we don't mention it explicitly here + // because that's not really consistent with the Terraform UI + // writing style. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: errSummary, + Detail: "Validation error message must be at least one full English sentence starting with an uppercase letter and ending with a period or question mark.", + Subject: attr.Expr.Range().Ptr(), + }) + } + } + } + + return vv, diags +} + +// looksLikeSentence is a simple heuristic that encourages writing error +// messages that will be presentable when included as part of a larger error +// diagnostic whose other text is written in the UI writing style. +// +// This is intentionally not a very strong validation since we're assuming that +// authors want to write good messages and might just need a nudge about +// Packer's specific style, rather than that they are going to try to work +// around these rules to write a lower-quality message. +func looksLikeSentences(s string) bool { + if len(s) < 1 { + return false + } + runes := []rune(s) // HCL guarantees that all strings are valid UTF-8 + first := runes[0] + last := runes[len(runes)-1] + + // If the first rune is a letter then it must be an uppercase letter. + // (This will only see the first rune in a multi-rune combining sequence, + // but the first rune is generally the letter if any are, and if not then + // we'll just ignore it because we're primarily expecting English messages + // right now anyway, for consistency with all of Terraform's other output.) + if unicode.IsLetter(first) && !unicode.IsUpper(first) { + return false + } + + // The string must be at least one full sentence, which implies having + // sentence-ending punctuation. + return last == '.' || last == '?' || last == '!' +} + // Prefix your environment variables with VarEnvPrefix so that Packer can see // them. const VarEnvPrefix = "PKR_VAR_" diff --git a/hcl2template/types.variables_test.go b/hcl2template/types.variables_test.go index b36b6e44f..d1c589d5b 100644 --- a/hcl2template/types.variables_test.go +++ b/hcl2template/types.variables_test.go @@ -131,7 +131,7 @@ func TestParse_variables(t *testing.T) { }, }, }, - true, false, + true, true, []packer.Build{}, false, },