terraform/terraform.go (247 lines of code) (raw):

// Copyright 2023 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package terraform handles parsing Terraform files to extract structured // information out of it, to support a few tools to speed up the DeployStack // authoring process package terraform import ( _ "embed" "fmt" "io/ioutil" "os" "sort" "strings" "github.com/hashicorp/terraform-config-inspect/tfconfig" "gopkg.in/yaml.v2" ) //go:embed resources.yaml var resources []byte // Extract points to a path that includes Terraform files and extracts all of // the information out of it for use with DeployStack Tools func Extract(path string) (*Blocks, error) { mod, dia := tfconfig.LoadModule(path) if dia.Err() != nil { return nil, fmt.Errorf("terraform config problem %s", dia.Err()) } b, err := NewBlocks(mod) if err != nil { return nil, fmt.Errorf("could not properly parse blocks %s", err) } return b, nil } // Block represents one of several kinds of Terraform constructs: resources, // variables, module type Block struct { Name string `json:"name" yaml:"name"` Text string `json:"text" yaml:"text"` Kind string `json:"kind" yaml:"kind"` Type string `json:"type" yaml:"type"` Attr map[string]string `json:"attr" yaml:"attr"` File string `json:"file" yaml:"file"` Start int `json:"start" yaml:"start"` } // NewResourceBlock converts a parsed Terraform Resource to a Block func NewResourceBlock(t *tfconfig.Resource) (Block, error) { b := Block{} var err error b.Name = t.Name b.Type = t.Type b.Kind = t.Mode.String() b.Start = t.Pos.Line b.File = t.Pos.Filename b.Text, err = getResourceText(t.Pos.Filename, t.Pos.Line) if err != nil { return b, fmt.Errorf("could not extract text from Resource: %s", err) } return b, nil } // NewVariableBlock converts a parsed Terraform Variable to a Block func NewVariableBlock(t *tfconfig.Variable) (Block, error) { b := Block{} var err error b.Name = t.Name b.Type = t.Type b.Kind = "variable" b.Start = t.Pos.Line b.File = t.Pos.Filename b.Text, err = getResourceText(t.Pos.Filename, t.Pos.Line) if err != nil { return b, fmt.Errorf("could not extract text from Variable: %s", err) } return b, nil } // NewModuleBlock converts a parsed Terraform Module to a Block func NewModuleBlock(t *tfconfig.ModuleCall) (Block, error) { b := Block{} var err error b.Name = t.Name b.Type = t.Source b.Kind = "module" b.Start = t.Pos.Line b.File = t.Pos.Filename b.Text, err = getResourceText(t.Pos.Filename, t.Pos.Line) if err != nil { return b, fmt.Errorf("could not extract text from Module: %s", err) } return b, nil } // IsResource returns true if block is a Terraform resource func (b Block) IsResource() bool { return b.Kind == "managed" } // IsModule returns true if block is a Terraform module func (b Block) IsModule() bool { return b.Kind == "module" } // IsVariable returns true if block is a Terraform variable func (b Block) IsVariable() bool { return b.Kind == "variable" } // NoDefault returns true if block does not contain a default value func (b Block) NoDefault() bool { return !strings.Contains(b.Text, "default") } func getResourceText(file string, start int) (string, error) { dat, err := os.ReadFile(file) if err != nil { return "", fmt.Errorf("could not get terraform file: %s", err) } sl := strings.Split(string(dat), "\n") resultSl := []string{} startpos := start - 1 end := findClosingBracket(start, sl) + 1 if startpos == 0 { startpos = 1 } for i := startpos - 1; i < end; i++ { resultSl = append(resultSl, sl[i]) } result := strings.Join(resultSl, "\n") return result, nil } func findClosingBracket(start int, sl []string) int { count := 0 for i := start - 1; i < len(sl); i++ { if strings.Contains(sl[i], "{") { count++ } if strings.Contains(sl[i], "}") { count-- } if count == 0 { return i } } return len(sl) } // Blocks is a slice of type Block type Blocks []Block // NewBlocks converts the results from a Terraform parse operation to Blocks. func NewBlocks(mod *tfconfig.Module) (*Blocks, error) { result := Blocks{} for _, v := range mod.ModuleCalls { b, err := NewModuleBlock(v) if err != nil { return nil, fmt.Errorf("could not parse Module Calls: %s", err) } result = append(result, b) } for _, v := range mod.ManagedResources { b, err := NewResourceBlock(v) if err != nil { return nil, fmt.Errorf("could not parse ManagedResources: %s", err) } result = append(result, b) } for _, v := range mod.DataResources { b, err := NewResourceBlock(v) if err != nil { return nil, fmt.Errorf("could not parse DataResources: %s", err) } result = append(result, b) } for _, v := range mod.Variables { b, err := NewVariableBlock(v) if err != nil { return nil, fmt.Errorf("could not parse Variables: %s", err) } result = append(result, b) } sort.Slice(result, func(i, j int) bool { return result[i].Start < result[j].Start }) return &result, nil } // Search will query the give blocks by field and match any whose field // contains the string func (b Blocks) Search(q, field string) Blocks { result := Blocks{} for _, v := range b { switch field { case "type": if strings.Contains(v.Type, q) { result = append(result, v) } case "name": if strings.Contains(v.Name, q) { result = append(result, v) } case "kind": if strings.Contains(v.Kind, q) { result = append(result, v) } case "file": if strings.Contains(v.File, q) { result = append(result, v) } } } return result } // Sort sorts a collections of blocks by file and by start line number func (b *Blocks) Sort() { sort.Slice(*b, func(i, j int) bool { if (*b)[i].File != (*b)[j].File { return (*b)[i].File < (*b)[j].File } return (*b)[i].Start < (*b)[j].Start }) } // List is a slice of strings that we add extra functionality to type List []string // Matches determines if a given string is an entry in the list. func (l List) Matches(s string) bool { tmp := strings.ToLower(s) for _, v := range l { if strings.Contains(tmp, strings.ToLower(v)) { return true } } return false } // GCPResources is a collection of GCPResource type GCPResources map[string]GCPResource // GetProduct returns the prouct name associated with the terraform resource func (g GCPResources) GetProduct(key string) string { v, ok := g[key] if !ok { return "" } return v.Product } // GCPResource is a Terraform resource that matches up with a GCP product. This // is used to automate the generation of tests and documentation type GCPResource struct { Label string `json:"label" yaml:"label"` Product string `json:"product" yaml:"product"` APICalls []string `json:"api_calls" yaml:"api_calls"` TestConfig TestConfig `json:"test_config" yaml:"test_config"` AliasOf []string `json:"aliasof" yaml:"aliasof"` } // TestConfig is the information needed to automate test creation. type TestConfig struct { TestType string `json:"test_type" yaml:"test_type"` TestCommand string `json:"test_command" yaml:"test_command"` Suffix string `json:"suffix" yaml:"suffix"` Region bool `json:"region" yaml:"region"` Zone bool `json:"zone" yaml:"zone"` LabelField string `json:"label_field" yaml:"label_field"` Expected string `json:"expected" yaml:"expected"` Todo string `json:"todo" yaml:"todo"` } // HasTest returns true if test config actually has a test in it. func (t TestConfig) HasTest() bool { return len(t.TestCommand) > 0 } // HasTodo returns true if test config actually has a todo in it. func (t TestConfig) HasTodo() bool { return len(t.Todo) > 0 } // NewGCPResources reads in a yaml file as a config func NewGCPResources() (GCPResources, error) { result := GCPResources{} if err := yaml.Unmarshal(resources, &result); err != nil { return result, fmt.Errorf("unable to convert content to GCPResources: %s", err) } return result, nil } // Repos is a slice of strings containing github urls type Repos []string // NewRepos retrieves the list of repos from a ymal file. func NewRepos(path string) (Repos, error) { result := Repos{} content, err := ioutil.ReadFile(path) if err != nil { return result, fmt.Errorf("unable to find or read config file: %s", err) } if err := yaml.Unmarshal(content, &result); err != nil { return result, fmt.Errorf("unable to convert content to a list of repos: %s", err) } return result, nil }