diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..d4d716e --- /dev/null +++ b/cli.go @@ -0,0 +1,48 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" +) + +func cmdList(stdout io.Writer, stderr io.Writer, s *state) int { + groups := make(map[string][]string, 0) + + // add each instance as a pseudo-group, so they can be provisioned + // individually where necessary. + for name, inst := range s.instances() { + groups[name] = []string{inst.Attributes["private_ip"]} + } + + return output(stdout, stderr, groups) +} + +func cmdHost(stdout io.Writer, stderr io.Writer, s *state, hostname string) int { + for _, inst := range s.instances() { + if hostname == inst.Attributes["private_ip"] { + return output(stdout, stderr, inst.Attributes) + } + } + + fmt.Fprintf(stderr, "No such host: %s\n", hostname) + return 1 +} + +// output marshals an arbitrary JSON object and writes it to stdout, or writes +// an error to stderr, then returns the appropriate exit code. +func output(stdout io.Writer, stderr io.Writer, whatever interface{}) int { + b, err := json.Marshal(whatever) + if err != nil { + fmt.Fprintf(stderr, "Error encoding JSON: %s\n", err) + return 1 + } + + _, err = stdout.Write(b) + if err != nil { + fmt.Fprintf(stderr, "Error writing JSON: %s\n", err) + return 1 + } + + return 0 +} diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/cli_test.go @@ -0,0 +1 @@ +package main diff --git a/fixtures/example.tfstate b/fixtures/example.tfstate new file mode 100644 index 0000000..636a0af --- /dev/null +++ b/fixtures/example.tfstate @@ -0,0 +1,114 @@ +{ + "version": 1, + "serial": 1, + "modules": [ + { + "path": [ + "root" + ], + "outputs": {}, + "resources": { + "aws_instance.one": { + "type": "aws_instance", + "depends_on": [ + "aws_security_group.example" + ], + "primary": { + "id": "i-aaaaaaaa", + "attributes": { + "ami": "ami-XXXXXXXX", + "availability_zone": "us-east-1b", + "id": "i-aaaaaaaa", + "instance_type": "t2.micro", + "key_name": "", + "private_dns": "ip-1-1-1-1.ec2.internal", + "private_ip": "1.1.1.1", + "public_dns": "", + "public_ip": "", + "security_groups.#": "1", + "security_groups.0": "sg-cccccccc", + "subnet_id": "subnet-XXXXXXXX", + "tenancy": "default" + } + } + }, + "aws_instance.two": { + "type": "aws_instance", + "depends_on": [ + "aws_security_group.example" + ], + "primary": { + "id": "i-bbbbbbbb", + "attributes": { + "ami": "ami-XXXXXXXX", + "availability_zone": "us-east-1b", + "id": "i-bbbbbbbb", + "instance_type": "t2.micro", + "key_name": "", + "private_dns": "ip-2-2-2-2.ec2.internal", + "private_ip": "2.2.2.2", + "public_dns": "", + "public_ip": "", + "security_groups.#": "1", + "security_groups.0": "sg-cccccccc", + "subnet_id": "subnet-XXXXXXXX", + "tenancy": "default" + } + } + }, + "aws_route53_record.example": { + "type": "aws_route53_record", + "depends_on": [ + "aws_instance.one", + "aws_instance.two" + ], + "primary": { + "id": "XXXXXXXXXXXXXX_something.example.com_CNAME", + "attributes": { + "id": "XXXXXXXXXXXXXX_something.example.com_CNAME", + "name": "something.example.com", + "records.#": "2", + "records.0": "i-aaaaaaaa", + "records.1": "i-bbbbbbbb", + "ttl": "300", + "type": "CNAME", + "zone_id": "XXXXXXXXXXXXXX" + } + } + }, + "aws_security_group.example": { + "type": "aws_security_group", + "primary": { + "id": "sg-cccccccc", + "attributes": { + "description": "Allow SSH and HTTP from inside the firewall", + "id": "sg-cccccccc", + "ingress.#": "2", + "ingress.0.cidr_blocks.#": "2", + "ingress.0.cidr_blocks.0": "10.0.0.0/8", + "ingress.0.cidr_blocks.1": "192.168.0.0/16", + "ingress.0.from_port": "22", + "ingress.0.protocol": "tcp", + "ingress.0.security_groups.#": "0", + "ingress.0.self": "false", + "ingress.0.to_port": "22", + "ingress.1.cidr_blocks.#": "2", + "ingress.1.cidr_blocks.0": "10.0.0.0/8", + "ingress.1.cidr_blocks.1": "192.168.0.0/16", + "ingress.1.from_port": "80", + "ingress.1.protocol": "tcp", + "ingress.1.security_groups.#": "0", + "ingress.1.self": "false", + "ingress.1.to_port": "80", + "name": "example", + "owner_id": "111111111111", + "tags.App": "my_app", + "tags.Environment": "my_env", + "vpc_id": "vpc-XXXXXXXX" + } + } + } + } + } + ] +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e9a61c7 --- /dev/null +++ b/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" +) + +var version = flag.Bool("version", false, "print version information and exit") +var list = flag.Bool("list", false, "list mode") +var host = flag.String("host", "", "host mode") + +func main() { + flag.Parse() + file := flag.Arg(0) + + if *version == true { + fmt.Printf("%s version %d\n", os.Args[0], versionInfo()) + return + } + + if file == "" { + fmt.Printf("Usage: %s [options] path\n", os.Args[0]) + os.Exit(1) + } + + if !*list && *host == "" { + fmt.Println("Either --host or --list must be specified") + os.Exit(1) + } + + path, err := filepath.Abs(file) + if err != nil { + fmt.Printf("Invalid file: %s\n", err) + os.Exit(1) + } + + stateFile, err := os.Open(path) + defer stateFile.Close() + if err != nil { + fmt.Printf("Error opening tfstate file: %s\n", err) + os.Exit(1) + } + + var s state + err = s.read(stateFile) + if err != nil { + fmt.Printf("Error reading tfstate file: %s\n", err) + os.Exit(1) + } + + if *list { + os.Exit(cmdList(os.Stdout, os.Stderr, &s)) + + } else if *host != "" { + os.Exit(cmdHost(os.Stdout, os.Stderr, &s, *host)) + + } +} diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..617d167 --- /dev/null +++ b/parser.go @@ -0,0 +1,61 @@ +package main + +import ( + "io" + "io/ioutil" + "encoding/json" + "strings" +) + +type state struct { + Modules []moduleState `json:"modules"` +} + +// read populates the state object from a statefile. +func (s *state) read(stateFile io.Reader) error { + + // read statefile contents + b, err := ioutil.ReadAll(stateFile) + if err != nil { + return err + } + + // parse into struct + err = json.Unmarshal(b, s) + if err != nil { + return err + } + + return nil +} + +// hosts returns a map of name to instanceState, for each of the aws_instance +// resources found in the statefile. +func (s *state) instances() map[string]instanceState { + inst := make(map[string]instanceState) + + for _, m := range s.Modules { + for k, r := range m.Resources { + if r.Type == "aws_instance" { + name := strings.TrimPrefix(k, "aws_instance.") + inst[name] = r.Primary + } + } + } + + return inst +} + +type moduleState struct { + Resources map[string]resourceState `json:"resources"` +} + +type resourceState struct { + 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 new file mode 100644 index 0000000..0387020 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "github.com/stretchr/testify/assert" + "testing" + "strings" +) + +const exampleStateFile = ` +{ + "version": 1, + "serial": 1, + "modules": [ + { + "path": [ + "root" + ], + "outputs": {}, + "resources": { + "aws_instance.one": { + "type": "aws_instance", + "primary": { + "id": "i-aaaaaaaa", + "attributes": { + "ami": "ami-XXXXXXXX", + "id": "i-aaaaaaaa" + } + } + }, + "aws_instance.two": { + "type": "aws_instance", + "primary": { + "id": "i-bbbbbbbb", + "attributes": { + "ami": "ami-YYYYYYYY", + "id": "i-bbbbbbbb" + } + } + }, + "aws_security_group.example": { + "type": "aws_security_group", + "primary": { + "id": "sg-cccccccc", + "attributes": { + "description": "Whatever", + "id": "sg-cccccccc" + } + } + } + } + } + ] +} +` + +func TestStateRead(t *testing.T) { + r := strings.NewReader(exampleStateFile) + + var s state + err := s.read(r) + assert.Nil(t, err) + + exp := state{ + Modules: []moduleState{ + moduleState{ + Resources: map[string]resourceState{ + "aws_instance.one": resourceState{ + Type: "aws_instance", + Primary: instanceState{ + ID: "i-aaaaaaaa", + Attributes: map[string]string{ + "ami": "ami-XXXXXXXX", + "id": "i-aaaaaaaa", + }, + }, + }, + "aws_instance.two": resourceState{ + Type: "aws_instance", + Primary: instanceState{ + ID: "i-bbbbbbbb", + Attributes: map[string]string{ + "ami": "ami-YYYYYYYY", + "id": "i-bbbbbbbb", + }, + }, + }, + "aws_security_group.example": resourceState{ + Type: "aws_security_group", + Primary: instanceState{ + ID: "sg-cccccccc", + Attributes: map[string]string{ + "description": "Whatever", + "id": "sg-cccccccc", + }, + }, + }, + }, + }, + }, + } + + assert.Equal(t, exp, s) +} + +func TestInstances(t *testing.T) { + r := strings.NewReader(exampleStateFile) + + var s state + err := s.read(r) + assert.Nil(t, err) + + inst := s.instances() + assert.Equal(t, 2, len(inst)) + assert.Equal(t, "i-aaaaaaaa", inst["one"].ID) + assert.Equal(t, "i-bbbbbbbb", inst["two"].ID) +} diff --git a/terraform-inventory.go b/terraform-inventory.go deleted file mode 100644 index 801e19a..0000000 --- a/terraform-inventory.go +++ /dev/null @@ -1,139 +0,0 @@ -package main - -import ( - "encoding/json" - "flag" - "fmt" - "github.com/hashicorp/terraform/terraform" - "os" - "path/filepath" -) - -var list = flag.Bool("list", false, "list mode") -var host = flag.String("host", "", "host mode") - -type Host struct { - Name string `json:"name"` - IP string `json:"ip"` - vars HostVars -} - -type HostVars map[string]interface{} - -type Group struct { - name string - hosts []*Host -} - -func main() { - flag.Parse() - file := flag.Arg(0) - - if file == "" { - fmt.Printf("Usage: %s [options] path\n", os.Args[0]) - os.Exit(1) - } - - if !*list && *host == "" { - fmt.Println("Either --host or --list must be specified") - os.Exit(1) - } - - path, err := filepath.Abs(file) - if err != nil { - fmt.Printf("Invalid file: %s\n", err) - os.Exit(1) - } - - f, err := os.Open(path) - if err != nil { - fmt.Printf("Error opening tfstate file: %s\n", err) - os.Exit(1) - } - - state, err := terraform.ReadState(f) - if err != nil { - fmt.Printf("Error reading state: %s\n", err) - os.Exit(1) - } - - if *list { - hosts := mustGetList(state) - os.Stdout.Write(mustMarshal(hosts)) - - } else if *host != "" { - host := mustGetHost(state, *host) - os.Stdout.Write(mustMarshal(host.vars)) - } -} - -func mustGetList(state *terraform.State) interface{} { - hosts, err := getList(state) - - if err != nil { - fmt.Printf("Error getting host list: %s\n", err) - os.Exit(1) - } - - return hosts -} - -func mustGetHost(state *terraform.State, hostname string) *Host { - host, err := getHost(state, hostname) - - if err != nil { - fmt.Printf("Error getting host variables: %s\n", err) - os.Exit(1) - } - - return host -} - -func mustMarshal(whatever interface{}) []byte { - json, err := json.Marshal(whatever) - - if err != nil { - fmt.Printf("Error encoding JSON: %s\n", err) - os.Exit(1) - } - - return json -} - -func getList(state *terraform.State) (interface{}, error) { - hostnames := make([]string, 0) - for _, h := range readHosts(state) { - hostnames = append(hostnames, h.IP) - } - - groups := map[string][]string{"production": hostnames} - return groups, nil -} - -func getHost(state *terraform.State, hostname string) (*Host, error) { - for _, h := range readHosts(state) { - if h.IP == hostname { - return h, nil - } - } - - return nil, fmt.Errorf("No such host: %s", hostname) -} - -func readHosts(state *terraform.State) []*Host { - hosts := make([]*Host, 0) - - for _, resource := range state.Resources { - if resource.Type == "aws_instance" { - host := &Host{ - Name: resource.Attributes["id"], - IP: resource.Attributes["public_ip"], - vars: HostVars{}, - } - - hosts = append(hosts, host) - } - } - - return hosts -} diff --git a/version.go b/version.go new file mode 100644 index 0000000..a520ffe --- /dev/null +++ b/version.go @@ -0,0 +1,15 @@ +package main + +// Deliberately uninitialized. See below. +var build_version string + +// versionInfo returns a string containing the version information of the +// current build. It's empty by default, but can be included as part of the +// build process by setting the main.build_version variable. +func versionInfo() string { + if build_version != "" { + return build_version + } else { + return "unknown" + } +} diff --git a/version_test.go b/version_test.go new file mode 100644 index 0000000..cea3ffa --- /dev/null +++ b/version_test.go @@ -0,0 +1,13 @@ +package main + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestVersionInfo(t *testing.T) { + assert.Equal(t, "unknown", versionInfo()) + + build_version = "vXYZ" + assert.Equal(t, "vXYZ", versionInfo()) +}