github.com/rstandt/terraform@v0.12.32-0.20230710220336-b1063613405c/configs/configupgrade/module_sources.go (about) 1 package configupgrade 2 3 import ( 4 "fmt" 5 "io/ioutil" 6 "path/filepath" 7 "strings" 8 9 "github.com/zclconf/go-cty/cty" 10 11 "github.com/hashicorp/terraform/configs" 12 "github.com/hashicorp/terraform/tfdiags" 13 14 "github.com/hashicorp/hcl/v2" 15 hcl2syntax "github.com/hashicorp/hcl/v2/hclsyntax" 16 17 version "github.com/hashicorp/go-version" 18 ) 19 20 type ModuleSources map[string][]byte 21 22 // LoadModule looks for Terraform configuration files in the given directory 23 // and loads each of them into memory as source code, in preparation for 24 // further analysis and conversion. 25 // 26 // At this stage the files are not parsed at all. Instead, we just read the 27 // raw bytes from the file so that they can be passed into a parser in a 28 // separate step. 29 // 30 // If the given directory or any of the files cannot be read, an error is 31 // returned. It is not safe to proceed with processing in that case because 32 // we cannot "see" all of the source code for the configuration. 33 func LoadModule(dir string) (ModuleSources, error) { 34 entries, err := ioutil.ReadDir(dir) 35 if err != nil { 36 return nil, err 37 } 38 39 ret := make(ModuleSources) 40 for _, entry := range entries { 41 name := entry.Name() 42 if entry.IsDir() { 43 continue 44 } 45 if configs.IsIgnoredFile(name) { 46 continue 47 } 48 ext := fileExt(name) 49 if ext == "" { 50 continue 51 } 52 53 fullPath := filepath.Join(dir, name) 54 src, err := ioutil.ReadFile(fullPath) 55 if err != nil { 56 return nil, err 57 } 58 59 ret[name] = src 60 } 61 62 return ret, nil 63 } 64 65 // UnusedFilename finds a filename that isn't already used by a file in 66 // the receiving sources and returns it. 67 // 68 // The given "proposed" name is returned verbatim if it isn't already used. 69 // Otherwise, the function will try appending incrementing integers to the 70 // proposed name until an unused name is found. Callers should propose names 71 // that they do not expect to already be in use so that numeric suffixes are 72 // only used in rare cases. 73 // 74 // The proposed name must end in either ".tf" or ".tf.json" because a 75 // ModuleSources only has visibility into such files. This function will 76 // panic if given a file whose name does not end with one of these 77 // extensions. 78 // 79 // A ModuleSources only works on one directory at a time, so the proposed 80 // name must not contain any directory separator characters. 81 func (ms ModuleSources) UnusedFilename(proposed string) string { 82 ext := fileExt(proposed) 83 if ext == "" { 84 panic(fmt.Errorf("method UnusedFilename used with invalid proposal %q", proposed)) 85 } 86 87 if _, exists := ms[proposed]; !exists { 88 return proposed 89 } 90 91 base := proposed[:len(proposed)-len(ext)] 92 for i := 1; ; i++ { 93 try := fmt.Sprintf("%s-%d%s", base, i, ext) 94 if _, exists := ms[try]; !exists { 95 return try 96 } 97 } 98 } 99 100 // MaybeAlreadyUpgraded is a heuristic to see if a given module may have 101 // already been upgraded by this package. 102 // 103 // The heuristic used is to look for a Terraform Core version constraint in 104 // any of the given sources that seems to be requiring a version greater than 105 // or equal to v0.12.0. If true is returned then the source range of the found 106 // version constraint is returned in case the caller wishes to present it to 107 // the user as context for a warning message. The returned range is not 108 // meaningful if false is returned. 109 func (ms ModuleSources) MaybeAlreadyUpgraded() (bool, tfdiags.SourceRange) { 110 for name, src := range ms { 111 f, diags := hcl2syntax.ParseConfig(src, name, hcl.Pos{Line: 1, Column: 1}) 112 if diags.HasErrors() { 113 // If we can't parse at all then that's a reasonable signal that 114 // we _haven't_ been upgraded yet, but we'll continue checking 115 // other files anyway. 116 continue 117 } 118 119 content, _, diags := f.Body.PartialContent(&hcl.BodySchema{ 120 Blocks: []hcl.BlockHeaderSchema{ 121 { 122 Type: "terraform", 123 }, 124 }, 125 }) 126 if diags.HasErrors() { 127 // Suggests that the file has an invalid "terraform" block, such 128 // as one with labels. 129 continue 130 } 131 132 for _, block := range content.Blocks { 133 content, _, diags := block.Body.PartialContent(&hcl.BodySchema{ 134 Attributes: []hcl.AttributeSchema{ 135 { 136 Name: "required_version", 137 }, 138 }, 139 }) 140 if diags.HasErrors() { 141 continue 142 } 143 144 attr, present := content.Attributes["required_version"] 145 if !present { 146 continue 147 } 148 149 constraintVal, diags := attr.Expr.Value(nil) 150 if diags.HasErrors() { 151 continue 152 } 153 if constraintVal.Type() != cty.String || constraintVal.IsNull() { 154 continue 155 } 156 157 constraints, err := version.NewConstraint(constraintVal.AsString()) 158 if err != nil { 159 continue 160 } 161 162 // The go-version package doesn't actually let us see the details 163 // of the parsed constraints here, so we now need a bit of an 164 // abstraction inversion to decide if any of the given constraints 165 // match our heuristic. However, we do at least get to benefit 166 // from go-version's ability to extract multiple constraints from 167 // a single string and the fact that it's already validated each 168 // constraint to match its expected pattern. 169 Constraints: 170 for _, constraint := range constraints { 171 str := strings.TrimSpace(constraint.String()) 172 // Want to match >, >= and ~> here. 173 if !(strings.HasPrefix(str, ">") || strings.HasPrefix(str, "~>")) { 174 continue 175 } 176 177 // Try to find something in this string that'll parse as a version. 178 for i := 1; i < len(str); i++ { 179 candidate := str[i:] 180 v, err := version.NewVersion(candidate) 181 if err != nil { 182 continue 183 } 184 185 if v.Equal(firstVersionWithNewParser) || v.GreaterThan(firstVersionWithNewParser) { 186 // This constraint appears to be preventing the old 187 // parser from being used, so we suspect it was 188 // already upgraded. 189 return true, tfdiags.SourceRangeFromHCL(attr.Range) 190 } 191 192 // If we fall out here then we _did_ find something that 193 // parses as a version, so we'll stop and move on to the 194 // next constraint. (Otherwise we'll pass by 0.7.0 and find 195 // 7.0, which is also a valid version.) 196 continue Constraints 197 } 198 } 199 } 200 } 201 return false, tfdiags.SourceRange{} 202 } 203 204 var firstVersionWithNewParser = version.Must(version.NewVersion("0.12.0")) 205 206 // fileExt returns the Terraform configuration extension of the given 207 // path, or a blank string if it is not a recognized extension. 208 func fileExt(path string) string { 209 if strings.HasSuffix(path, ".tf") { 210 return ".tf" 211 } else if strings.HasSuffix(path, ".tf.json") { 212 return ".tf.json" 213 } else { 214 return "" 215 } 216 }