diff --git a/config/config.go b/config/config.go index 63c5651a5..39f0cce0b 100644 --- a/config/config.go +++ b/config/config.go @@ -15,6 +15,7 @@ type Config struct { ProviderConfigs map[string]*ProviderConfig Resources []*Resource Variables map[string]*Variable + Outputs map[string]*Output } // ProviderConfig is the configuration for a resource provider. @@ -42,6 +43,13 @@ type Variable struct { defaultSet bool } +// Output is an output defined within the configuration. An output is +// resulting data that is highlighted by Terraform when finished. +type Output struct { + Name string + RawConfig *RawConfig +} + // An InterpolatedVariable is a variable that is embedded within a string // in the configuration, such as "hello ${world}" (world in this case is // an interpolated variable). @@ -55,9 +63,10 @@ type InterpolatedVariable interface { // A ResourceVariable is a variable that is referencing the field // of a resource, such as "${aws_instance.foo.ami}" type ResourceVariable struct { - Type string - Name string - Field string + Type string // Resource type, i.e. "aws_instance" + Name string // Resource name + Field string // Resource field + Multi bool // True if multi-variable: aws_instance.foo.*.id key string } @@ -98,17 +107,19 @@ func (c *Config) Validate() error { // Check for references to user variables that do not actually // exist and record those errors. - for source, v := range vars { - uv, ok := v.(*UserVariable) - if !ok { - continue - } + for source, vs := range vars { + for _, v := range vs { + uv, ok := v.(*UserVariable) + if !ok { + continue + } - if _, ok := c.Variables[uv.Name]; !ok { - errs = append(errs, fmt.Errorf( - "%s: unknown variable referenced: %s", - source, - uv.Name)) + if _, ok := c.Variables[uv.Name]; !ok { + errs = append(errs, fmt.Errorf( + "%s: unknown variable referenced: %s", + source, + uv.Name)) + } } } @@ -117,19 +128,36 @@ func (c *Config) Validate() error { for _, r := range c.Resources { resources[r.Id()] = struct{}{} } - for source, v := range vars { - rv, ok := v.(*ResourceVariable) - if !ok { - continue - } + for source, vs := range vars { + for _, v := range vs { + rv, ok := v.(*ResourceVariable) + if !ok { + continue + } - id := fmt.Sprintf("%s.%s", rv.Type, rv.Name) - if _, ok := resources[id]; !ok { + id := fmt.Sprintf("%s.%s", rv.Type, rv.Name) + if _, ok := resources[id]; !ok { + errs = append(errs, fmt.Errorf( + "%s: unknown resource '%s' referenced in variable %s", + source, + id, + rv.FullKey())) + } + } + } + + // Check that all outputs are valid + for _, o := range c.Outputs { + invalid := false + for k, _ := range o.RawConfig.Raw { + if k != "value" { + invalid = true + break + } + } + if invalid { errs = append(errs, fmt.Errorf( - "%s: unknown resource '%s' referenced in variable %s", - source, - id, - rv.FullKey())) + "%s: output should only have 'value' field", o.Name)) } } @@ -143,19 +171,26 @@ func (c *Config) Validate() error { // allVariables is a helper that returns a mapping of all the interpolated // variables within the configuration. This is used to verify references // are valid in the Validate step. -func (c *Config) allVariables() map[string]InterpolatedVariable { - result := make(map[string]InterpolatedVariable) +func (c *Config) allVariables() map[string][]InterpolatedVariable { + result := make(map[string][]InterpolatedVariable) for n, pc := range c.ProviderConfigs { source := fmt.Sprintf("provider config '%s'", n) for _, v := range pc.RawConfig.Variables { - result[source] = v + result[source] = append(result[source], v) } } for _, rc := range c.Resources { source := fmt.Sprintf("resource '%s'", rc.Id()) for _, v := range rc.RawConfig.Variables { - result[source] = v + result[source] = append(result[source], v) + } + } + + for _, o := range c.Outputs { + source := fmt.Sprintf("output '%s'", o.Name) + for _, v := range o.RawConfig.Variables { + result[source] = append(result[source], v) } } @@ -169,10 +204,19 @@ func (v *Variable) Required() bool { func NewResourceVariable(key string) (*ResourceVariable, error) { parts := strings.SplitN(key, ".", 3) + field := parts[2] + multi := false + + if idx := strings.Index(field, "."); idx != -1 && field[:idx] == "*" { + multi = true + field = field[idx+1:] + } + return &ResourceVariable{ Type: parts[0], Name: parts[1], - Field: parts[2], + Field: field, + Multi: multi, key: key, }, nil } diff --git a/config/config_test.go b/config/config_test.go index 4974b7276..613c6a58b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -15,6 +15,13 @@ func TestConfigValidate(t *testing.T) { } } +func TestConfigValidate_outputBadField(t *testing.T) { + c := testConfig(t, "validate-output-bad-field") + if err := c.Validate(); err == nil { + t.Fatal("should not be valid") + } +} + func TestConfigValidate_unknownResourceVar(t *testing.T) { c := testConfig(t, "validate-unknown-resource-var") if err := c.Validate(); err == nil { @@ -22,6 +29,13 @@ func TestConfigValidate_unknownResourceVar(t *testing.T) { } } +func TestConfigValidate_unknownResourceVar_output(t *testing.T) { + c := testConfig(t, "validate-unknown-resource-var-output") + if err := c.Validate(); err == nil { + t.Fatal("should not be valid") + } +} + func TestConfigValidate_unknownVar(t *testing.T) { c := testConfig(t, "validate-unknownvar") if err := c.Validate(); err == nil { @@ -44,12 +58,35 @@ func TestNewResourceVariable(t *testing.T) { if v.Field != "baz" { t.Fatalf("bad: %#v", v) } + if v.Multi { + t.Fatal("should not be multi") + } if v.FullKey() != "foo.bar.baz" { t.Fatalf("bad: %#v", v) } } +func TestResourceVariable_Multi(t *testing.T) { + v, err := NewResourceVariable("foo.bar.*.baz") + if err != nil { + t.Fatalf("err: %s", err) + } + + if v.Type != "foo" { + t.Fatalf("bad: %#v", v) + } + if v.Name != "bar" { + t.Fatalf("bad: %#v", v) + } + if v.Field != "baz" { + t.Fatalf("bad: %#v", v) + } + if !v.Multi { + t.Fatal("should be multi") + } +} + func TestNewUserVariable(t *testing.T) { v, err := NewUserVariable("var.bar") if err != nil { diff --git a/config/loader_libucl.go b/config/loader_libucl.go index 4d5644a26..bf219f0b0 100644 --- a/config/loader_libucl.go +++ b/config/loader_libucl.go @@ -78,6 +78,16 @@ func (t *libuclConfigurable) Config() (*Config, error) { } } + // Build the outputs + if outputs := t.Object.Get("output"); outputs != nil { + var err error + config.Outputs, err = loadOutputsLibucl(outputs) + outputs.Close() + if err != nil { + return nil, err + } + } + return config, nil } @@ -145,6 +155,52 @@ func loadFileLibucl(root string) (configurable, []string, error) { return result, importPaths, nil } +// LoadOutputsLibucl recurses into the given libucl object and turns +// it into a mapping of outputs. +func loadOutputsLibucl(o *libucl.Object) (map[string]*Output, error) { + objects := make(map[string]*libucl.Object) + + // Iterate over all the "output" blocks and get the keys along with + // their raw configuration objects. We'll parse those later. + iter := o.Iterate(false) + for o1 := iter.Next(); o1 != nil; o1 = iter.Next() { + iter2 := o1.Iterate(true) + for o2 := iter2.Next(); o2 != nil; o2 = iter2.Next() { + objects[o2.Key()] = o2 + defer o2.Close() + } + + o1.Close() + iter2.Close() + } + iter.Close() + + // Go through each object and turn it into an actual result. + result := make(map[string]*Output) + for n, o := range objects { + var config map[string]interface{} + + if err := o.Decode(&config); err != nil { + return nil, err + } + + rawConfig, err := NewRawConfig(config) + if err != nil { + return nil, fmt.Errorf( + "Error reading config for output %s: %s", + n, + err) + } + + result[n] = &Output{ + Name: n, + RawConfig: rawConfig, + } + } + + return result, nil +} + // LoadProvidersLibucl recurses into the given libucl object and turns // it into a mapping of provider configs. func loadProvidersLibucl(o *libucl.Object) (map[string]*ProviderConfig, error) { diff --git a/config/loader_test.go b/config/loader_test.go index b9340d3a0..362d90989 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -39,6 +39,11 @@ func TestLoadBasic(t *testing.T) { if actual != strings.TrimSpace(basicResourcesStr) { t.Fatalf("bad:\n%s", actual) } + + actual = outputsStr(c.Outputs) + if actual != strings.TrimSpace(basicOutputsStr) { + t.Fatalf("bad:\n%s", actual) + } } func TestLoadBasic_import(t *testing.T) { @@ -92,6 +97,40 @@ func TestLoad_variables(t *testing.T) { } } +func outputsStr(os map[string]*Output) string { + ns := make([]string, 0, len(os)) + for n, _ := range os { + ns = append(ns, n) + } + sort.Strings(ns) + + result := "" + for _, n := range ns { + o := os[n] + + result += fmt.Sprintf("%s\n", n) + + if len(o.RawConfig.Variables) > 0 { + result += fmt.Sprintf(" vars\n") + for _, rawV := range o.RawConfig.Variables { + kind := "unknown" + str := rawV.FullKey() + + switch rawV.(type) { + case *ResourceVariable: + kind = "resource" + case *UserVariable: + kind = "user" + } + + result += fmt.Sprintf(" %s: %s\n", kind, str) + } + } + } + + return strings.TrimSpace(result) +} + // This helper turns a provider configs field into a deterministic // string value for comparison in tests. func providerConfigsStr(pcs map[string]*ProviderConfig) string { @@ -219,6 +258,12 @@ func variablesStr(vs map[string]*Variable) string { return strings.TrimSpace(result) } +const basicOutputsStr = ` +web_ip + vars + resource: aws_instance.web.private_ip +` + const basicProvidersStr = ` aws access_key diff --git a/config/test-fixtures/basic.tf b/config/test-fixtures/basic.tf index 07aa5ff95..84cbd8b31 100644 --- a/config/test-fixtures/basic.tf +++ b/config/test-fixtures/basic.tf @@ -32,3 +32,7 @@ resource aws_instance "web" { resource "aws_instance" "db" { security_groups = "${aws_security_group.firewall.*.id}" } + +output "web_ip" { + value = "${aws_instance.web.private_ip}" +} diff --git a/config/test-fixtures/validate-output-bad-field/main.tf b/config/test-fixtures/validate-output-bad-field/main.tf new file mode 100644 index 000000000..dae3781a4 --- /dev/null +++ b/config/test-fixtures/validate-output-bad-field/main.tf @@ -0,0 +1,7 @@ +resource "aws_instance" "web" { +} + +output "ip" { + value = "foo" + another = "nope" +} diff --git a/config/test-fixtures/validate-unknown-resource-var-output/main.tf b/config/test-fixtures/validate-unknown-resource-var-output/main.tf new file mode 100644 index 000000000..d79c65712 --- /dev/null +++ b/config/test-fixtures/validate-unknown-resource-var-output/main.tf @@ -0,0 +1,6 @@ +resource "aws_instance" "web" { +} + +output "ip" { + value = "${aws_instance.loadbalancer.foo}" +} diff --git a/terraform/context.go b/terraform/context.go index 66e553738..a0c35772e 100644 --- a/terraform/context.go +++ b/terraform/context.go @@ -105,6 +105,18 @@ func (c *Context) Apply() (*State, error) { // Update our state, even if we have an error, for partial updates c.state = s + // If we have no errors, then calculate the outputs if we have any + if err == nil && len(c.config.Outputs) > 0 { + s.Outputs = make(map[string]string) + for _, o := range c.config.Outputs { + if err = c.computeVars(o.RawConfig); err != nil { + break + } + + s.Outputs[o.Name] = o.RawConfig.Config()["value"].(string) + } + } + return s, err } @@ -232,6 +244,110 @@ func (c *Context) Validate() ([]string, []error) { return warns, errs } +// computeVars takes the State and given RawConfig and processes all +// the variables. This dynamically discovers the attributes instead of +// using a static map[string]string that the genericWalkFn uses. +func (c *Context) computeVars(raw *config.RawConfig) error { + // If there are on variables, then we're done + if len(raw.Variables) == 0 { + return nil + } + + // Go through each variable and find it + vs := make(map[string]string) + for n, rawV := range raw.Variables { + switch v := rawV.(type) { + case *config.ResourceVariable: + var attr string + var err error + if v.Multi { + attr, err = c.computeResourceMultiVariable(v) + } else { + attr, err = c.computeResourceVariable(v) + } + if err != nil { + return err + } + + vs[n] = attr + case *config.UserVariable: + vs[n] = c.variables[v.Name] + } + } + + // Interpolate the variables + return raw.Interpolate(vs) +} + +func (c *Context) computeResourceVariable( + v *config.ResourceVariable) (string, error) { + r, ok := c.state.Resources[v.ResourceId()] + if !ok { + return "", fmt.Errorf( + "Resource '%s' not found for variable '%s'", + v.ResourceId(), + v.FullKey()) + } + + attr, ok := r.Attributes[v.Field] + if !ok { + return "", fmt.Errorf( + "Resource '%s' does not have attribute '%s' "+ + "for variable '%s'", + v.ResourceId(), + v.Field, + v.FullKey()) + } + + return attr, nil +} + +func (c *Context) computeResourceMultiVariable( + v *config.ResourceVariable) (string, error) { + // Get the resource from the configuration so we can know how + // many of the resource there is. + var cr *config.Resource + for _, r := range c.config.Resources { + if r.Id() == v.ResourceId() { + cr = r + break + } + } + if cr == nil { + return "", fmt.Errorf( + "Resource '%s' not found for variable '%s'", + v.ResourceId(), + v.FullKey()) + } + + var values []string + for i := 0; i < cr.Count; i++ { + id := fmt.Sprintf("%s.%d", v.ResourceId(), i) + r, ok := c.state.Resources[id] + if !ok { + continue + } + + attr, ok := r.Attributes[v.Field] + if !ok { + continue + } + + values = append(values, attr) + } + + if len(values) == 0 { + return "", fmt.Errorf( + "Resource '%s' does not have attribute '%s' "+ + "for variable '%s'", + v.ResourceId(), + v.Field, + v.FullKey()) + } + + return strings.Join(values, ","), nil +} + func (c *Context) graph() (*depgraph.Graph, error) { return Graph(&GraphOpts{ Config: c.config, @@ -647,17 +763,9 @@ func computeAggregateVars( if !ok { continue } - - idx := strings.Index(rv.Field, ".") - if idx == -1 { - // It isn't an aggregated var + if !rv.Multi { continue } - if rv.Field[:idx] != "*" { - // It isn't an aggregated var - continue - } - field := rv.Field[idx+1:] // Get the meta node so that we can determine the count key := fmt.Sprintf("%s.%s", rv.Type, rv.Name) @@ -677,7 +785,7 @@ func computeAggregateVars( rv.Type, rv.Name, i, - field) + rv.Field) if v, ok := vs[key]; ok { values = append(values, v) } diff --git a/terraform/context_test.go b/terraform/context_test.go index c67faec6c..e0da66d3e 100644 --- a/terraform/context_test.go +++ b/terraform/context_test.go @@ -508,6 +508,62 @@ func TestContextApply_hook(t *testing.T) { } } +func TestContextApply_output(t *testing.T) { + c := testConfig(t, "apply-output") + p := testProvider("aws") + p.ApplyFn = testApplyFn + p.DiffFn = testDiffFn + ctx := testContext(t, &ContextOpts{ + Config: c, + Providers: map[string]ResourceProviderFactory{ + "aws": testProviderFuncFixed(p), + }, + }) + + if _, err := ctx.Plan(nil); err != nil { + t.Fatalf("err: %s", err) + } + + state, err := ctx.Apply() + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(state.String()) + expected := strings.TrimSpace(testTerraformApplyOutputStr) + if actual != expected { + t.Fatalf("bad: \n%s", actual) + } +} + +func TestContextApply_outputMulti(t *testing.T) { + c := testConfig(t, "apply-output-multi") + p := testProvider("aws") + p.ApplyFn = testApplyFn + p.DiffFn = testDiffFn + ctx := testContext(t, &ContextOpts{ + Config: c, + Providers: map[string]ResourceProviderFactory{ + "aws": testProviderFuncFixed(p), + }, + }) + + if _, err := ctx.Plan(nil); err != nil { + t.Fatalf("err: %s", err) + } + + state, err := ctx.Apply() + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(state.String()) + expected := strings.TrimSpace(testTerraformApplyOutputMultiStr) + if actual != expected { + t.Fatalf("bad: \n%s", actual) + } +} + func TestContextApply_unknownAttribute(t *testing.T) { c := testConfig(t, "apply-unknown") p := testProvider("aws") diff --git a/terraform/state.go b/terraform/state.go index 104459155..ae50f562a 100644 --- a/terraform/state.go +++ b/terraform/state.go @@ -16,6 +16,7 @@ import ( // can use to keep track of what real world resources it is actually // managing. type State struct { + Outputs map[string]string Resources map[string]*ResourceState once sync.Once @@ -96,6 +97,21 @@ func (s *State) String() string { } } + if len(s.Outputs) > 0 { + buf.WriteString("\nOutputs:\n\n") + + ks := make([]string, 0, len(s.Outputs)) + for k, _ := range s.Outputs { + ks = append(ks, k) + } + sort.Strings(ks) + + for _, k := range ks { + v := s.Outputs[k] + buf.WriteString(fmt.Sprintf("%s = %s\n", k, v)) + } + } + return buf.String() } diff --git a/terraform/terraform_test.go b/terraform/terraform_test.go index dfb0f0e37..ce2b58733 100644 --- a/terraform/terraform_test.go +++ b/terraform/terraform_test.go @@ -106,6 +106,44 @@ aws_instance.foo: num = 2 ` +const testTerraformApplyOutputStr = ` +aws_instance.bar: + ID = foo + foo = bar + type = aws_instance +aws_instance.foo: + ID = foo + num = 2 + type = aws_instance + +Outputs: + +foo_num = 2 +` + +const testTerraformApplyOutputMultiStr = ` +aws_instance.bar.0: + ID = foo + foo = bar + type = aws_instance +aws_instance.bar.1: + ID = foo + foo = bar + type = aws_instance +aws_instance.bar.2: + ID = foo + foo = bar + type = aws_instance +aws_instance.foo: + ID = foo + num = 2 + type = aws_instance + +Outputs: + +foo_num = bar,bar,bar +` + const testTerraformApplyUnknownAttrStr = ` aws_instance.foo: ID = foo diff --git a/terraform/test-fixtures/apply-output-multi/main.tf b/terraform/test-fixtures/apply-output-multi/main.tf new file mode 100644 index 000000000..40003342e --- /dev/null +++ b/terraform/test-fixtures/apply-output-multi/main.tf @@ -0,0 +1,12 @@ +resource "aws_instance" "foo" { + num = "2" +} + +resource "aws_instance" "bar" { + foo = "bar" + count = 3 +} + +output "foo_num" { + value = "${aws_instance.bar.*.foo}" +} diff --git a/terraform/test-fixtures/apply-output/main.tf b/terraform/test-fixtures/apply-output/main.tf new file mode 100644 index 000000000..4d8b0c4f0 --- /dev/null +++ b/terraform/test-fixtures/apply-output/main.tf @@ -0,0 +1,11 @@ +resource "aws_instance" "foo" { + num = "2" +} + +resource "aws_instance" "bar" { + foo = "bar" +} + +output "foo_num" { + value = "${aws_instance.foo.num}" +}