Merge pull request #10206 from hashicorp/azr_variable_validation

HCL2: add variable validation
This commit is contained in:
Megan Marsh 2020-11-04 10:46:17 -08:00 committed by GitHub
commit 61b9015415
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1199 additions and 250 deletions

View File

@ -324,6 +324,52 @@ func TestBuild(t *testing.T) {
},
},
},
{
name: "hcl - valid validation rule for default value",
args: []string{
filepath.Join(testFixture("hcl", "validation", "map")),
},
expectedCode: 0,
},
{
name: "hcl - valid setting from varfile",
args: []string{
"-var-file", filepath.Join(testFixture("hcl", "validation", "map", "valid_value.pkrvars.hcl")),
filepath.Join(testFixture("hcl", "validation", "map")),
},
expectedCode: 0,
},
{
name: "hcl - invalid setting from varfile",
args: []string{
"-var-file", filepath.Join(testFixture("hcl", "validation", "map", "invalid_value.pkrvars.hcl")),
filepath.Join(testFixture("hcl", "validation", "map")),
},
expectedCode: 1,
},
{
name: "hcl - valid cmd ( invalid varfile bypased )",
args: []string{
"-var-file", filepath.Join(testFixture("hcl", "validation", "map", "invalid_value.pkrvars.hcl")),
"-var", `image_metadata={key = "new_value", something = { foo = "bar" }}`,
filepath.Join(testFixture("hcl", "validation", "map")),
},
expectedCode: 0,
},
{
name: "hcl - invalid cmd ( valid varfile bypased )",
args: []string{
"-var-file", filepath.Join(testFixture("hcl", "validation", "map", "valid_value.pkrvars.hcl")),
"-var", `image_metadata={key = "?", something = { foo = "wrong" }}`,
filepath.Join(testFixture("hcl", "validation", "map")),
},
expectedCode: 1,
},
}
for _, tt := range tc {

View File

@ -0,0 +1,19 @@
variable "image_metadata" {
default = {
key: "value",
something: {
foo: "bar",
}
}
validation {
condition = length(var.image_metadata.key) > 4
error_message = "The image_metadata.key field must be more than 4 runes."
}
validation {
condition = substr(var.image_metadata.something.foo, 0, 3) == "bar"
error_message = "The image_metadata.something.foo field must start with \"bar\"."
}
}
build {}

View File

@ -0,0 +1,7 @@
image_metadata = {
key: "value",
something: {
foo: "woo",
}
}

View File

@ -0,0 +1,7 @@
image_metadata = {
key: "value",
something: {
foo: "barwoo",
}
}

11
hcl2template/addrs/doc.go Normal file
View File

@ -0,0 +1,11 @@
// Package addrs contains types that represent "addresses", which are
// references to specific objects within a Packer configuration.
//
// All addresses have string representations based on HCL traversal syntax
// which should be used in the user-interface, and also in-memory
// representations that can be used internally.
//
// All types within this package should be treated as immutable, even if this
// is not enforced by the Go compiler. It is always an implementation error
// to modify an address object in-place after it is initially constructed.
package addrs

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,22 @@
package addrs
// Referenceable is an interface implemented by all address types that can
// appear as references in configuration language expressions.
type Referenceable interface {
// referenceableSigil is private to ensure that all Referenceables are
// implentented in this current package. For now this does nothing.
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
}
// referenceable is an empty struct that implements Referenceable, add it to
// your Referenceable struct so that it can be recognized as such.
type referenceable struct {
}
func (r referenceable) referenceableSigil() {
}

View File

@ -69,53 +69,17 @@ func testParse(t *testing.T, tests []parseTest) {
if tt.parseWantDiagHasErrors != gotDiags.HasErrors() {
t.Fatalf("Parser.parse() unexpected diagnostics HasErrors. %s", gotDiags)
}
if diff := cmp.Diff(tt.parseWantCfg, gotCfg,
cmpopts.IgnoreUnexported(
PackerConfig{},
cty.Value{},
cty.Type{},
Variable{},
SourceBlock{},
ProvisionerBlock{},
PostProcessorBlock{},
),
cmpopts.IgnoreFields(PackerConfig{},
"Cwd", // Cwd will change for every computer
),
cmpopts.IgnoreTypes(HCL2Ref{}),
cmpopts.IgnoreTypes([]*LocalBlock{}),
cmpopts.IgnoreTypes([]hcl.Range{}),
cmpopts.IgnoreTypes(hcl.Range{}),
cmpopts.IgnoreInterfaces(struct{ hcl.Expression }{}),
cmpopts.IgnoreInterfaces(struct{ hcl.Body }{}),
); diff != "" {
if diff := cmp.Diff(tt.parseWantCfg, gotCfg, cmpOpts...); diff != "" {
t.Fatalf("Parser.parse() wrong packer config. %s", diff)
}
if gotCfg != nil && !tt.parseWantDiagHasErrors {
gotInputVar := gotCfg.InputVariables
for name, value := range tt.parseWantCfg.InputVariables {
if variable, ok := gotInputVar[name]; ok {
if diff := cmp.Diff(variable.DefaultValue.GoString(), value.DefaultValue.GoString()); diff != "" {
t.Fatalf("Parser.parse(): unexpected default value for %s: %s", name, diff)
}
if diff := cmp.Diff(variable.VarfileValue.GoString(), value.VarfileValue.GoString()); diff != "" {
t.Fatalf("Parser.parse(): varfile value differs for %s: %s", name, diff)
}
} else {
t.Fatalf("Parser.parse() missing input variable. %s", name)
}
if diff := cmp.Diff(tt.parseWantCfg.InputVariables, gotCfg.InputVariables, cmpOpts...); diff != "" {
t.Fatalf("Parser.parse() unexpected input vars. %s", diff)
}
gotLocalVar := gotCfg.LocalVariables
for name, value := range tt.parseWantCfg.LocalVariables {
if variable, ok := gotLocalVar[name]; ok {
if variable.DefaultValue.GoString() != value.DefaultValue.GoString() {
t.Fatalf("Parser.parse() local variable %s expected '%s' but was '%s'", name, value.DefaultValue.GoString(), variable.DefaultValue.GoString())
}
} else {
t.Fatalf("Parser.parse() missing local variable. %s", name)
}
if diff := cmp.Diff(tt.parseWantCfg.LocalVariables, gotCfg.LocalVariables, cmpOpts...); diff != "" {
t.Fatalf("Parser.parse() unexpected local vars. %s", diff)
}
}
@ -127,18 +91,7 @@ func testParse(t *testing.T, tests []parseTest) {
if tt.getBuildsWantDiags == (gotDiags == nil) {
t.Fatalf("Parser.getBuilds() unexpected diagnostics. %s", gotDiags)
}
if diff := cmp.Diff(tt.getBuildsWantBuilds, gotBuilds,
cmpopts.IgnoreUnexported(
cty.Value{},
cty.Type{},
packer.CoreBuild{},
packer.CoreBuildProvisioner{},
packer.CoreBuildPostProcessor{},
null.Builder{},
HCL2Provisioner{},
HCL2PostProcessor{},
),
); diff != "" {
if diff := cmp.Diff(tt.getBuildsWantBuilds, gotBuilds, cmpOpts...); diff != "" {
t.Fatalf("Parser.getBuilds() wrong packer builds. %s", diff)
}
})
@ -250,3 +203,48 @@ var (
},
}
)
var ctyValueComparer = cmp.Comparer(func(x, y cty.Value) bool {
return x.RawEquals(y)
})
var ctyTypeComparer = cmp.Comparer(func(x, y cty.Type) bool {
if x == cty.NilType && y == cty.NilType {
return true
}
if x == cty.NilType || y == cty.NilType {
return false
}
return x.Equals(y)
})
var cmpOpts = []cmp.Option{
ctyValueComparer,
ctyTypeComparer,
cmpopts.IgnoreUnexported(
PackerConfig{},
Variable{},
SourceBlock{},
ProvisionerBlock{},
PostProcessorBlock{},
packer.CoreBuild{},
HCL2Provisioner{},
HCL2PostProcessor{},
packer.CoreBuildPostProcessor{},
packer.CoreBuildProvisioner{},
packer.CoreBuildPostProcessor{},
null.Builder{},
),
cmpopts.IgnoreFields(PackerConfig{},
"Cwd", // Cwd will change for every os type
),
cmpopts.IgnoreFields(VariableAssignment{},
"Expr", // its an interface
),
cmpopts.IgnoreTypes(HCL2Ref{}),
cmpopts.IgnoreTypes([]*LocalBlock{}),
cmpopts.IgnoreTypes([]hcl.Range{}),
cmpopts.IgnoreTypes(hcl.Range{}),
cmpopts.IgnoreInterfaces(struct{ hcl.Expression }{}),
cmpopts.IgnoreInterfaces(struct{ hcl.Body }{}),
}

View File

@ -0,0 +1,59 @@
package function
import (
"errors"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
"github.com/zclconf/go-cty/cty/function/stdlib"
)
var LengthFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "value",
Type: cty.DynamicPseudoType,
AllowDynamicType: true,
AllowUnknown: true,
},
},
Type: func(args []cty.Value) (cty.Type, error) {
collTy := args[0].Type()
switch {
case collTy == cty.String || collTy.IsTupleType() || collTy.IsObjectType() || collTy.IsListType() || collTy.IsMapType() || collTy.IsSetType() || collTy == cty.DynamicPseudoType:
return cty.Number, nil
default:
return cty.Number, errors.New("argument must be a string, a collection type, or a structural type")
}
},
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
coll := args[0]
collTy := args[0].Type()
switch {
case collTy == cty.DynamicPseudoType:
return cty.UnknownVal(cty.Number), nil
case collTy.IsTupleType():
l := len(collTy.TupleElementTypes())
return cty.NumberIntVal(int64(l)), nil
case collTy.IsObjectType():
l := len(collTy.AttributeTypes())
return cty.NumberIntVal(int64(l)), nil
case collTy == cty.String:
// We'll delegate to the cty stdlib strlen function here, because
// it deals with all of the complexities of tokenizing unicode
// grapheme clusters.
return stdlib.Strlen(coll)
case collTy.IsListType() || collTy.IsSetType() || collTy.IsMapType():
return coll.Length(), nil
default:
// Should never happen, because of the checks in our Type func above
return cty.UnknownVal(cty.Number), errors.New("impossible value type for length(...)")
}
},
})
// Length returns the number of elements in the given collection or number of
// Unicode characters in the given string.
func Length(collection cty.Value) (cty.Value, error) {
return LengthFunc.Call([]cty.Value{collection})
}

View File

@ -0,0 +1,139 @@
package function
import (
"fmt"
"testing"
"github.com/zclconf/go-cty/cty"
)
func TestLength(t *testing.T) {
tests := []struct {
Value cty.Value
Want cty.Value
}{
{
cty.ListValEmpty(cty.Number),
cty.NumberIntVal(0),
},
{
cty.ListVal([]cty.Value{cty.True}),
cty.NumberIntVal(1),
},
{
cty.ListVal([]cty.Value{cty.UnknownVal(cty.Bool)}),
cty.NumberIntVal(1),
},
{
cty.SetValEmpty(cty.Number),
cty.NumberIntVal(0),
},
{
cty.SetVal([]cty.Value{cty.True}),
cty.NumberIntVal(1),
},
{
cty.MapValEmpty(cty.Bool),
cty.NumberIntVal(0),
},
{
cty.MapVal(map[string]cty.Value{"hello": cty.True}),
cty.NumberIntVal(1),
},
{
cty.EmptyTupleVal,
cty.NumberIntVal(0),
},
{
cty.UnknownVal(cty.EmptyTuple),
cty.NumberIntVal(0),
},
{
cty.TupleVal([]cty.Value{cty.True}),
cty.NumberIntVal(1),
},
{
cty.EmptyObjectVal,
cty.NumberIntVal(0),
},
{
cty.UnknownVal(cty.EmptyObject),
cty.NumberIntVal(0),
},
{
cty.ObjectVal(map[string]cty.Value{"true": cty.True}),
cty.NumberIntVal(1),
},
{
cty.UnknownVal(cty.List(cty.Bool)),
cty.UnknownVal(cty.Number),
},
{
cty.DynamicVal,
cty.UnknownVal(cty.Number),
},
{
cty.StringVal("hello"),
cty.NumberIntVal(5),
},
{
cty.StringVal(""),
cty.NumberIntVal(0),
},
{
cty.StringVal("1"),
cty.NumberIntVal(1),
},
{
cty.StringVal("Живой Журнал"),
cty.NumberIntVal(12),
},
{
// note that the dieresis here is intentionally a combining
// ligature.
cty.StringVal("noël"),
cty.NumberIntVal(4),
},
{
// The Es in this string has three combining acute accents.
// This tests something that NFC-normalization cannot collapse
// into a single precombined codepoint, since otherwise we might
// be cheating and relying on the single-codepoint forms.
cty.StringVal("wé́́é́́é́́!"),
cty.NumberIntVal(5),
},
{
// Go's normalization forms don't handle this ligature, so we
// will produce the wrong result but this is now a compatibility
// constraint and so we'll test it.
cty.StringVal("baffle"),
cty.NumberIntVal(4),
},
{
cty.StringVal("😸😾"),
cty.NumberIntVal(2),
},
{
cty.UnknownVal(cty.String),
cty.UnknownVal(cty.Number),
},
{
cty.DynamicVal,
cty.UnknownVal(cty.Number),
},
}
for _, test := range tests {
t.Run(fmt.Sprintf("Length(%#v)", test.Value), func(t *testing.T) {
got, err := Length(test.Value)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if !got.RawEquals(test.Want) {
t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want)
}
})
}
}

View File

@ -68,7 +68,7 @@ func Functions(basedir string) map[string]function.Function {
"jsondecode": stdlib.JSONDecodeFunc,
"jsonencode": stdlib.JSONEncodeFunc,
"keys": stdlib.KeysFunc,
"length": stdlib.LengthFunc,
"length": pkrfunction.LengthFunc,
"log": stdlib.LogFunc,
"lookup": stdlib.LookupFunc,
"lower": stdlib.LowerFunc,

View File

@ -0,0 +1,9 @@
variable "image_id" {
type = string
default = "potato"
validation {
condition = length(var.image_id) > 4 && substr(var.image_id, 0, 4) == "ami-"
error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"."
}
}

View File

@ -0,0 +1,9 @@
variable "image_id" {
type = string
default = "ami-something-something"
validation {
condition = length(var.image_id) > 4 && substr(var.image_id, 0, 4) == "ami-"
error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"."
}
}

View File

@ -0,0 +1,17 @@
variable "image_metadata" {
default = {
key: "value",
something: {
foo: "bar",
}
}
validation {
condition = length(var.image_metadata.key) > 4
error_message = "The image_metadata.key field must be more than 4 runes."
}
validation {
condition = substr(var.image_metadata.something.foo, 0, 3) == "bar"
error_message = "The image_metadata.something.foo field must start with \"bar\"."
}
}

View File

@ -0,0 +1,7 @@
image_metadata = {
key: "value",
something: {
foo: "woo",
}
}

View File

@ -210,9 +210,13 @@ func (c *PackerConfig) evaluateLocalVariable(local *LocalBlock) hcl.Diagnostics
return diags
}
c.LocalVariables[local.Name] = &Variable{
Name: local.Name,
DefaultValue: value,
Type: value.Type(),
Name: local.Name,
Values: []VariableAssignment{{
Value: value,
Expr: local.Expr,
From: "default",
}},
Type: value.Type(),
}
return diags

View File

@ -25,49 +25,76 @@ func TestParser_complete(t *testing.T) {
Basedir: "testdata/complete",
InputVariables: Variables{
"foo": &Variable{
Name: "foo",
DefaultValue: cty.StringVal("value"),
Name: "foo",
Values: []VariableAssignment{{From: "default", Value: cty.StringVal("value")}},
Type: cty.String,
},
"image_id": &Variable{
Name: "image_id",
DefaultValue: cty.StringVal("image-id-default"),
Name: "image_id",
Values: []VariableAssignment{{From: "default", Value: cty.StringVal("image-id-default")}},
Type: cty.String,
},
"port": &Variable{
Name: "port",
DefaultValue: cty.NumberIntVal(42),
Name: "port",
Values: []VariableAssignment{{From: "default", Value: cty.NumberIntVal(42)}},
Type: cty.Number,
},
"availability_zone_names": &Variable{
Name: "availability_zone_names",
DefaultValue: cty.ListVal([]cty.Value{
cty.StringVal("A"),
cty.StringVal("B"),
cty.StringVal("C"),
}),
Values: []VariableAssignment{{
From: "default",
Value: cty.ListVal([]cty.Value{
cty.StringVal("A"),
cty.StringVal("B"),
cty.StringVal("C"),
}),
}},
Type: cty.List(cty.String),
},
},
LocalVariables: Variables{
"feefoo": &Variable{
Name: "feefoo",
DefaultValue: cty.StringVal("value_image-id-default"),
Name: "feefoo",
Values: []VariableAssignment{{From: "default", Value: cty.StringVal("value_image-id-default")}},
Type: cty.String,
},
"standard_tags": &Variable{
Name: "standard_tags",
DefaultValue: cty.ObjectVal(map[string]cty.Value{
"Component": cty.StringVal("user-service"),
"Environment": cty.StringVal("production"),
Values: []VariableAssignment{{From: "default",
Value: cty.ObjectVal(map[string]cty.Value{
"Component": cty.StringVal("user-service"),
"Environment": cty.StringVal("production"),
}),
}},
Type: cty.Object(map[string]cty.Type{
"Component": cty.String,
"Environment": cty.String,
}),
},
"abc_map": &Variable{
Name: "abc_map",
DefaultValue: cty.TupleVal([]cty.Value{
cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("a"),
Values: []VariableAssignment{{From: "default",
Value: cty.TupleVal([]cty.Value{
cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("a"),
}),
cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("b"),
}),
cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("c"),
}),
}),
cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("b"),
}},
Type: cty.Tuple([]cty.Type{
cty.Object(map[string]cty.Type{
"id": cty.String,
}),
cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("c"),
cty.Object(map[string]cty.Type{
"id": cty.String,
}),
cty.Object(map[string]cty.Type{
"id": cty.String,
}),
}),
},

View File

@ -2,16 +2,22 @@ package hcl2template
import (
"fmt"
"log"
"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.
@ -20,15 +26,26 @@ type LocalBlock struct {
Expr hcl.Expression
}
// VariableAssignment represents a way a variable was set: the expression
// setting it and the value of that expression. It helps pinpoint were
// something was set in diagnostics.
type VariableAssignment struct {
// From tells were it was taken from, command/varfile/env/default
From string
Value cty.Value
Expr hcl.Expression
}
type Variable struct {
// CmdValue, VarfileValue, EnvValue, DefaultValue are possible values of
// the variable; The first value set from these will be the one used. If
// none is set; an error will be returned if a user tries to use the
// Variable.
CmdValue cty.Value
VarfileValue cty.Value
EnvValue cty.Value
DefaultValue cty.Value
// Values contains possible values for the variable; The last value set
// from these will be the one used. If none is set; an error will be
// returned by Value().
Values []VariableAssignment
// Validations contains all variables validation rules to be applied to the
// used value. Only the used value - the last value from Values - is
// validated.
Validations []*VariableValidation
// Cty Type of the variable. If the default value or a collected value is
// not of this type nor can be converted to this type an error diagnostic
@ -51,35 +68,101 @@ type Variable struct {
}
func (v *Variable) GoString() string {
return fmt.Sprintf("{Type:%s,CmdValue:%s,VarfileValue:%s,EnvValue:%s,DefaultValue:%s}",
v.Type.GoString(),
PrintableCtyValue(v.CmdValue),
PrintableCtyValue(v.VarfileValue),
PrintableCtyValue(v.EnvValue),
PrintableCtyValue(v.DefaultValue),
)
b := &strings.Builder{}
fmt.Fprintf(b, "{type:%s", v.Type.GoString())
for _, vv := range v.Values {
fmt.Fprintf(b, ",%s:%s", vv.From, vv.Value)
}
fmt.Fprintf(b, "}")
return b.String()
}
func (v *Variable) Value() (cty.Value, *hcl.Diagnostic) {
for _, value := range []cty.Value{
v.CmdValue,
v.VarfileValue,
v.EnvValue,
v.DefaultValue,
} {
if value != cty.NilVal {
return value, nil
// validateValue ensures that all of the configured custom validations for a
// variable value are passing.
//
func (v *Variable) validateValue(val VariableAssignment) (diags hcl.Diagnostics) {
if len(v.Validations) == 0 {
log.Printf("[TRACE] validateValue: not active for %s, so skipping", v.Name)
return nil
}
hclCtx := &hcl.EvalContext{
Variables: map[string]cty.Value{
"var": cty.ObjectVal(map[string]cty.Value{
v.Name: val.Value,
}),
},
Functions: Functions(""),
}
for _, validation := range v.Validations {
const errInvalidCondition = "Invalid variable validation result"
result, moreDiags := validation.Condition.Value(hclCtx)
diags = append(diags, moreDiags...)
if moreDiags.HasErrors() {
log.Printf("[TRACE] evalVariableValidations: %s rule %s condition expression failed: %s", v.Name, validation.DeclRange, moreDiags.Error())
}
if !result.IsKnown() {
log.Printf("[TRACE] evalVariableValidations: %s rule %s condition value is unknown, so skipping validation for now", v.Name, validation.DeclRange)
continue // We'll wait until we've learned more, then.
}
if result.IsNull() {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: errInvalidCondition,
Detail: "Validation condition expression must return either true or false, not null.",
Subject: validation.Condition.Range().Ptr(),
Expression: validation.Condition,
EvalContext: hclCtx,
})
continue
}
var err error
result, err = convert.Convert(result, cty.Bool)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: errInvalidCondition,
Detail: fmt.Sprintf("Invalid validation condition result value: %s.", err),
Subject: validation.Condition.Range().Ptr(),
Expression: validation.Condition,
EvalContext: hclCtx,
})
continue
}
if result.False() {
subj := validation.DeclRange.Ptr()
if val.Expr != nil {
subj = val.Expr.Range().Ptr()
}
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Invalid value for %s variable", val.From),
Detail: fmt.Sprintf("%s\n\nThis was checked by the validation rule at %s.", validation.ErrorMessage, validation.DeclRange.String()),
Subject: subj,
})
}
}
return cty.UnknownVal(v.Type), &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Unset variable %q", v.Name),
Detail: "A used variable must be set or have a default value; see " +
"https://packer.io/docs/configuration/from-1.5/syntax for " +
"details.",
Context: v.Range.Ptr(),
return diags
}
// Value returns the last found value from the list of variable settings.
func (v *Variable) Value() (cty.Value, hcl.Diagnostics) {
if len(v.Values) == 0 {
return cty.UnknownVal(v.Type), hcl.Diagnostics{&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Unset variable %q", v.Name),
Detail: "A used variable must be set or have a default value; see " +
"https://packer.io/docs/configuration/from-1.5/syntax for " +
"details.",
Context: v.Range.Ptr(),
}}
}
val := v.Values[len(v.Values)-1]
return val.Value, v.validateValue(v.Values[len(v.Values)-1])
}
type Variables map[string]*Variable
@ -96,10 +179,8 @@ func (variables Variables) Values() (map[string]cty.Value, hcl.Diagnostics) {
res := map[string]cty.Value{}
var diags hcl.Diagnostics
for k, v := range variables {
value, diag := v.Value()
if diag != nil {
diags = append(diags, diag)
}
value, moreDiags := v.Value()
diags = append(diags, moreDiags...)
res[k] = value
}
return res, diags
@ -130,15 +211,41 @@ func (variables *Variables) decodeVariable(key string, attr *hcl.Attribute, ectx
}
(*variables)[key] = &Variable{
Name: key,
DefaultValue: value,
Type: value.Type(),
Range: attr.Range,
Name: key,
Values: []VariableAssignment{{
From: "default",
Value: value,
Expr: attr.Expr,
}},
Type: value.Type(),
Range: attr.Range,
}
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 +262,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)
if diags.HasErrors() {
return diags
}
name := block.Labels[0]
res := &Variable{
Name: name,
Description: b.Description,
Sensitive: b.Sensitive,
Range: block.DefRange,
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],
})
}
attrs, moreDiags := b.Rest.JustAttributes()
diags = append(diags, moreDiags...)
v := &Variable{
Name: name,
Range: block.DefRange,
}
if t, ok := attrs["type"]; ok {
delete(attrs, "type")
if attr, exists := content.Attributes["description"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Description)
diags = append(diags, valDiags...)
}
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 +320,178 @@ func (variables *Variables) decodeVariableBlock(block *hcl.Block, ectx *hcl.Eval
}
}
res.DefaultValue = defaultValue
v.Values = append(v.Values, VariableAssignment{
From: "default",
Value: defaultValue,
Expr: def.Expr,
})
// 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 = defaultValue.Type()
}
}
if len(attrs) > 0 {
keys := []string{}
for k := range attrs {
keys = append(keys, k)
}
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
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)
}
}
(*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 _should_ 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, 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 Packer 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 Packer UI writing
// style.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: errSummary,
Detail: "Validation error message must be at least one full sentence starting with an uppercase letter ( if the alphabet permits it ) 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. To
// sorts of nudge people into writting sentences. For alphabets that don't
// have the notion of 'upper', this does nothing.
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_"
@ -288,7 +543,11 @@ func (cfg *PackerConfig) collectInputVariableValues(env []string, files []*hcl.F
val = cty.DynamicVal
}
}
variable.EnvValue = val
variable.Values = append(variable.Values, VariableAssignment{
From: "env",
Value: val,
Expr: expr,
})
}
// files will contain files found in the folder then files passed as
@ -310,7 +569,7 @@ func (cfg *PackerConfig) collectInputVariableValues(env []string, files []*hcl.F
})
for _, block := range content.Blocks {
name := block.Labels[0]
diags = diags.Append(&hcl.Diagnostic{
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Variable declaration in a .pkrvar file",
Detail: fmt.Sprintf("A .pkrvar file is used to assign "+
@ -372,7 +631,11 @@ func (cfg *PackerConfig) collectInputVariableValues(env []string, files []*hcl.F
}
}
variable.VarfileValue = val
variable.Values = append(variable.Values, VariableAssignment{
From: "varfile",
Value: val,
Expr: attr.Expr,
})
}
}
@ -416,7 +679,11 @@ func (cfg *PackerConfig) collectInputVariableValues(env []string, files []*hcl.F
}
}
variable.CmdValue = val
variable.Values = append(variable.Values, VariableAssignment{
From: "cmd",
Value: val,
Expr: expr,
})
}
return diags

View File

@ -25,47 +25,68 @@ func TestParse_variables(t *testing.T) {
Basedir: filepath.Join("testdata", "variables"),
InputVariables: Variables{
"image_name": &Variable{
Name: "image_name",
DefaultValue: cty.StringVal("foo-image-{{user `my_secret`}}"),
Name: "image_name",
Type: cty.String,
Values: []VariableAssignment{{From: "default", Value: cty.StringVal("foo-image-{{user `my_secret`}}")}},
},
"key": &Variable{
Name: "key",
DefaultValue: cty.StringVal("value"),
Name: "key",
Type: cty.String,
Values: []VariableAssignment{{From: "default", Value: cty.StringVal("value")}},
},
"my_secret": &Variable{
Name: "my_secret",
DefaultValue: cty.StringVal("foo"),
Name: "my_secret",
Type: cty.String,
Values: []VariableAssignment{{From: "default", Value: cty.StringVal("foo")}},
},
"image_id": &Variable{
Name: "image_id",
DefaultValue: cty.StringVal("image-id-default"),
Name: "image_id",
Type: cty.String,
Values: []VariableAssignment{{From: "default", Value: cty.StringVal("image-id-default")}},
},
"port": &Variable{
Name: "port",
DefaultValue: cty.NumberIntVal(42),
Name: "port",
Type: cty.Number,
Values: []VariableAssignment{{From: "default", Value: cty.NumberIntVal(42)}},
},
"availability_zone_names": &Variable{
Name: "availability_zone_names",
DefaultValue: cty.ListVal([]cty.Value{
cty.StringVal("us-west-1a"),
}),
Values: []VariableAssignment{{
From: "default",
Value: cty.ListVal([]cty.Value{
cty.StringVal("us-west-1a"),
}),
}},
Type: cty.List(cty.String),
Description: fmt.Sprintln("Describing is awesome ;D"),
},
"super_secret_password": &Variable{
Name: "super_secret_password",
Sensitive: true,
DefaultValue: cty.NullVal(cty.String),
Description: fmt.Sprintln("Handle with care plz"),
Name: "super_secret_password",
Sensitive: true,
Values: []VariableAssignment{{
From: "default",
Value: cty.NullVal(cty.String),
}},
Type: cty.String,
Description: fmt.Sprintln("Handle with care plz"),
},
},
LocalVariables: Variables{
"owner": &Variable{
Name: "owner",
DefaultValue: cty.StringVal("Community Team"),
Name: "owner",
Values: []VariableAssignment{{
From: "default",
Value: cty.StringVal("Community Team"),
}},
Type: cty.String,
},
"service_name": &Variable{
Name: "service_name",
DefaultValue: cty.StringVal("forum"),
Name: "service_name",
Values: []VariableAssignment{{
From: "default",
Value: cty.StringVal("forum"),
}},
Type: cty.String,
},
},
},
@ -81,6 +102,11 @@ func TestParse_variables(t *testing.T) {
InputVariables: Variables{
"boolean_value": &Variable{
Name: "boolean_value",
Values: []VariableAssignment{{
From: "default",
Value: cty.BoolVal(false),
}},
Type: cty.Bool,
},
},
},
@ -96,6 +122,11 @@ func TestParse_variables(t *testing.T) {
InputVariables: Variables{
"boolean_value": &Variable{
Name: "boolean_value",
Values: []VariableAssignment{{
From: "default",
Value: cty.BoolVal(false),
}},
Type: cty.Bool,
},
},
},
@ -111,6 +142,11 @@ func TestParse_variables(t *testing.T) {
InputVariables: Variables{
"broken_type": &Variable{
Name: "broken_type",
Values: []VariableAssignment{{
From: "default",
Value: cty.UnknownVal(cty.DynamicPseudoType),
}},
Type: cty.List(cty.String),
},
},
},
@ -126,12 +162,13 @@ func TestParse_variables(t *testing.T) {
Basedir: filepath.Join("testdata", "variables"),
InputVariables: Variables{
"broken_variable": &Variable{
Name: "broken_variable",
DefaultValue: cty.BoolVal(true),
Name: "broken_variable",
Values: []VariableAssignment{{From: "default", Value: cty.BoolVal(true)}},
Type: cty.Bool,
},
},
},
true, false,
true, true,
[]packer.Build{},
false,
},
@ -144,6 +181,7 @@ func TestParse_variables(t *testing.T) {
InputVariables: Variables{
"foo": &Variable{
Name: "foo",
Type: cty.String,
},
},
},
@ -160,6 +198,7 @@ func TestParse_variables(t *testing.T) {
InputVariables: Variables{
"foo": &Variable{
Name: "foo",
Type: cty.String,
},
},
Sources: map[SourceRef]SourceBlock{
@ -196,33 +235,46 @@ func TestParse_variables(t *testing.T) {
Basedir: "testdata/variables/complicated",
InputVariables: Variables{
"name_prefix": &Variable{
Name: "name_prefix",
DefaultValue: cty.StringVal("foo"),
Name: "name_prefix",
Values: []VariableAssignment{{From: "default", Value: cty.StringVal("foo")}},
Type: cty.String,
},
},
LocalVariables: Variables{
"name_prefix": &Variable{
Name: "name_prefix",
DefaultValue: cty.StringVal("foo"),
Name: "name_prefix",
Values: []VariableAssignment{{From: "default", Value: cty.StringVal("foo")}},
Type: cty.String,
},
"foo": &Variable{
Name: "foo",
DefaultValue: cty.StringVal("foo"),
Name: "foo",
Values: []VariableAssignment{{From: "default", Value: cty.StringVal("foo")}},
Type: cty.String,
},
"bar": &Variable{
Name: "bar",
DefaultValue: cty.StringVal("foo"),
Name: "bar",
Values: []VariableAssignment{{From: "default", Value: cty.StringVal("foo")}},
Type: cty.String,
},
"for_var": &Variable{
Name: "for_var",
DefaultValue: cty.StringVal("foo"),
Name: "for_var",
Values: []VariableAssignment{{From: "default", Value: cty.StringVal("foo")}},
Type: cty.String,
},
"bar_var": &Variable{
Name: "bar_var",
DefaultValue: cty.TupleVal([]cty.Value{
cty.StringVal("foo"),
cty.StringVal("foo"),
cty.StringVal("foo"),
Values: []VariableAssignment{{
From: "default",
Value: cty.TupleVal([]cty.Value{
cty.StringVal("foo"),
cty.StringVal("foo"),
cty.StringVal("foo"),
}),
}},
Type: cty.Tuple([]cty.Type{
cty.String,
cty.String,
cty.String,
}),
},
},
@ -250,9 +302,12 @@ func TestParse_variables(t *testing.T) {
Basedir: filepath.Join("testdata", "variables"),
InputVariables: Variables{
"foo": &Variable{
DefaultValue: cty.StringVal("bar"),
Name: "foo",
VarfileValue: cty.StringVal("wee"),
Name: "foo",
Values: []VariableAssignment{
VariableAssignment{"default", cty.StringVal("bar"), nil},
VariableAssignment{"varfile", cty.StringVal("wee"), nil},
},
Type: cty.String,
},
},
},
@ -279,14 +334,14 @@ func TestParse_variables(t *testing.T) {
Basedir: filepath.Join("testdata", "variables"),
InputVariables: Variables{
"max_retries": &Variable{
Name: "max_retries",
DefaultValue: cty.StringVal("1"),
Type: cty.String,
Name: "max_retries",
Values: []VariableAssignment{{"default", cty.StringVal("1"), nil}},
Type: cty.String,
},
"max_retries_int": &Variable{
Name: "max_retries_int",
DefaultValue: cty.NumberIntVal(1),
Type: cty.Number,
Name: "max_retries_int",
Values: []VariableAssignment{{"default", cty.NumberIntVal(1), nil}},
Type: cty.Number,
},
},
Sources: map[SourceRef]SourceBlock{
@ -357,6 +412,54 @@ func TestParse_variables(t *testing.T) {
},
false,
},
{"valid validation block",
defaultParser,
parseTestArgs{"testdata/variables/validation/valid.pkr.hcl", nil, nil},
&PackerConfig{
Basedir: filepath.Join("testdata", "variables", "validation"),
InputVariables: Variables{
"image_id": &Variable{
Values: []VariableAssignment{
{"default", cty.StringVal("ami-something-something"), nil},
},
Name: "image_id",
Type: cty.String,
Validations: []*VariableValidation{
&VariableValidation{
ErrorMessage: `The image_id value must be a valid AMI id, starting with "ami-".`,
},
},
},
},
},
false, false,
[]packer.Build{},
false,
},
{"valid validation block - invalid default",
defaultParser,
parseTestArgs{"testdata/variables/validation/invalid_default.pkr.hcl", nil, nil},
&PackerConfig{
Basedir: filepath.Join("testdata", "variables", "validation"),
InputVariables: Variables{
"image_id": &Variable{
Values: []VariableAssignment{{"default", cty.StringVal("potato"), nil}},
Name: "image_id",
Type: cty.String,
Validations: []*VariableValidation{
&VariableValidation{
ErrorMessage: `The image_id value must be a valid AMI id, starting with "ami-".`,
},
},
},
},
},
true, true,
nil,
false,
},
}
testParse(t, tests)
}
@ -380,8 +483,10 @@ func TestVariables_collectVariableValues(t *testing.T) {
{name: "string",
variables: Variables{"used_string": &Variable{
DefaultValue: cty.StringVal("default_value"),
Type: cty.String,
Values: []VariableAssignment{
{"default", cty.StringVal("default_value"), nil},
},
Type: cty.String,
}},
args: args{
env: []string{`PKR_VAR_used_string=env_value`},
@ -398,11 +503,14 @@ func TestVariables_collectVariableValues(t *testing.T) {
wantDiags: false,
wantVariables: Variables{
"used_string": &Variable{
Type: cty.String,
CmdValue: cty.StringVal("cmd_value"),
VarfileValue: cty.StringVal("varfile_value"),
EnvValue: cty.StringVal("env_value"),
DefaultValue: cty.StringVal("default_value"),
Type: cty.String,
Values: []VariableAssignment{
{"default", cty.StringVal(`default_value`), nil},
{"env", cty.StringVal(`env_value`), nil},
{"varfile", cty.StringVal(`xy`), nil},
{"varfile", cty.StringVal(`varfile_value`), nil},
{"cmd", cty.StringVal(`cmd_value`), nil},
},
},
},
wantValues: map[string]cty.Value{
@ -412,8 +520,10 @@ func TestVariables_collectVariableValues(t *testing.T) {
{name: "quoted string",
variables: Variables{"quoted_string": &Variable{
DefaultValue: cty.StringVal(`"default_value"`),
Type: cty.String,
Values: []VariableAssignment{
{"default", cty.StringVal(`"default_value"`), nil},
},
Type: cty.String,
}},
args: args{
env: []string{`PKR_VAR_quoted_string="env_value"`},
@ -430,11 +540,14 @@ func TestVariables_collectVariableValues(t *testing.T) {
wantDiags: false,
wantVariables: Variables{
"quoted_string": &Variable{
Type: cty.String,
CmdValue: cty.StringVal(`"cmd_value"`),
VarfileValue: cty.StringVal(`"varfile_value"`),
EnvValue: cty.StringVal(`"env_value"`),
DefaultValue: cty.StringVal(`"default_value"`),
Type: cty.String,
Values: []VariableAssignment{
{"default", cty.StringVal(`"default_value"`), nil},
{"env", cty.StringVal(`"env_value"`), nil},
{"varfile", cty.StringVal(`"xy"`), nil},
{"varfile", cty.StringVal(`"varfile_value"`), nil},
{"cmd", cty.StringVal(`"cmd_value"`), nil},
},
},
},
wantValues: map[string]cty.Value{
@ -444,8 +557,10 @@ func TestVariables_collectVariableValues(t *testing.T) {
{name: "array of strings",
variables: Variables{"used_strings": &Variable{
DefaultValue: stringListVal("default_value_1"),
Type: cty.List(cty.String),
Values: []VariableAssignment{
{"default", stringListVal("default_value_1"), nil},
},
Type: cty.List(cty.String),
}},
args: args{
env: []string{`PKR_VAR_used_strings=["env_value_1", "env_value_2"]`},
@ -462,11 +577,14 @@ func TestVariables_collectVariableValues(t *testing.T) {
wantDiags: false,
wantVariables: Variables{
"used_strings": &Variable{
Type: cty.List(cty.String),
CmdValue: stringListVal("cmd_value_1"),
VarfileValue: stringListVal("varfile_value_1"),
EnvValue: stringListVal("env_value_1", "env_value_2"),
DefaultValue: stringListVal("default_value_1"),
Type: cty.List(cty.String),
Values: []VariableAssignment{
{"default", stringListVal("default_value_1"), nil},
{"env", stringListVal("env_value_1", "env_value_2"), nil},
{"varfile", stringListVal("xy"), nil},
{"varfile", stringListVal("varfile_value_1"), nil},
{"cmd", stringListVal("cmd_value_1"), nil},
},
},
},
wantValues: map[string]cty.Value{
@ -476,8 +594,8 @@ func TestVariables_collectVariableValues(t *testing.T) {
{name: "bool",
variables: Variables{"enabled": &Variable{
DefaultValue: cty.False,
Type: cty.Bool,
Values: []VariableAssignment{{"default", cty.False, nil}},
Type: cty.Bool,
}},
args: args{
env: []string{`PKR_VAR_enabled=true`},
@ -493,11 +611,13 @@ func TestVariables_collectVariableValues(t *testing.T) {
wantDiags: false,
wantVariables: Variables{
"enabled": &Variable{
Type: cty.Bool,
CmdValue: cty.True,
VarfileValue: cty.False,
EnvValue: cty.True,
DefaultValue: cty.False,
Type: cty.Bool,
Values: []VariableAssignment{
{"default", cty.False, nil},
{"env", cty.True, nil},
{"varfile", cty.False, nil},
{"cmd", cty.True, nil},
},
},
},
wantValues: map[string]cty.Value{
@ -507,8 +627,8 @@ func TestVariables_collectVariableValues(t *testing.T) {
{name: "invalid env var",
variables: Variables{"used_string": &Variable{
DefaultValue: cty.StringVal("default_value"),
Type: cty.String,
Values: []VariableAssignment{{"default", cty.StringVal("default_value"), nil}},
Type: cty.String,
}},
args: args{
env: []string{`PKR_VAR_used_string`},
@ -518,8 +638,8 @@ func TestVariables_collectVariableValues(t *testing.T) {
wantDiags: false,
wantVariables: Variables{
"used_string": &Variable{
Type: cty.String,
DefaultValue: cty.StringVal("default_value"),
Type: cty.String,
Values: []VariableAssignment{{"default", cty.StringVal("default_value"), nil}},
},
},
wantValues: map[string]cty.Value{
@ -598,8 +718,8 @@ func TestVariables_collectVariableValues(t *testing.T) {
wantDiagsHasError: true,
wantVariables: Variables{
"used_string": &Variable{
Type: cty.List(cty.String),
EnvValue: cty.DynamicVal,
Type: cty.List(cty.String),
Values: []VariableAssignment{{"env", cty.DynamicVal, nil}},
},
},
wantValues: map[string]cty.Value{
@ -622,8 +742,8 @@ func TestVariables_collectVariableValues(t *testing.T) {
wantDiagsHasError: true,
wantVariables: Variables{
"used_string": &Variable{
Type: cty.Bool,
VarfileValue: cty.DynamicVal,
Type: cty.Bool,
Values: []VariableAssignment{{"varfile", cty.DynamicVal, nil}},
},
},
wantValues: map[string]cty.Value{
@ -648,8 +768,8 @@ func TestVariables_collectVariableValues(t *testing.T) {
wantDiagsHasError: true,
wantVariables: Variables{
"used_string": &Variable{
Type: cty.Bool,
CmdValue: cty.DynamicVal,
Type: cty.Bool,
Values: []VariableAssignment{{"cmd", cty.DynamicVal, nil}},
},
},
wantValues: map[string]cty.Value{
@ -692,7 +812,7 @@ func TestVariables_collectVariableValues(t *testing.T) {
if tt.wantDiagsHasError != gotDiags.HasErrors() {
t.Fatalf("Variables.collectVariableValues() unexpected diagnostics HasErrors. %s", gotDiags)
}
if diff := cmp.Diff(fmt.Sprintf("%#v", tt.wantVariables), fmt.Sprintf("%#v", tt.variables)); diff != "" {
if diff := cmp.Diff(tt.wantVariables, tt.variables, cmpOpts...); diff != "" {
t.Fatalf("didn't get expected variables: %s", diff)
}
values := map[string]cty.Value{}

View File

@ -24,6 +24,8 @@ If a default value is set, the variable is optional. Otherwise, the variable
`@include 'from-1.5/variables/assignment.mdx'`
`@include 'from-1.5/variables/custom-validation.mdx'`
Example of a variable assignment from a file:
`@include 'from-1.5/variables/foo-pkrvar.mdx'`

View File

@ -162,6 +162,8 @@ maintainers, use comments.
The following sections describe these options in more detail.
`@include 'from-1.5/variables/custom-validation.mdx'`
### Variables on the Command Line
To specify individual variables on the command line, use the `-var` option when

View File

@ -0,0 +1,73 @@
## Custom Validation Rules
In addition to Type Constraints, you can specify arbitrary custom validation
rules for a particular variable using one or more `validation` block nested
within the corresponding `variable` block:
```hcl
variable "image_id" {
type = string
description = "The id of the machine image (AMI) to use for the server."
validation {
condition = length(var.image_id) > 4 && substr(var.image_id, 0, 4) == "ami-"
error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"."
}
}
```
The `condition` argument is an expression that must use the value of the
variable to return `true` if the value is valid or `false` if it is invalid.
The expression can refer only to the variable that the condition applies to,
and _must not_ produce errors.
If the failure of an expression is the basis of the validation decision, use
[the `can` function](/docs/from-1.5/functions/conversion/can) to detect such errors. For example:
```hcl
variable "image_id" {
type = string
description = "The id of the machine image (AMI) to use for the server."
validation {
# regex(...) fails if it cannot find a match
condition = can(regex("^ami-", var.image_id))
error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"."
}
}
```
If `condition` evaluates to `false`, an error message including the sentences
given in `error_message` will be produced. The error message string should be
at least one full sentence explaining the constraint that failed, using a
sentence structure similar to the above examples.
Validation also works with more complex cases:
```hcl
variable "image_metadata" {
default = {
key: "value",
something: {
foo: "bar",
}
}
validation {
condition = length(var.image_metadata.key) > 4
error_message = "The image_metadata.key field must be more than 4 runes."
}
validation {
condition = can(var.image_metadata.something.foo)
error_message = "The image_metadata.something.foo field must exist."
}
validation {
condition = substr(var.image_metadata.something.foo, 0, 3) == "bar"
error_message = "The image_metadata.something.foo field must start with \"bar\"."
}
}
```