From 126e5f337f15ff1fc75ce3f0901c9ae8bcdf0841 Mon Sep 17 00:00:00 2001 From: Kristin Laemmert Date: Wed, 19 Dec 2018 11:08:25 -0800 Subject: [PATCH] json output of terraform plan (#19687) * command/show: adding functions to aid refactoring The planfile -> statefile -> state logic path was getting hard to follow with blurry human eyes. The getPlan... and getState... functions were added to help streamline the logic flow. Continued refactoring may follow. * command/show: use ctx.Config() instead of a config snapshot As originally written, the jsonconfig marshaller was getting an error when loading configs that included one or more modules. It's not clear if that was an error in the function call or in the configloader itself, but as a simpler solution existed I did not dig too far. * command/jsonplan: implement jsonplan.Marshal Split the `config` portion into a discrete package to aid in naming sanity (so we could have for example jsonconfig.Resource instead of jsonplan.ConfigResource) and to enable marshaling the config on it's own. --- command/jsonconfig/config.go | 248 ++++++++++++++++++++++++++++ command/jsonconfig/doc.go | 3 + command/jsonconfig/expression.go | 119 ++++++++++++++ command/jsonplan/doc.go | 3 + command/jsonplan/module.go | 14 ++ command/jsonplan/plan.go | 253 +++++++++++++++++++++++++++++ command/jsonplan/resource.go | 63 +++++++ command/jsonplan/values.go | 253 +++++++++++++++++++++++++++++ command/jsonstate/expression.go | 25 +++ command/jsonstate/state.go | 108 ++++++++++++ command/show.go | 159 ++++++++++++------ command/show_test.go | 124 ++++++++++++++ command/test-fixtures/show/main.tf | 3 + 13 files changed, 1324 insertions(+), 51 deletions(-) create mode 100644 command/jsonconfig/config.go create mode 100644 command/jsonconfig/doc.go create mode 100644 command/jsonconfig/expression.go create mode 100644 command/jsonplan/doc.go create mode 100644 command/jsonplan/module.go create mode 100644 command/jsonplan/plan.go create mode 100644 command/jsonplan/resource.go create mode 100644 command/jsonplan/values.go create mode 100644 command/jsonstate/expression.go create mode 100644 command/jsonstate/state.go create mode 100644 command/test-fixtures/show/main.tf diff --git a/command/jsonconfig/config.go b/command/jsonconfig/config.go new file mode 100644 index 000000000..1ca605c5a --- /dev/null +++ b/command/jsonconfig/config.go @@ -0,0 +1,248 @@ +package jsonconfig + +import ( + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/configs" + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/terraform" + "github.com/zclconf/go-cty/cty" +) + +// Config represents the complete configuration source +type config struct { + ProviderConfigs map[string]providerConfig `json:"provider_config,omitempty"` + RootModule module `json:"root_module,omitempty"` +} + +// ProviderConfig describes all of the provider configurations throughout the +// configuration tree, flattened into a single map for convenience since +// provider configurations are the one concept in Terraform that can span across +// module boundaries. +type providerConfig struct { + Name string `json:"name,omitempty"` + Alias string `json:"alias,omitempty"` + ModuleAddress string `json:"module_address,omitempty"` + Expressions map[string]interface{} `json:"expressions,omitempty"` +} + +type module struct { + Outputs map[string]configOutput `json:"outputs,omitempty"` + Resources []resource `json:"resources,omitempty"` + ModuleCalls []moduleCall `json:"module_calls,omitempty"` +} + +type moduleCall struct { + ResolvedSource string `json:"resolved_source,omitempty"` + Expressions map[string]interface{} `json:"expressions,omitempty"` + CountExpression expression `json:"count_expression,omitempty"` + ForEachExpression expression `json:"for_each_expression,omitempty"` + Module module `json:"module,omitempty"` +} + +// Resource is the representation of a resource in the config +type resource struct { + // Address is the absolute resource address + Address string `json:"address,omitempty"` + + // Mode can be "managed" or "data" + Mode string `json:"mode,omitempty"` + + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + + // ProviderConfigKey is the key into "provider_configs" (shown above) for + // the provider configuration that this resource is associated with. + ProviderConfigKey string `json:"provider_config_key,omitempty"` + + // Provisioners is an optional field which describes any provisioners. + // Connection info will not be included here. + Provisioners []provisioner `json:"provisioners,omitempty"` + + // Expressions" describes the resource-type-specific content of the + // configuration block. + Expressions map[string]interface{} `json:"expressions,omitempty"` + + // SchemaVersion indicates which version of the resource type schema the + // "values" property conforms to. + SchemaVersion uint64 `json:"schema_version"` + + // CountExpression and ForEachExpression describe the expressions given for + // the corresponding meta-arguments in the resource configuration block. + // These are omitted if the corresponding argument isn't set. + CountExpression expression `json:"count_expression,omitempty"` + ForEachExpression expression `json:"for_each_expression,omitempty"` +} + +type configOutput struct { + Sensitive bool `json:"sensitive,omitempty"` + Expression expression `json:"expression,omitempty"` +} + +type provisioner struct { + Name string `json:"name,omitempty"` + Expressions map[string]interface{} `json:"expressions,omitempty"` +} + +// Marshal returns the json encoding of terraform configuration. +func Marshal(c *configs.Config, schemas *terraform.Schemas) ([]byte, error) { + var output config + + pcs := make(map[string]providerConfig) + marshalProviderConfigs(c, schemas, pcs) + output.ProviderConfigs = pcs + + rootModule, err := marshalModule(c, schemas) + if err != nil { + return nil, err + } + output.RootModule = rootModule + + ret, err := json.Marshal(output) + return ret, err +} + +func marshalProviderConfigs( + c *configs.Config, + schemas *terraform.Schemas, + m map[string]providerConfig, +) { + if c == nil { + return + } + + for _, pc := range c.Module.ProviderConfigs { + schema := schemas.ProviderConfig(pc.Name) + m[pc.Name] = providerConfig{ + Name: pc.Name, + Alias: pc.Alias, + ModuleAddress: c.Path.String(), + Expressions: marshalExpressions(pc.Config, schema), + } + } + + // Must also visit our child modules, recursively. + for _, cc := range c.Children { + marshalProviderConfigs(cc, schemas, m) + } +} + +func marshalModule(c *configs.Config, schemas *terraform.Schemas) (module, error) { + var module module + var rs []resource + + managedResources, err := marshalResources(c.Module.ManagedResources, schemas) + if err != nil { + return module, err + } + dataResources, err := marshalResources(c.Module.DataResources, schemas) + if err != nil { + return module, err + } + + rs = append(managedResources, dataResources...) + module.Resources = rs + + outputs := make(map[string]configOutput) + for _, v := range c.Module.Outputs { + outputs[v.Name] = configOutput{ + Sensitive: v.Sensitive, + Expression: marshalExpression(v.Expr), + } + } + module.Outputs = outputs + module.ModuleCalls = marshalModuleCalls(c, schemas) + return module, nil +} + +func marshalModuleCalls(c *configs.Config, schemas *terraform.Schemas) []moduleCall { + var ret []moduleCall + for _, v := range c.Module.ModuleCalls { + mc := moduleCall{ + ResolvedSource: v.SourceAddr, + } + cExp := marshalExpression(v.Count) + if !cExp.Empty() { + mc.CountExpression = cExp + } else { + fExp := marshalExpression(v.ForEach) + if !fExp.Empty() { + mc.ForEachExpression = fExp + } + } + + schema := &configschema.Block{} + schema.Attributes = make(map[string]*configschema.Attribute) + for _, variable := range c.Module.Variables { + schema.Attributes[variable.Name] = &configschema.Attribute{ + Required: variable.Default == cty.NilVal, + } + } + mc.Expressions = marshalExpressions(v.Config, schema) + + for _, cc := range c.Children { + childModule, _ := marshalModule(cc, schemas) + mc.Module = childModule + } + ret = append(ret, mc) + + } + + return ret + +} + +func marshalResources(resources map[string]*configs.Resource, schemas *terraform.Schemas) ([]resource, error) { + var rs []resource + for _, v := range resources { + r := resource{ + Address: v.Addr().String(), + Type: v.Type, + Name: v.Name, + ProviderConfigKey: v.ProviderConfigAddr().String(), + } + + switch v.Mode { + case addrs.ManagedResourceMode: + r.Mode = "managed" + case addrs.DataResourceMode: + r.Mode = "data" + default: + return rs, fmt.Errorf("resource %s has an unsupported mode %s", r.Address, v.Mode.String()) + } + + cExp := marshalExpression(v.Count) + if !cExp.Empty() { + r.CountExpression = cExp + } else { + fExp := marshalExpression(v.ForEach) + if !fExp.Empty() { + r.ForEachExpression = fExp + } + } + + schema, schemaVersion := schemas.ResourceTypeConfig(v.ProviderConfigAddr().String(), v.Mode, v.Type) + r.SchemaVersion = schemaVersion + + r.Expressions = marshalExpressions(v.Config, schema) + + // Managed is populated only for Mode = addrs.ManagedResourceMode + if v.Managed != nil && len(v.Managed.Provisioners) > 0 { + var provisioners []provisioner + for _, p := range v.Managed.Provisioners { + schema := schemas.ProvisionerConfig(p.Type) + prov := provisioner{ + Name: p.Type, + Expressions: marshalExpressions(p.Config, schema), + } + provisioners = append(provisioners, prov) + } + r.Provisioners = provisioners + } + + rs = append(rs, r) + } + return rs, nil +} diff --git a/command/jsonconfig/doc.go b/command/jsonconfig/doc.go new file mode 100644 index 000000000..28324a578 --- /dev/null +++ b/command/jsonconfig/doc.go @@ -0,0 +1,3 @@ +// Package jsonconfig implements methods for outputting a configuration snapshot +// in machine-readable json format +package jsonconfig diff --git a/command/jsonconfig/expression.go b/command/jsonconfig/expression.go new file mode 100644 index 000000000..3e940ae9c --- /dev/null +++ b/command/jsonconfig/expression.go @@ -0,0 +1,119 @@ +package jsonconfig + +import ( + "encoding/json" + + "github.com/hashicorp/hcl2/hcl" + "github.com/hashicorp/hcl2/hcldec" + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/lang" + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" +) + +// expression represents any unparsed expression +type expression struct { + // "constant_value" is set only if the expression contains no references to + // other objects, in which case it gives the resulting constant value. This + // is mapped as for the individual values in the common value + // representation. + ConstantValue json.RawMessage `json:"constant_value,omitempty"` + + // Alternatively, "references" will be set to a list of references in the + // expression. Multi-step references will be unwrapped and duplicated for + // each significant traversal step, allowing callers to more easily + // recognize the objects they care about without attempting to parse the + // expressions. Callers should only use string equality checks here, since + // the syntax may be extended in future releases. + References []string `json:"references,omitempty"` +} + +func marshalExpression(ex hcl.Expression) expression { + var ret expression + if ex != nil { + val, _ := ex.Value(nil) + if val != cty.NilVal { + valJSON, _ := ctyjson.Marshal(val, val.Type()) + ret.ConstantValue = valJSON + } + vars, _ := lang.ReferencesInExpr(ex) + var varString []string + if len(vars) > 0 { + for _, v := range vars { + varString = append(varString, v.Subject.String()) + } + ret.References = varString + } + return ret + } + return ret +} + +func (e *expression) Empty() bool { + return e.ConstantValue == nil && e.References == nil +} + +// expressions is used to represent the entire content of a block. Attribute +// arguments are mapped directly with the attribute name as key and an +// expression as value. +type expressions map[string]interface{} + +func marshalExpressions(body hcl.Body, schema *configschema.Block) expressions { + // Since we want the raw, un-evaluated expressions we need to use the + // low-level HCL API here, rather than the hcldec decoder API. That means we + // need the low-level schema. + lowSchema := hcldec.ImpliedSchema(schema.DecoderSpec()) + // (lowSchema is an hcl.BodySchema: + // https://godoc.org/github.com/hashicorp/hcl2/hcl#BodySchema ) + + // Use the low-level schema with the body to decode one level We'll just + // ignore any additional content that's not covered by the schema, which + // will effectively ignore "dynamic" blocks, and may also ignore other + // unknown stuff but anything else would get flagged by Terraform as an + // error anyway, and so we wouldn't end up in here. + content, _, _ := body.PartialContent(lowSchema) + if content == nil { + // Should never happen for a valid body, but we'll just generate empty + // if there were any problems. + return nil + } + + ret := make(expressions) + + // Any attributes we encode directly as expression objects. + for name, attr := range content.Attributes { + ret[name] = marshalExpression(attr.Expr) // note: singular expression for this one + } + + // Any nested blocks require a recursive call to produce nested expressions + // objects. + for _, block := range content.Blocks { + typeName := block.Type + blockS, exists := schema.BlockTypes[typeName] + if !exists { + // Should never happen since only block types in the schema would be + // put in blocks list + continue + } + + switch blockS.Nesting { + case configschema.NestingSingle: + ret[typeName] = marshalExpressions(block.Body, &blockS.Block) + case configschema.NestingList, configschema.NestingSet: + if _, exists := ret[typeName]; !exists { + ret[typeName] = make([]map[string]interface{}, 0, 1) + } + ret[typeName] = append(ret[typeName].([]map[string]interface{}), marshalExpressions(block.Body, &blockS.Block)) + case configschema.NestingMap: + if _, exists := ret[typeName]; !exists { + ret[typeName] = make(map[string]map[string]interface{}) + } + // NestingMap blocks always have the key in the first (and only) label + key := block.Labels[0] + retMap := ret[typeName].(map[string]map[string]interface{}) + retMap[key] = marshalExpressions(block.Body, &blockS.Block) + } + } + + return ret +} diff --git a/command/jsonplan/doc.go b/command/jsonplan/doc.go new file mode 100644 index 000000000..db1f3fb0c --- /dev/null +++ b/command/jsonplan/doc.go @@ -0,0 +1,3 @@ +// Package jsonplan implements methods for outputting a plan in a +// machine-readable json format +package jsonplan diff --git a/command/jsonplan/module.go b/command/jsonplan/module.go new file mode 100644 index 000000000..4531429e9 --- /dev/null +++ b/command/jsonplan/module.go @@ -0,0 +1,14 @@ +package jsonplan + +// module is the representation of a module in state. This can be the root +// module or a child module. +type module struct { + Resources []resource `json:"resources,omitempty"` + + // Address is the absolute module address, omitted for the root module + Address string `json:"address,omitempty"` + + // Each module object can optionally have its own nested "child_modules", + // recursively describing the full module tree. + ChildModules []module `json:"child_modules,omitempty"` +} diff --git a/command/jsonplan/plan.go b/command/jsonplan/plan.go new file mode 100644 index 000000000..d069c436f --- /dev/null +++ b/command/jsonplan/plan.go @@ -0,0 +1,253 @@ +package jsonplan + +import ( + "encoding/json" + "fmt" + + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/command/jsonconfig" + "github.com/hashicorp/terraform/command/jsonstate" + "github.com/hashicorp/terraform/configs" + "github.com/hashicorp/terraform/plans" + "github.com/hashicorp/terraform/states" + "github.com/hashicorp/terraform/terraform" + + ctyjson "github.com/zclconf/go-cty/cty/json" +) + +// FormatVersion represents the version of the json format and will be +// incremented for any change to this format that requires changes to a +// consuming parser. +const FormatVersion = "0.1" + +// Plan is the top-level representation of the json format of a plan. It includes +// the complete config and current state. +type plan struct { + FormatVersion string `json:"format_version,omitempty"` + PlannedValues stateValues `json:"planned_values,omitempty"` + ProposedUnknown stateValues `json:"proposed_unknown,omitempty"` + ResourceChanges []resourceChange `json:"resource_changes,omitempty"` + OutputChanges map[string]change `json:"output_changes,omitempty"` + PriorState json.RawMessage `json:"prior_state,omitempty"` + Config json.RawMessage `json:"configuration,omitempty"` +} + +func newPlan() *plan { + return &plan{ + FormatVersion: FormatVersion, + } +} + +// Change is the representation of a proposed change for an object. +type change struct { + // Actions are the actions that will be taken on the object selected by the + // properties below. Valid actions values are: + // ["no-op"] + // ["create"] + // ["read"] + // ["update"] + // ["delete", "create"] + // ["create", "delete"] + // ["delete"] + // The two "replace" actions are represented in this way to allow callers to + // e.g. just scan the list for "delete" to recognize all three situations + // where the object will be deleted, allowing for any new deletion + // combinations that might be added in future. + Actions []string `json:"actions,omitempty"` + + // Before and After are representations of the object value both before and + // after the action. For ["create"] and ["delete"] actions, either "before" + // or "after" is unset (respectively). For ["no-op"], the before and after + // values are identical. The "after" value will be incomplete if there are + // values within it that won't be known until after apply. + Before json.RawMessage `json:"before,omitempty"` + After json.RawMessage `json:"after,omitempty"` +} + +type output struct { + Sensitive bool `json:"sensitive,omitempty"` + Value json.RawMessage `json:"value,omitempty"` +} + +// Marshal returns the json encoding of a terraform plan. +func Marshal( + config *configs.Config, + p *plans.Plan, + s *states.State, + schemas *terraform.Schemas, +) ([]byte, error) { + + output := newPlan() + + // marshalPlannedValues populates both PlannedValues and ProposedUnknowns + err := output.marshalPlannedValues(p.Changes, schemas) + if err != nil { + return nil, fmt.Errorf("error in marshalPlannedValues: %s", err) + } + + // output.ResourceChanges + err = output.marshalResourceChanges(p.Changes, schemas) + if err != nil { + return nil, fmt.Errorf("error in marshalResourceChanges: %s", err) + } + + // output.OutputChanges + err = output.marshalOutputChanges(p.Changes) + if err != nil { + return nil, fmt.Errorf("error in marshaling output changes: %s", err) + } + + // output.PriorState + output.PriorState, err = jsonstate.Marshal(s) + if err != nil { + return nil, fmt.Errorf("error marshaling prior state: %s", err) + } + + // output.Config + output.Config, err = jsonconfig.Marshal(config, schemas) + if err != nil { + return nil, fmt.Errorf("error marshaling config: %s", err) + } + + // add some polish + ret, err := json.MarshalIndent(output, "", " ") + return ret, err +} + +func (p *plan) marshalResourceChanges(changes *plans.Changes, schemas *terraform.Schemas) error { + if changes == nil { + // Nothing to do! + return nil + } + for _, rc := range changes.Resources { + var r resourceChange + addr := rc.Addr + r.Address = addr.String() + + dataSource := addr.Resource.Resource.Mode == addrs.DataResourceMode + // We create "delete" actions for data resources so we can clean up + // their entries in state, but this is an implementation detail that + // users shouldn't see. + if dataSource && rc.Action == plans.Delete { + continue + } + + schema, _ := schemas.ResourceTypeConfig(rc.ProviderAddr.ProviderConfig.StringCompact(), addr.Resource.Resource.Mode, addr.Resource.Resource.Type) + if schema == nil { + return fmt.Errorf("no schema found for %s", r.Address) + } + + changeV, err := rc.Decode(schema.ImpliedType()) + if err != nil { + return err + } + + var before, after []byte + if changeV.Before != cty.NilVal { + before, err = ctyjson.Marshal(changeV.Before, changeV.Before.Type()) + if err != nil { + return err + } + } + if changeV.After != cty.NilVal { + if changeV.After.IsWhollyKnown() { + after, err = ctyjson.Marshal(changeV.After, changeV.After.Type()) + if err != nil { + return err + } + } else { + // TODO: what is the expected value if after is not known? + } + } + + r.Change = change{ + Actions: []string{rc.Action.String()}, + Before: json.RawMessage(before), + After: json.RawMessage(after), + } + r.Deposed = rc.DeposedKey == states.NotDeposed + + key := addr.Resource.Key + if key != nil { + r.Index = key + } + + switch addr.Resource.Resource.Mode { + case addrs.ManagedResourceMode: + r.Mode = "managed" + case addrs.DataResourceMode: + r.Mode = "data" + default: + return fmt.Errorf("resource %s has an unsupported mode %s", r.Address, addr.Resource.Resource.Mode.String()) + } + r.ModuleAddress = addr.Module.String() + r.Name = addr.Resource.Resource.Name + r.Type = addr.Resource.Resource.Type + + p.ResourceChanges = append(p.ResourceChanges, r) + + } + + return nil +} + +func (p *plan) marshalOutputChanges(changes *plans.Changes) error { + if changes == nil { + // Nothing to do! + return nil + } + + p.OutputChanges = make(map[string]change, len(changes.Outputs)) + for _, oc := range changes.Outputs { + changeV, err := oc.Decode() + if err != nil { + return err + } + + var before, after []byte + if changeV.Before != cty.NilVal { + before, err = ctyjson.Marshal(changeV.Before, changeV.Before.Type()) + if err != nil { + return err + } + } + if changeV.After != cty.NilVal { + if changeV.After.IsWhollyKnown() { + after, err = ctyjson.Marshal(changeV.After, changeV.After.Type()) + if err != nil { + return err + } + } + } + + var c change + c.Actions = []string{oc.Action.String()} + c.Before = json.RawMessage(before) + c.After = json.RawMessage(after) + p.OutputChanges[oc.Addr.OutputValue.Name] = c + } + + return nil +} + +func (p *plan) marshalPlannedValues(changes *plans.Changes, schemas *terraform.Schemas) error { + // marshal the planned changes into a module + plan, unknownValues, err := marshalPlannedValues(changes, schemas) + if err != nil { + return err + } + p.PlannedValues.RootModule = plan + p.ProposedUnknown.RootModule = unknownValues + + // marshalPlannedOutputs + outputs, unknownOutputs, err := marshalPlannedOutputs(changes) + if err != nil { + return err + } + p.PlannedValues.Outputs = outputs + p.ProposedUnknown.Outputs = unknownOutputs + + return nil +} diff --git a/command/jsonplan/resource.go b/command/jsonplan/resource.go new file mode 100644 index 000000000..65ce5911e --- /dev/null +++ b/command/jsonplan/resource.go @@ -0,0 +1,63 @@ +package jsonplan + +import ( + "github.com/hashicorp/terraform/addrs" +) + +// Resource is the representation of a resource in the json plan +type resource struct { + // Address is the absolute resource address + Address string `json:"address,omitempty"` + + // Mode can be "managed" or "data" + Mode string `json:"mode,omitempty"` + + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + + // Index is omitted for a resource not using `count` or `for_each` + Index addrs.InstanceKey `json:"index,omitempty"` + + // ProviderName allows the property "type" to be interpreted unambiguously + // in the unusual situation where a provider offers a resource type whose + // name does not start with its own name, such as the "googlebeta" provider + // offering "google_compute_instance". + ProviderName string `json:"provider_name,omitempty"` + + // SchemaVersion indicates which version of the resource type schema the + // "values" property conforms to. + SchemaVersion uint64 `json:"schema_version"` + + // AttributeValues is the JSON representation of the attribute values of the + // resource, whose structure depends on the resource type schema. Any + // unknown values are omitted or set to null, making them indistinguishable + // from absent values. + AttributeValues attributeValues `json:"values,omitempty"` +} + +// resourceChange is a description of an individual change action that Terraform +// plans to use to move from the prior state to a new state matching the +// configuration. +type resourceChange struct { + // Address is the absolute resource address + Address string `json:"address,omitempty"` + + // ModuleAddress is the module portion of the above address. Omitted if the + // instance is in the root module. + ModuleAddress string `json:"module_address,omitempty"` + + // "managed" or "data" + Mode string `json:"mode,omitempty"` + + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Index addrs.InstanceKey `json:"index,omitempty"` + + // "deposed", if set, indicates that this action applies to a "deposed" + // object of the given instance rather than to its "current" object. Omitted + // for changes to the current object. + Deposed bool `json:"deposed,omitempty"` + + // Change describes the change that will be made to this object + Change change `json:"change,omitempty"` +} diff --git a/command/jsonplan/values.go b/command/jsonplan/values.go new file mode 100644 index 000000000..1a709935c --- /dev/null +++ b/command/jsonplan/values.go @@ -0,0 +1,253 @@ +package jsonplan + +import ( + "encoding/json" + "fmt" + + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/plans" + "github.com/hashicorp/terraform/terraform" + ctyjson "github.com/zclconf/go-cty/cty/json" +) + +// stateValues is the common representation of resolved values for both the +// prior state (which is always complete) and the planned new state. +type stateValues struct { + Outputs map[string]output `json:"outputs,omitempty"` + RootModule module `json:"root_module,omitempty"` +} + +// attributeValues is the JSON representation of the attribute values of the +// resource, whose structure depends on the resource type schema. +type attributeValues map[string]interface{} + +func marshalAttributeValues(value cty.Value, schema *configschema.Block) attributeValues { + ret := make(attributeValues) + + it := value.ElementIterator() + for it.Next() { + k, v := it.Element() + ret[k.AsString()] = v + } + return ret +} + +// marshalAttributeValuesBool returns an attributeValues structure with "true" and +// "false" in place of the values indicating whether the value is known or not. +func marshalAttributeValuesBool(value cty.Value, schema *configschema.Block) attributeValues { + ret := make(attributeValues) + + it := value.ElementIterator() + for it.Next() { + k, v := it.Element() + if v.IsWhollyKnown() { + ret[k.AsString()] = "true" + } + ret[k.AsString()] = "false" + } + return ret +} + +// marshalPlannedOutputs takes a list of changes and returns two output maps, +// the former with output values and the latter with true/false in place of +// values indicating whether the values are known at plan time. +func marshalPlannedOutputs(changes *plans.Changes) (map[string]output, map[string]output, error) { + if changes.Outputs == nil { + // No changes - we're done here! + return nil, nil, nil + } + + ret := make(map[string]output) + uRet := make(map[string]output) + + for _, oc := range changes.Outputs { + if oc.ChangeSrc.Action == plans.Delete { + continue + } + + var after []byte + changeV, err := oc.Decode() + if err != nil { + return ret, uRet, err + } + + if changeV.After != cty.NilVal { + if changeV.After.IsWhollyKnown() { + after, err = ctyjson.Marshal(changeV.After, changeV.After.Type()) + if err != nil { + return ret, uRet, err + } + uRet[oc.Addr.OutputValue.Name] = output{ + Value: json.RawMessage("true"), + Sensitive: oc.Sensitive, + } + } else { + uRet[oc.Addr.OutputValue.Name] = output{ + Value: json.RawMessage("false"), + Sensitive: oc.Sensitive, + } + } + } + + ret[oc.Addr.OutputValue.Name] = output{ + Value: json.RawMessage(after), + Sensitive: oc.Sensitive, + } + } + + return ret, uRet, nil + +} + +// marshalPlannedValues returns two modules: +// The former has attribute values populated and the latter has true/false in +// place of values indicating whether the values are known at plan time. +func marshalPlannedValues(changes *plans.Changes, schemas *terraform.Schemas) (module, module, error) { + var ret, uRet module + if changes.Empty() { + return ret, uRet, nil + } + + // build two maps: + // module name -> [resource addresses] + // module -> [children modules] + moduleResourceMap := make(map[string][]addrs.AbsResourceInstance) + moduleMap := make(map[string][]addrs.ModuleInstance) + + for _, resource := range changes.Resources { + // if the resource is being deleted, skip over it. + if resource.Action != plans.Delete { + containingModule := resource.Addr.Module.String() + moduleResourceMap[containingModule] = append(moduleResourceMap[containingModule], resource.Addr) + + // root has no parents. + if containingModule != "" { + parent := resource.Addr.Module.Parent().String() + moduleMap[parent] = append(moduleMap[parent], resource.Addr.Module) + } + } + } + + // start with the root module + resources, uResources, err := marshalPlanResources(changes, moduleResourceMap[""], schemas) + if err != nil { + return ret, uRet, err + } + ret.Resources = resources + uRet.Resources = uResources + + childModules, err := marshalPlanModules(changes, schemas, moduleMap[""], moduleMap, moduleResourceMap) + if err != nil { + return ret, uRet, err + } + ret.ChildModules = childModules + + return ret, uRet, nil +} + +// marshalPlannedValues returns two resource slices: +// The former has attribute values populated and the latter has true/false in +// place of values indicating whether the values are known at plan time. +func marshalPlanResources(changes *plans.Changes, ris []addrs.AbsResourceInstance, schemas *terraform.Schemas) ([]resource, []resource, error) { + var ret, uRet []resource + + for _, ri := range ris { + r := changes.ResourceInstance(ri) + if r.Action == plans.Delete || r.Action == plans.NoOp { + continue + } + + resource := resource{ + Address: r.Addr.String(), + Type: r.Addr.Resource.Resource.Type, + Name: r.Addr.Resource.Resource.Name, + ProviderName: r.ProviderAddr.ProviderConfig.StringCompact(), + Index: r.Addr.Resource.Key, + } + + switch r.Addr.Resource.Resource.Mode { + case addrs.ManagedResourceMode: + resource.Mode = "managed" + case addrs.DataResourceMode: + resource.Mode = "data" + default: + return nil, nil, fmt.Errorf("resource %s has an unsupported mode %s", + r.Addr.String(), + r.Addr.Resource.Resource.Mode.String(), + ) + } + + schema, schemaVer := schemas.ResourceTypeConfig( + resource.ProviderName, + r.Addr.Resource.Resource.Mode, + resource.Type, + ) + if schema == nil { + return nil, nil, fmt.Errorf("no schema found for %s", r.Addr.String()) + } + resource.SchemaVersion = schemaVer + changeV, err := r.Decode(schema.ImpliedType()) + if err != nil { + return nil, nil, err + } + + var unknownAttributeValues attributeValues + if changeV.After != cty.NilVal { + if changeV.After.IsWhollyKnown() { + resource.AttributeValues = marshalAttributeValues(changeV.After, schema) + } + unknownAttributeValues = marshalAttributeValuesBool(changeV.After, schema) + } + + uResource := resource + uResource.AttributeValues = unknownAttributeValues + + ret = append(ret, resource) + uRet = append(uRet, uResource) + } + + return ret, uRet, nil +} + +// marshalPlanModules iterates over a list of modules to recursively describe +// the full module tree. +func marshalPlanModules( + changes *plans.Changes, + schemas *terraform.Schemas, + childModules []addrs.ModuleInstance, + moduleMap map[string][]addrs.ModuleInstance, + moduleResourceMap map[string][]addrs.AbsResourceInstance, +) ([]module, error) { + + var ret []module + + for _, child := range childModules { + moduleResources := moduleResourceMap[child.String()] + // cm for child module, naming things is hard. + var cm module + // don't populate the address for the root module + if child.String() != "" { + cm.Address = child.String() + } + rs, _, err := marshalPlanResources(changes, moduleResources, schemas) + if err != nil { + return nil, err + } + cm.Resources = rs + + if len(moduleMap[child.String()]) > 0 { + moreChildModules, err := marshalPlanModules(changes, schemas, moduleMap[child.String()], moduleMap, moduleResourceMap) + if err != nil { + return nil, err + } + cm.ChildModules = moreChildModules + } + + ret = append(ret, cm) + } + + return ret, nil +} diff --git a/command/jsonstate/expression.go b/command/jsonstate/expression.go new file mode 100644 index 000000000..4709893f2 --- /dev/null +++ b/command/jsonstate/expression.go @@ -0,0 +1,25 @@ +package jsonstate + +import "encoding/json" + +// expression represents any unparsed expression +type expression struct { + // "constant_value" is set only if the expression contains no references to + // other objects, in which case it gives the resulting constant value. This + // is mapped as for the individual values in the common value + // representation. + ConstantValue json.RawMessage `json:"constant_value,omitempty"` + + // Alternatively, "references" will be set to a list of references in the + // expression. Multi-step references will be unwrapped and duplicated for + // each significant traversal step, allowing callers to more easily + // recognize the objects they care about without attempting to parse the + // expressions. Callers should only use string equality checks here, since + // the syntax may be extended in future releases. + References []string `json:"references,omitempty"` + + // "source" is an object describing the source span of this expression in + // the configuration. Callers might use this, for example, to extract a raw + // source code snippet for display purposes. + Source source `json:"source"` +} diff --git a/command/jsonstate/state.go b/command/jsonstate/state.go new file mode 100644 index 000000000..ee6eac9e9 --- /dev/null +++ b/command/jsonstate/state.go @@ -0,0 +1,108 @@ +package jsonstate + +import ( + "encoding/json" + + "github.com/hashicorp/terraform/states" +) + +// FormatVersion represents the version of the json format and will be +// incremented for any change to this format that requires changes to a +// consuming parser. +const FormatVersion = "0.1" + +// state is the top-level representation of the json format of a terraform +// state. +type state struct { + FormatVersion string `json:"format_version"` + Values stateValues `json:"values"` +} + +// stateValues is the common representation of resolved values for both the prior +// state (which is always complete) and the planned new state. +type stateValues struct { + Outputs map[string]output + RootModule module +} + +type output struct { + Sensitive bool + Value json.RawMessage +} + +// module is the representation of a module in state. This can be the root module +// or a child module +type module struct { + Resources []resource + + // Address is the absolute module address, omitted for the root module + Address string `json:"address,omitempty"` + + // Each module object can optionally have its own nested "child_modules", + // recursively describing the full module tree. + ChildModules []module `json:"child_modules,omitempty"` +} + +type moduleCall struct { + ResolvedSource string `json:"resolved_source"` + Expressions map[string]interface{} `json:"expressions,omitempty"` + CountExpression expression `json:"count_expression"` + ForEachExpression expression `json:"for_each_expression"` + Module module `json:"module"` +} + +// Resource is the representation of a resource in the state. +type resource struct { + // Address is the absolute resource address + Address string `json:"address"` + + // Mode can be "managed" or "data" + Mode string `json:"mode"` + + Type string `json:"type"` + Name string `json:"name"` + + // Index is omitted for a resource not using `count` or `for_each`. + Index int `json:"index,omitempty"` + + // ProviderName allows the property "type" to be interpreted unambiguously + // in the unusual situation where a provider offers a resource type whose + // name does not start with its own name, such as the "googlebeta" provider + // offering "google_compute_instance". + ProviderName string `json:"provider_name"` + + // SchemaVersion indicates which version of the resource type schema the + // "values" property conforms to. + SchemaVersion int `json:"schema_version"` + + // Values is the JSON representation of the attribute values of the + // resource, whose structure depends on the resource type schema. Any + // unknown values are omitted or set to null, making them indistinguishable + // from absent values. + Values json.RawMessage `json:"values"` +} + +type source struct { + FileName string `json:"filename"` + Start string `json:"start"` + End string `json:"end"` +} + +// newState() returns a minimally-initialized state +func newState() *state { + return &state{ + FormatVersion: FormatVersion, + } +} + +// Marshal returns the json encoding of a terraform plan. +func Marshal(s *states.State) ([]byte, error) { + if s.Empty() { + return nil, nil + } + + output := newState() + + ret, err := json.Marshal(output) + return ret, err +} diff --git a/command/show.go b/command/show.go index 477bd8978..5561250eb 100644 --- a/command/show.go +++ b/command/show.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform/tfdiags" "github.com/hashicorp/terraform/command/format" + "github.com/hashicorp/terraform/command/jsonplan" "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/states" ) @@ -28,16 +29,18 @@ func (c *ShowCommand) Run(args []string) int { } cmdFlags := c.Meta.defaultFlagSet("show") + var jsonOutput bool + cmdFlags.BoolVar(&jsonOutput, "json", false, "produce JSON output (only available when showing a planfile)") cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } if err := cmdFlags.Parse(args); err != nil { return 1 } args = cmdFlags.Args() - if len(args) > 1 { + if len(args) > 2 { c.Ui.Error( - "The show command expects at most one argument with the path\n" + - "to a Terraform state or plan file.\n") + "The show command expects at most two arguments.\n The path to a " + + "Terraform state or plan file, and optionally -json for json output.\n") cmdFlags.Usage() return 1 } @@ -67,9 +70,19 @@ func (c *ShowCommand) Run(args []string) int { return 1 } + // Determine if a planfile was passed to the command + var planFile *planfile.Reader + if len(args) > 0 { + // We will handle error checking later on - this is just required to + // load the local context if the given path is successfully read as + // a planfile. + planFile, _ = c.PlanFile(args[0]) + } + // Build the operation opReq := c.Operation(b) opReq.ConfigDir = cwd + opReq.PlanFile = planFile opReq.ConfigLoader, err = c.initConfigLoader() if err != nil { diags = diags.Append(err) @@ -88,68 +101,63 @@ func (c *ShowCommand) Run(args []string) int { // Get the schemas from the context schemas := ctx.Schemas() - env := c.Workspace() - var planErr, stateErr error - var path string var plan *plans.Plan var state *states.State + + // if a path was provided, try to read it as a path to a planfile + // if that fails, try to read the cli argument as a path to a statefile if len(args) > 0 { - path = args[0] - pr, err := planfile.Open(path) - if err != nil { - f, err := os.Open(path) - if err != nil { - c.Ui.Error(fmt.Sprintf("Error loading file: %s", err)) + path := args[0] + plan, planErr = getPlanFromPath(path) + if planErr != nil { + // json output is only supported for plans + if jsonOutput == true { + c.Ui.Error("Error: JSON output not available for state") return 1 } - defer f.Close() - - var stateFile *statefile.File - stateFile, err = statefile.Read(f) - if err != nil { - stateErr = err - } else { - state = stateFile.State + state, stateErr = getStateFromPath(path) + if stateErr != nil { + c.Ui.Error(fmt.Sprintf( + "Terraform couldn't read the given file as a state or plan file.\n"+ + "The errors while attempting to read the file as each format are\n"+ + "shown below.\n\n"+ + "State read error: %s\n\nPlan read error: %s", + stateErr, + planErr)) + return 1 } - } else { - plan, err = pr.ReadPlan() - if err != nil { - planErr = err - } - } - } else { - // Get the state - stateStore, err := b.StateMgr(env) - if err != nil { - c.Ui.Error(fmt.Sprintf("Failed to load state: %s", err)) - return 1 - } - - if err := stateStore.RefreshState(); err != nil { - c.Ui.Error(fmt.Sprintf("Failed to load state: %s", err)) - return 1 - } - - state = stateStore.State() - if state == nil { - c.Ui.Output("No state.") - return 0 } } + if state == nil { + env := c.Workspace() + state, stateErr = getStateFromEnv(b, env) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + } + + // This is an odd-looking check, because it's ok if we have a plan and an + // empty state, and we've already validated that any command-line arguments + // have been read successfully if plan == nil && state == nil { - c.Ui.Error(fmt.Sprintf( - "Terraform couldn't read the given file as a state or plan file.\n"+ - "The errors while attempting to read the file as each format are\n"+ - "shown below.\n\n"+ - "State read error: %s\n\nPlan read error: %s", - stateErr, - planErr)) - return 1 + c.Ui.Output("No state.") + return 0 } if plan != nil { + if jsonOutput == true { + config := ctx.Config() + jsonPlan, err := jsonplan.Marshal(config, plan, state, schemas) + if err != nil { + c.Ui.Error(fmt.Sprintf("Failed to marshal plan to json: %s", err)) + return 1 + } + c.Ui.Output(string(jsonPlan)) + return 0 + } dispPlan := format.NewPlan(plan.Changes) c.Ui.Output(dispPlan.Format(c.Colorize())) return 0 @@ -173,6 +181,8 @@ Usage: terraform show [options] [path] Options: -no-color If specified, output won't contain any color. + -json If specified, output the Terraform plan in a machine- + readable form. Only available for plan files. ` return strings.TrimSpace(helpText) @@ -181,3 +191,50 @@ Options: func (c *ShowCommand) Synopsis() string { return "Inspect Terraform state or plan" } + +// getPlanFromPath returns a plan if the user-supplied path points to a planfile. +// If both plan and error are nil, the path is likely a directory. +// An error could suggest that the given path points to a statefile. +func getPlanFromPath(path string) (*plans.Plan, error) { + pr, err := planfile.Open(path) + if err != nil { + return nil, err + } + plan, err := pr.ReadPlan() + if err != nil { + return nil, err + } + return plan, nil +} + +// getStateFromPath returns a State if the user-supplied path points to a statefile. +func getStateFromPath(path string) (*states.State, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("Error loading statefile: %s", err) + } + defer f.Close() + + var stateFile *statefile.File + stateFile, err = statefile.Read(f) + if err != nil { + return nil, fmt.Errorf("Error reading %s as a statefile: %s", path, err) + } + return stateFile.State, nil +} + +// getStateFromEnv returns the State for the current workspace, if available. +func getStateFromEnv(b backend.Backend, env string) (*states.State, error) { + // Get the state + stateStore, err := b.StateMgr(env) + if err != nil { + return nil, fmt.Errorf("Failed to load state manager: %s", err) + } + + if err := stateStore.RefreshState(); err != nil { + return nil, fmt.Errorf("Failed to load state: %s", err) + } + + state := stateStore.State() + return state, nil +} diff --git a/command/show_test.go b/command/show_test.go index e0ff42cca..ba8e48927 100644 --- a/command/show_test.go +++ b/command/show_test.go @@ -4,7 +4,14 @@ import ( "path/filepath" "testing" + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/plans" + "github.com/hashicorp/terraform/providers" + "github.com/hashicorp/terraform/states" + "github.com/hashicorp/terraform/terraform" "github.com/mitchellh/cli" + "github.com/zclconf/go-cty/cty" ) func TestShow(t *testing.T) { @@ -25,6 +32,27 @@ func TestShow(t *testing.T) { } } +func TestShow_JSONStateNotImplemented(t *testing.T) { + // Create the default state + statePath := testStateFile(t, testState()) + defer testChdir(t, filepath.Dir(statePath))() + ui := new(cli.MockUi) + c := &ShowCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + }, + } + + args := []string{ + "-json", + statePath, + } + if code := c.Run(args); code != 1 { + t.Fatalf("bad: \n%s", ui.OutputWriter.String()) + } +} + func TestShow_noArgs(t *testing.T) { // Create the default state statePath := testStateFile(t, testState()) @@ -82,6 +110,26 @@ func TestShow_plan(t *testing.T) { } } +func TestShow_plan_json(t *testing.T) { + planPath := showFixturePlanFile(t) + + ui := new(cli.MockUi) + c := &ShowCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(showFixtureProvider()), + Ui: ui, + }, + } + + args := []string{ + "-json", + planPath, + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } +} + func TestShow_state(t *testing.T) { originalState := testState() statePath := testStateFile(t, originalState) @@ -101,3 +149,79 @@ func TestShow_state(t *testing.T) { t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) } } + +// showFixtureSchema returns a schema suitable for processing the configuration +// in test-fixtures/show. This schema should be assigned to a mock provider +// named "test". +func showFixtureSchema() *terraform.ProviderSchema { + return &terraform.ProviderSchema{ + ResourceTypes: map[string]*configschema.Block{ + "test_instance": { + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Optional: true, Computed: true}, + "ami": {Type: cty.String, Optional: true}, + }, + }, + }, + } +} + +// showFixtureProvider returns a mock provider that is configured for basic +// operation with the configuration in test-fixtures/show. This mock has +// GetSchemaReturn, PlanResourceChangeFn, and ApplyResourceChangeFn populated, +// with the plan/apply steps just passing through the data determined by +// Terraform Core. +func showFixtureProvider() *terraform.MockProvider { + p := testProvider() + p.GetSchemaReturn = showFixtureSchema() + p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: req.ProposedNewState, + } + } + p.ApplyResourceChangeFn = func(req providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse { + return providers.ApplyResourceChangeResponse{ + NewState: cty.UnknownAsNull(req.PlannedState), + } + } + return p +} + +// showFixturePlanFile creates a plan file at a temporary location containing a +// single change to create the test_instance.foo that is included in the "show" +// test fixture, returning the location of that plan file. +func showFixturePlanFile(t *testing.T) string { + _, snap := testModuleWithSnapshot(t, "show") + plannedVal := cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "ami": cty.StringVal("bar"), + }) + priorValRaw, err := plans.NewDynamicValue(cty.NullVal(plannedVal.Type()), plannedVal.Type()) + if err != nil { + t.Fatal(err) + } + plannedValRaw, err := plans.NewDynamicValue(plannedVal, plannedVal.Type()) + if err != nil { + t.Fatal(err) + } + plan := testPlan(t) + plan.Changes.SyncWrapper().AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{ + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ProviderAddr: addrs.ProviderConfig{Type: "test"}.Absolute(addrs.RootModuleInstance), + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + Before: priorValRaw, + After: plannedValRaw, + }, + }) + return testPlanFile( + t, + snap, + states.NewState(), + plan, + ) +} diff --git a/command/test-fixtures/show/main.tf b/command/test-fixtures/show/main.tf new file mode 100644 index 000000000..1b1012991 --- /dev/null +++ b/command/test-fixtures/show/main.tf @@ -0,0 +1,3 @@ +resource "test_instance" "foo" { + ami = "bar" +}