diff --git a/command/jsonconfig/config.go b/command/jsonconfig/config.go new file mode 100644 index 0000000000..1ca605c5ae --- /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 0000000000..28324a5787 --- /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 0000000000..3e940ae9c4 --- /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 0000000000..db1f3fb0cd --- /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 0000000000..4531429e9a --- /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 0000000000..d069c436fd --- /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 0000000000..65ce5911ed --- /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 0000000000..1a709935ce --- /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 0000000000..4709893f22 --- /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 0000000000..ee6eac9e9f --- /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 477bd89785..5561250eb8 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 - } - } else { - plan, err = pr.ReadPlan() - if err != nil { - planErr = err + 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 { - // 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)) + if state == nil { + env := c.Workspace() + state, stateErr = getStateFromEnv(b, env) + if err != nil { + c.Ui.Error(err.Error()) return 1 } - - state = stateStore.State() - if state == nil { - c.Ui.Output("No state.") - return 0 - } } + // 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 e0ff42ccac..ba8e489275 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 0000000000..1b10129919 --- /dev/null +++ b/command/test-fixtures/show/main.tf @@ -0,0 +1,3 @@ +resource "test_instance" "foo" { + ami = "bar" +}