From 94a66e3c5e56afe02f93ef1c765f6de6d25c0103 Mon Sep 17 00:00:00 2001 From: Andreas Sommer Date: Thu, 1 Aug 2019 17:21:54 +0200 Subject: [PATCH] Support Terraform 0.12 state format (#114) --- .travis.yml | 5 +- cli.go | 108 +++++++++++- main.go | 34 ++-- output.go | 2 +- parser.go | 270 +++++++++++++++++++++++++++--- parser_test.go | 296 ++++++++++++++++++++++++++++++++- parser_test.go.example.tfstate | 86 ++++++++++ resource.go | 31 ++-- 8 files changed, 769 insertions(+), 63 deletions(-) create mode 100644 parser_test.go.example.tfstate diff --git a/.travis.yml b/.travis.yml index 0213d3e..7827bd5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,9 @@ language: go go: - - 1.5 - - 1.6 + - "1.8" + - "1.11.x" + - "1.x" # latest script: - go test -v ./... diff --git a/cli.go b/cli.go index b4c3061..ec55ebf 100644 --- a/cli.go +++ b/cli.go @@ -42,7 +42,107 @@ func appendUniq(strs []string, item string) []string { return strs } -func gatherResources(s *state) map[string]interface{} { +func gatherResources(s *stateAnyTerraformVersion) map[string]interface{} { + if s.TerraformVersion == TerraformVersionPre0dot12 { + return gatherResourcesPre0dot12(&s.StatePre0dot12) + } else if s.TerraformVersion == TerraformVersion0dot12 { + return gatherResources0dot12(&s.State0dot12) + } else { + panic("Unimplemented Terraform version enum") + } +} + +func gatherResourcesPre0dot12(s *state) map[string]interface{} { + outputGroups := make(map[string]interface{}) + + all := &allGroup{Hosts: make([]string, 0), Vars: make(map[string]interface{})} + types := make(map[string][]string) + individual := make(map[string][]string) + ordered := make(map[string][]string) + tags := make(map[string][]string) + + unsortedOrdered := make(map[string][]*Resource) + + resourceIDNames := s.mapResourceIDNames() + for _, res := range s.resources() { + // place in list of all resources + all.Hosts = appendUniq(all.Hosts, res.Hostname()) + + // place in list of resource types + tp := fmt.Sprintf("type_%s", res.resourceType) + types[tp] = appendUniq(types[tp], res.Hostname()) + + unsortedOrdered[res.baseName] = append(unsortedOrdered[res.baseName], res) + + // store as invdividual host (eg. .) + invdName := fmt.Sprintf("%s.%d", res.baseName, res.counter) + if old, exists := individual[invdName]; exists { + fmt.Fprintf(os.Stderr, "overwriting already existing individual key %s, old: %v, new: %v\n", invdName, old, res.Hostname()) + } + individual[invdName] = []string{res.Hostname()} + + // inventorize tags + for k, v := range res.Tags() { + // Valueless + tag := k + if v != "" { + tag = fmt.Sprintf("%s_%s", k, v) + } + // if v is a resource ID, then tag should be resource name + if _, exists := resourceIDNames[v]; exists { + tag = resourceIDNames[v] + } + tags[tag] = appendUniq(tags[tag], res.Hostname()) + } + } + + // inventorize outputs as variables + if len(s.outputs()) > 0 { + for _, out := range s.outputs() { + all.Vars[out.keyName] = out.value + } + } + + // sort the ordered groups + for basename, resources := range unsortedOrdered { + cs := counterSorter{resources} + sort.Sort(cs) + + for i := range resources { + ordered[basename] = append(ordered[basename], resources[i].Hostname()) + } + } + + outputGroups["all"] = all + for k, v := range individual { + if old, exists := outputGroups[k]; exists { + fmt.Fprintf(os.Stderr, "individual overwriting already existing output with key %s, old: %v, new: %v", k, old, v) + } + outputGroups[k] = v + } + for k, v := range ordered { + if old, exists := outputGroups[k]; exists { + fmt.Fprintf(os.Stderr, "ordered overwriting already existing output with key %s, old: %v, new: %v", k, old, v) + } + outputGroups[k] = v + } + for k, v := range types { + if old, exists := outputGroups[k]; exists { + fmt.Fprintf(os.Stderr, "types overwriting already existing output key %s, old: %v, new: %v", k, old, v) + } + outputGroups[k] = v + } + for k, v := range tags { + if old, exists := outputGroups[k]; exists { + fmt.Fprintf(os.Stderr, "tags overwriting already existing output key %s, old: %v, new: %v", k, old, v) + } + outputGroups[k] = v + } + + return outputGroups +} + +func gatherResources0dot12(s *stateTerraform0dot12) map[string]interface{} { outputGroups := make(map[string]interface{}) all := &allGroup{Hosts: make([]string, 0), Vars: make(map[string]interface{})} @@ -132,11 +232,11 @@ func gatherResources(s *state) map[string]interface{} { return outputGroups } -func cmdList(stdout io.Writer, stderr io.Writer, s *state) int { +func cmdList(stdout io.Writer, stderr io.Writer, s *stateAnyTerraformVersion) int { return output(stdout, stderr, gatherResources(s)) } -func cmdInventory(stdout io.Writer, stderr io.Writer, s *state) int { +func cmdInventory(stdout io.Writer, stderr io.Writer, s *stateAnyTerraformVersion) int { groups := gatherResources(s) group_names := []string{} for group, _ := range groups { @@ -190,7 +290,7 @@ func checkErr(err error, stderr io.Writer) int { return 0 } -func cmdHost(stdout io.Writer, stderr io.Writer, s *state, hostname string) int { +func cmdHost(stdout io.Writer, stderr io.Writer, s *stateAnyTerraformVersion, hostname string) int { for _, res := range s.resources() { if hostname == res.Hostname() { attributes := res.Attributes() diff --git a/main.go b/main.go index f845916..409cb58 100644 --- a/main.go +++ b/main.go @@ -51,7 +51,7 @@ func main() { os.Exit(1) } - var s state + var s stateAnyTerraformVersion if !f.IsDir() { stateFile, err := os.Open(path) @@ -69,39 +69,51 @@ func main() { } if f.IsDir() { - cmd := exec.Command("terraform", "state", "pull") + cmd := exec.Command("terraform", "show", "-json") cmd.Dir = path var out bytes.Buffer cmd.Stdout = &out err = cmd.Run() if err != nil { - fmt.Fprintf(os.Stderr, "Error running `terraform state pull` in directory %s, %s\n", path, err) - os.Exit(1) + fmt.Fprintf(os.Stderr, "Error running `terraform show -json` in directory %s, %s, falling back to trying Terraform pre-0.12 command\n", path, err) + + cmd = exec.Command("terraform", "state", "pull") + cmd.Dir = path + out.Reset() + cmd.Stdout = &out + err = cmd.Run() + + if err != nil { + fmt.Fprintf(os.Stderr, "Error running `terraform state pull` in directory %s, %s\n", path, err) + os.Exit(1) + } } err = s.read(&out) if err != nil { - fmt.Fprintf(os.Stderr, "Error reading `terraform state pull` output: %s\n", err) + fmt.Fprintf(os.Stderr, "Error reading Terraform state: %s\n", err) os.Exit(1) } - } - if s.Modules == nil { - fmt.Printf("Usage: %s [options] path\npath: this is either a path to a state file or a folder from which `terraform commands` are valid\n", os.Args[0]) + if s.TerraformVersion == TerraformVersionUnknown { + fmt.Fprintf(os.Stderr, "Unknown state format\n\nUsage: %s [options] path\npath: this is either a path to a state file or a folder from which `terraform commands` are valid\n", os.Args[0]) + os.Exit(1) + } + + if (s.TerraformVersion == TerraformVersionPre0dot12 && s.StatePre0dot12.Modules == nil) || + (s.TerraformVersion == TerraformVersion0dot12 && s.State0dot12.Values.RootModule == nil) { + fmt.Fprintf(os.Stderr, "No modules found in state\n\nUsage: %s [options] path\npath: this is either a path to a state file or a folder from which `terraform commands` are valid\n", os.Args[0]) os.Exit(1) } if *list { os.Exit(cmdList(os.Stdout, os.Stderr, &s)) - } else if *inventory { os.Exit(cmdInventory(os.Stdout, os.Stderr, &s)) - } else if *host != "" { os.Exit(cmdHost(os.Stdout, os.Stderr, &s, *host)) - } } diff --git a/output.go b/output.go index 515b643..b798ce0 100644 --- a/output.go +++ b/output.go @@ -15,7 +15,7 @@ func NewOutput(keyName string, value interface{}) (*Output, error) { // TODO: Warn instead of silently ignore error? if len(keyName) == 0 { - return nil, fmt.Errorf("couldn't parse keyName: %s", keyName) + return nil, fmt.Errorf("couldn't parse output keyName: %s", keyName) } return &Output{ diff --git a/parser.go b/parser.go index 7f0f5e8..1d1794f 100644 --- a/parser.go +++ b/parser.go @@ -2,29 +2,94 @@ package main import ( "encoding/json" + "fmt" "io" "io/ioutil" + "os" "sort" + "strconv" "strings" ) +// TerraformVersion defines which version of Terraform state applies +type TerraformVersion int + +const ( + // TerraformVersionUnknown means unknown version + TerraformVersionUnknown TerraformVersion = 0 + + // TerraformVersionPre0dot12 means < 0.12 + TerraformVersionPre0dot12 TerraformVersion = 1 + + // TerraformVersion0dot12 means >= 0.12 + TerraformVersion0dot12 TerraformVersion = 2 +) + +type stateAnyTerraformVersion struct { + StatePre0dot12 state + State0dot12 stateTerraform0dot12 + TerraformVersion TerraformVersion +} + +// Terraform < v0.12 type state struct { Modules []moduleState `json:"modules"` } +type moduleState struct { + Path []string `json:"path"` + ResourceStates map[string]resourceState `json:"resources"` + Outputs map[string]interface{} `json:"outputs"` +} +type resourceState struct { + // Populated from statefile + Type string `json:"type"` + Primary instanceState `json:"primary"` +} +type instanceState struct { + ID string `json:"id"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +// Terraform <= v0.12 +type stateTerraform0dot12 struct { + Values valuesStateTerraform0dot12 `json:"values"` +} +type valuesStateTerraform0dot12 struct { + RootModule *moduleStateTerraform0dot12 `json:"root_module"` + Outputs map[string]interface{} `json:"outputs"` +} +type moduleStateTerraform0dot12 struct { + ResourceStates []resourceStateTerraform0dot12 `json:"resources"` + ChildModules []moduleStateTerraform0dot12 `json:"child_modules"` + Address string `json:"address"` // empty for root module, else e.g. `module.mymodulename` +} +type resourceStateTerraform0dot12 struct { + Address string `json:"address"` + Index *int `json:"index"` // only set by Terraform for counted resources + Name string `json:"name"` + RawValues map[string]interface{} `json:"values"` + Type string `json:"type"` +} // read populates the state object from a statefile. -func (s *state) read(stateFile io.Reader) error { +func (s *stateAnyTerraformVersion) read(stateFile io.Reader) error { + s.TerraformVersion = TerraformVersionUnknown - // read statefile contents - b, err := ioutil.ReadAll(stateFile) - if err != nil { - return err + b, readErr := ioutil.ReadAll(stateFile) + if readErr != nil { + return readErr } - // parse into struct - err = json.Unmarshal(b, s) - if err != nil { - return err + err0dot12 := json.Unmarshal(b, &(*s).State0dot12) + if err0dot12 == nil && s.State0dot12.Values.RootModule != nil { + s.TerraformVersion = TerraformVersion0dot12 + } else { + errPre0dot12 := json.Unmarshal(b, &(*s).StatePre0dot12) + if errPre0dot12 == nil && s.StatePre0dot12.Modules != nil { + s.TerraformVersion = TerraformVersionPre0dot12 + } else { + return fmt.Errorf("0.12 format error: %v; pre-0.12 format error: %v (nil error means no content/modules found in the respective format)", err0dot12, errPre0dot12) + } } return nil @@ -53,6 +118,25 @@ func (s *state) outputs() []*Output { return inst } +// outputs returns a slice of the Outputs found in the statefile. +func (s *stateTerraform0dot12) outputs() []*Output { + inst := make([]*Output, 0) + + for k, v := range s.Values.Outputs { + var o *Output + switch v := v.(type) { + case map[string]interface{}: + o, _ = NewOutput(k, v["value"]) + default: // not expected + o, _ = NewOutput(k, "") + } + + inst = append(inst, o) + } + + return inst +} + // map of resource ID -> resource Name func (s *state) mapResourceIDNames() map[string]string { t := map[string]string{} @@ -68,17 +152,91 @@ func (s *state) mapResourceIDNames() map[string]string { return t } +// map of resource ID -> resource Name +func (s *stateTerraform0dot12) mapResourceIDNames() map[string]string { + t := map[string]string{} + + for _, module := range s.getAllModules() { + for _, resourceState := range module.ResourceStates { + id, typeOk := resourceState.RawValues["id"].(string) + if typeOk && id != "" && resourceState.Name != "" { + k := strings.ToLower(id) + t[k] = resourceState.Name + } + } + } + + return t +} + +func (s *stateTerraform0dot12) getAllModules() []*moduleStateTerraform0dot12 { + var allModules []*moduleStateTerraform0dot12 + allModules = append(allModules, s.Values.RootModule) + addChildModules(&allModules, s.Values.RootModule) + return allModules +} + +// recursively adds all child modules to the slice +func addChildModules(out *[]*moduleStateTerraform0dot12, from *moduleStateTerraform0dot12) { + for i := range from.ChildModules { + addChildModules(out, &from.ChildModules[i]) + *out = append(*out, &from.ChildModules[i]) + } +} + +// resources returns a slice of the Resources found in the statefile. +func (s *stateAnyTerraformVersion) resources() []*Resource { + switch s.TerraformVersion { + case TerraformVersionPre0dot12: + return s.StatePre0dot12.resources() + case TerraformVersion0dot12: + return s.State0dot12.resources() + case TerraformVersionUnknown: + } + panic("Unimplemented Terraform version enum") +} + // resources returns a slice of the Resources found in the statefile. func (s *state) resources() []*Resource { inst := make([]*Resource, 0) for _, m := range s.Modules { for _, k := range m.resourceKeys() { + if strings.HasPrefix(k, "data.") { + // This does not represent a host (e.g. AWS AMI) + continue + } + + // If a module is used, the resource key may not be unique, for instance: + // + // The module cannot use dynamic resource naming and thus has to use some hardcoded name: + // + // resource "aws_instance" "host" { ... } + // + // The main file then uses the module twice: + // + // module "application1" { source = "./modules/mymodulename" } + // module "application2" { source = "./modules/mymodulename" } + // + // Avoid key clashes by prepending module name to the key. If path is ["root"], don't + // prepend anything. + // + // In the above example: `aws_instance.host` -> `aws_instance.application1_host` + fullKey := k + resourceNameIndex := strings.Index(fullKey, ".") + 1 + if len(m.Path) > 1 && resourceNameIndex > 0 { + for i := len(m.Path) - 1; i >= 1; i-- { + fullKey = fullKey[:resourceNameIndex] + strings.Replace(m.Path[i], ".", "_", -1) + "_" + fullKey[resourceNameIndex:] + } + } + // Terraform stores resources in a name->map map, but we need the name to // decide which groups to include the resource in. So wrap it in a higher- // level object with both properties. - r, err := NewResource(k, m.ResourceStates[k]) + r, err := NewResource(fullKey, m.ResourceStates[k]) if err != nil { + asJSON, _ := json.Marshal(m.ResourceStates[k]) + fmt.Fprintf(os.Stderr, "Warning: failed to parse resource %s (%v)\n", asJSON, err) continue } if r.IsSupported() { @@ -90,9 +248,81 @@ func (s *state) resources() []*Resource { return inst } -type moduleState struct { - ResourceStates map[string]resourceState `json:"resources"` - Outputs map[string]interface{} `json:"outputs"` +func encodeTerraform0Dot12ValuesAsAttributes(rawValues *map[string]interface{}) map[string]string { + ret := make(map[string]string) + for k, v := range *rawValues { + switch v := v.(type) { + case map[string]interface{}: + ret[k+".#"] = strconv.Itoa(len(v)) + for kk, vv := range v { + if str, typeOk := vv.(string); typeOk { + ret[k+"."+kk] = str + } else { + ret[k+"."+kk] = "" + } + } + case string: + ret[k] = v + default: + ret[k] = "" + } + } + return ret +} + +// resources returns a slice of the Resources found in the statefile. +func (s *stateTerraform0dot12) resources() []*Resource { + inst := make([]*Resource, 0) + + for _, module := range s.getAllModules() { + for _, rs := range module.ResourceStates { + id, typeOk := rs.RawValues["id"].(string) + if !typeOk { + continue + } + + if strings.HasPrefix(rs.Address, "data.") { + // This does not represent a host (e.g. AWS AMI) + continue + } + + modulePrefix := "" + if module.Address != "" { + modulePrefix = strings.Replace(module.Address, ".", "_", -1) + "_" + } + resourceKeyName := rs.Type + "." + modulePrefix + rs.Name + if rs.Index != nil { + resourceKeyName += "." + strconv.Itoa(*rs.Index) + } + + // Terraform stores resources in a name->map map, but we need the name to + // decide which groups to include the resource in. So wrap it in a higher- + // level object with both properties. + // + // Convert to the pre-0.12 structure for backwards compatibility of code. + r, err := NewResource(resourceKeyName, resourceState{ + Type: rs.Type, + Primary: instanceState{ + ID: id, + Attributes: encodeTerraform0Dot12ValuesAsAttributes(&rs.RawValues), + }, + }) + if err != nil { + asJSON, _ := json.Marshal(rs) + fmt.Fprintf(os.Stderr, "Warning: failed to parse resource %s (%v)\n", asJSON, err) + continue + } + if r.IsSupported() { + inst = append(inst, r) + } + } + } + + sort.Slice(inst, func(i, j int) bool { + return inst[i].baseName < inst[j].baseName + }) + + return inst } // resourceKeys returns a sorted slice of the key names of the resources in this @@ -105,21 +335,9 @@ func (ms *moduleState) resourceKeys() []string { for k := range ms.ResourceStates { keys[i] = k - i += 1 + i++ } sort.Strings(keys) return keys } - -type resourceState struct { - - // Populated from statefile - Type string `json:"type"` - Primary instanceState `json:"primary"` -} - -type instanceState struct { - ID string `json:"id"` - Attributes map[string]string `json:"attributes,omitempty"` -} diff --git a/parser_test.go b/parser_test.go index 8cabaaa..97e4e1a 100644 --- a/parser_test.go +++ b/parser_test.go @@ -797,11 +797,13 @@ const expectedHostOneOutput = ` ` func TestListCommand(t *testing.T) { - var s state + var s stateAnyTerraformVersion r := strings.NewReader(exampleStateFile) err := s.read(r) assert.NoError(t, err) + assert.Equal(t, TerraformVersionPre0dot12, s.TerraformVersion) + // Decode expectation as JSON var exp interface{} err = json.Unmarshal([]byte(expectedListOutput), &exp) @@ -822,11 +824,13 @@ func TestListCommand(t *testing.T) { } func TestListCommandEnvHostname(t *testing.T) { - var s state + var s stateAnyTerraformVersion r := strings.NewReader(exampleStateFileEnvHostname) err := s.read(r) assert.NoError(t, err) + assert.Equal(t, TerraformVersionPre0dot12, s.TerraformVersion) + // Decode expectation as JSON var exp interface{} err = json.Unmarshal([]byte(expectedListOutputEnvHostname), &exp) @@ -849,11 +853,13 @@ func TestListCommandEnvHostname(t *testing.T) { } func TestHostCommand(t *testing.T) { - var s state + var s stateAnyTerraformVersion r := strings.NewReader(exampleStateFile) err := s.read(r) assert.NoError(t, err) + assert.Equal(t, TerraformVersionPre0dot12, s.TerraformVersion) + // Decode expectation as JSON var exp interface{} err = json.Unmarshal([]byte(expectedHostOneOutput), &exp) @@ -874,11 +880,13 @@ func TestHostCommand(t *testing.T) { } func TestInventoryCommand(t *testing.T) { - var s state + var s stateAnyTerraformVersion r := strings.NewReader(exampleStateFile) err := s.read(r) assert.NoError(t, err) + assert.Equal(t, TerraformVersionPre0dot12, s.TerraformVersion) + // Run the command, capture the output var stdout, stderr bytes.Buffer exitCode := cmdInventory(&stdout, &stderr, &s) @@ -887,3 +895,283 @@ func TestInventoryCommand(t *testing.T) { assert.Equal(t, expectedInventoryOutput, stdout.String()) } + +// +// Terraform 0.12 BEGIN +// + +const exampleStateFileTerraform0dot12 = ` +{ + "format_version": "0.1", + "terraform_version": "0.12.1", + "values": { + "outputs": { + "my_endpoint": { + "sensitive": false, + "value": "a.b.c.d.example.com" + }, + "my_password": { + "sensitive": true, + "value": "1234" + }, + "map": { + "sensitive": false, + "value": { + "first": "a", + "second": "b" + } + } + }, + "root_module": { + "resources": [ + { + "address": "aws_instance.one", + "type": "aws_instance", + "name": "one", + "provider_name": "aws", + "schema_version": 1, + "values": { + "ami": "ami-00000000000000000", + "id": "i-11111111111111111", + "private_ip": "10.0.0.1", + "public_ip": "35.159.25.34", + "tags": { + "Name": "one-aws-instance" + }, + "volume_tags": { + "Ignored": "stuff" + } + } + } + ], + "child_modules": [ + { + "resources": [ + { + "address": "aws_instance.host", + "type": "aws_instance", + "name": "host", + "values": { + "ami": "ami-00000000000000001", + "id": "i-22222222222222222", + "private_ip": "10.0.0.2", + "public_ip": "", + "tags": { + "Name": "two-aws-instance" + } + } + } + ], + "address": "module.my-module-two" + }, + { + "resources": [ + { + "address": "aws_instance.host", + "type": "aws_instance", + "name": "host", + "index": 0, + "values": { + "ami": "ami-00000000000000001", + "id": "i-33333333333333333", + "private_ip": "10.0.0.3", + "public_ip": "", + "tags": { + "Name": "three-aws-instance" + } + } + }, + { + "address": "aws_instance.host", + "type": "aws_instance", + "name": "host", + "index": 1, + "values": { + "ami": "ami-00000000000000001", + "id": "i-11133333333333333", + "private_ip": "10.0.1.3", + "public_ip": "", + "tags": { + "Name": "three-aws-instance" + } + } + } + ], + "address": "module.my-module-three" + } + ] + } + } +} +` + +const expectedListOutputTerraform0dot12 = ` +{ + "all": { + "hosts": [ + "10.0.0.2", + "10.0.0.3", + "10.0.1.3", + "35.159.25.34" + ], + "vars": { + "my_endpoint": "a.b.c.d.example.com", + "my_password": "1234", + "map": {"first": "a", "second": "b"} + } + }, + "one.0": ["35.159.25.34"], + "one": ["35.159.25.34"], + "module_my-module-two_host.0": ["10.0.0.2"], + "module_my-module-two_host": ["10.0.0.2"], + "module_my-module-three_host.0": ["10.0.0.3"], + "module_my-module-three_host.1": ["10.0.1.3"], + "module_my-module-three_host": ["10.0.0.3", "10.0.1.3"], + + "type_aws_instance": ["10.0.0.2", "10.0.0.3", "10.0.1.3", "35.159.25.34"], + + "name_one-aws-instance": ["35.159.25.34"], + "name_two-aws-instance": ["10.0.0.2"], + "name_three-aws-instance": ["10.0.0.3", "10.0.1.3"] +} +` + +const expectedInventoryOutputTerraform0dot12 = `[all] +10.0.0.2 +10.0.0.3 +10.0.1.3 +35.159.25.34 + +[all:vars] +map={"first":"a","second":"b"} +my_endpoint="a.b.c.d.example.com" +my_password="1234" + +[module_my-module-three_host] +10.0.0.3 +10.0.1.3 + +[module_my-module-three_host.0] +10.0.0.3 + +[module_my-module-three_host.1] +10.0.1.3 + +[module_my-module-two_host] +10.0.0.2 + +[module_my-module-two_host.0] +10.0.0.2 + +[name_one-aws-instance] +35.159.25.34 + +[name_three-aws-instance] +10.0.0.3 +10.0.1.3 + +[name_two-aws-instance] +10.0.0.2 + +[one] +35.159.25.34 + +[one.0] +35.159.25.34 + +[type_aws_instance] +10.0.0.2 +10.0.0.3 +10.0.1.3 +35.159.25.34 + +` + +const expectedHostOneOutputTerraform0dot12 = ` +{ + "ami": "ami-00000000000000000", + "ansible_host": "35.159.25.34", + "id":"i-11111111111111111", + "private_ip":"10.0.0.1", + "public_ip": "35.159.25.34", + "tags.#": "1", + "tags.Name": "one-aws-instance", + "volume_tags.#":"1", + "volume_tags.Ignored":"stuff" +} +` + +func TestListCommandTerraform0dot12(t *testing.T) { + var s stateAnyTerraformVersion + r := strings.NewReader(exampleStateFileTerraform0dot12) + err := s.read(r) + assert.NoError(t, err) + + assert.Equal(t, TerraformVersion0dot12, s.TerraformVersion) + + // Decode expectation as JSON + var exp interface{} + err = json.Unmarshal([]byte(expectedListOutputTerraform0dot12), &exp) + assert.NoError(t, err) + + // Run the command, capture the output + var stdout, stderr bytes.Buffer + exitCode := cmdList(&stdout, &stderr, &s) + assert.Equal(t, 0, exitCode) + assert.Equal(t, "", stderr.String()) + + // Decode the output to compare + var act interface{} + err = json.Unmarshal([]byte(stdout.String()), &act) + assert.NoError(t, err) + + assert.Equal(t, exp, act) +} + +func TestHostCommandTerraform0dot12(t *testing.T) { + var s stateAnyTerraformVersion + r := strings.NewReader(exampleStateFileTerraform0dot12) + err := s.read(r) + assert.NoError(t, err) + + assert.Equal(t, TerraformVersion0dot12, s.TerraformVersion) + + // Decode expectation as JSON + var exp interface{} + err = json.Unmarshal([]byte(expectedHostOneOutputTerraform0dot12), &exp) + assert.NoError(t, err) + + // Run the command, capture the output + var stdout, stderr bytes.Buffer + exitCode := cmdHost(&stdout, &stderr, &s, "35.159.25.34") + assert.Equal(t, 0, exitCode) + assert.Equal(t, "", stderr.String()) + + // Decode the output to compare + var act interface{} + err = json.Unmarshal([]byte(stdout.String()), &act) + assert.NoError(t, err) + + assert.Equal(t, exp, act) +} + +func TestInventoryCommandTerraform0dot12(t *testing.T) { + var s stateAnyTerraformVersion + r := strings.NewReader(exampleStateFileTerraform0dot12) + err := s.read(r) + assert.NoError(t, err) + + assert.Equal(t, TerraformVersion0dot12, s.TerraformVersion) + + // Run the command, capture the output + var stdout, stderr bytes.Buffer + exitCode := cmdInventory(&stdout, &stderr, &s) + assert.Equal(t, 0, exitCode) + assert.Equal(t, "", stderr.String()) + + assert.Equal(t, expectedInventoryOutputTerraform0dot12, stdout.String()) +} + +// +// Terraform 0.12 END +// diff --git a/parser_test.go.example.tfstate b/parser_test.go.example.tfstate new file mode 100644 index 0000000..300853c --- /dev/null +++ b/parser_test.go.example.tfstate @@ -0,0 +1,86 @@ +{ + "format_version": "0.1", + "terraform_version": "0.12.1", + "values": { + "outputs": { + "my_endpoint": { + "sensitive": false, + "value": "a.b.c.d.example.com" + }, + "my_password": { + "sensitive": true, + "value": "1234" + }, + "map": { + "sensitive": false, + "value": { + "first": "a", + "second": "b" + } + } + }, + "root_module": { + "resources": [ + { + "address": "aws_instance.one", + "type": "aws_instance", + "name": "one", + "provider_name": "aws", + "schema_version": 1, + "values": { + "ami": "ami-00000000000000000", + "id": "i-11111111111111111", + "private_ip": "10.0.0.1", + "public_ip": "35.159.25.34", + "tags": { + "Name": "one-aws-instance" + }, + "volume_tags": { + "Ignored": "stuff" + } + } + } + ], + "child_modules": [ + { + "resources": [ + { + "address": "aws_instance.host", + "type": "aws_instance", + "name": "host", + "values": { + "ami": "ami-00000000000000001", + "id": "i-22222222222222222", + "private_ip": "10.0.0.2", + "public_ip": "", + "tags": { + "Name": "two-aws-instance" + } + } + } + ], + "address": "module.my-module-two" + }, + { + "resources": [ + { + "address": "aws_instance.host", + "type": "aws_instance", + "name": "host", + "values": { + "ami": "ami-00000000000000001", + "id": "i-33333333333333333", + "private_ip": "10.0.0.3", + "public_ip": "", + "tags": { + "Name": "three-aws-instance" + } + } + } + ], + "address": "module.my-module-three" + } + ] + } + } +} diff --git a/resource.go b/resource.go index 973f128..2908fee 100644 --- a/resource.go +++ b/resource.go @@ -16,16 +16,16 @@ var nameParser *regexp.Regexp func init() { keyNames = []string{ - "ipv4_address", // DO and SoftLayer - "public_ip", // AWS - "public_ipv6", // Scaleway - "ipaddress", // CS - "ip_address", // VMware, Docker, Linode - "private_ip", // AWS - "network_interface.0.ipv4_address", // VMware - "default_ip_address", // provider.vsphere v1.1.1 - "access_ip_v4", // OpenStack - "floating_ip", // OpenStack + "ipv4_address", // DO and SoftLayer + "public_ip", // AWS + "public_ipv6", // Scaleway + "ipaddress", // CS + "ip_address", // VMware, Docker, Linode + "private_ip", // AWS + "network_interface.0.ipv4_address", // VMware + "default_ip_address", // provider.vsphere v1.1.1 + "access_ip_v4", // OpenStack + "floating_ip", // OpenStack "network_interface.0.access_config.0.nat_ip", // GCE "network_interface.0.access_config.0.assigned_nat_ip", // GCE "network_interface.0.address", // GCE @@ -38,8 +38,11 @@ func init() { "nic_list.0.ip_endpoint_list.0.ip", // Nutanix } - // type.name.0 - nameParser = regexp.MustCompile(`^(\w+)\.([\w\-]+)(?:\.(\d+))?$`) + // Formats: + // - type.[module_]name (no `count` attribute; contains module name if we're not in the root module) + // - type.[module_]name.0 (if resource has `count` attribute) + // - "data." prefix should not parse and be ignored by caller (does not represent a host) + nameParser = regexp.MustCompile(`^([\w\-]+)\.([\w\-]+)(?:\.(\d+))?$`) } type Resource struct { @@ -62,15 +65,13 @@ func NewResource(keyName string, state resourceState) (*Resource, error) { m := nameParser.FindStringSubmatch(keyName) // This should not happen unless our regex changes. - // TODO: Warn instead of silently ignore error? if len(m) != 4 { - return nil, fmt.Errorf("couldn't parse keyName: %s", keyName) + return nil, fmt.Errorf("couldn't parse resource keyName: %s", keyName) } var c int var err error if m[3] != "" { - // The third section should be the index, if it's present. Not sure what // else we can do other than panic (which seems highly undesirable) if that // isn't the case.