github.com/pix4d/terravalet@v0.8.1-0.20240131132849-abcd6a79eeeb/cmdmoverename.go (about) 1 package main 2 3 import ( 4 "bufio" 5 "fmt" 6 "io" 7 "os" 8 "regexp" 9 "sort" 10 "strings" 11 12 "github.com/dexyk/stringosim" 13 "github.com/scylladb/go-set" 14 "github.com/scylladb/go-set/strset" 15 ) 16 17 func doRename(upPath, downPath, planPath, localStatePath string, fuzzyMatch bool) error { 18 planFile, err := os.Open(planPath) 19 if err != nil { 20 return fmt.Errorf("opening the terraform plan file: %v", err) 21 } 22 defer planFile.Close() 23 24 upFile, err := os.Create(upPath) 25 if err != nil { 26 return fmt.Errorf("creating the up file: %v", err) 27 } 28 defer upFile.Close() 29 30 downFile, err := os.Create(downPath) 31 if err != nil { 32 return fmt.Errorf("creating the down file: %v", err) 33 } 34 defer downFile.Close() 35 36 create, destroy, err := parse(planFile) 37 if err != nil { 38 return fmt.Errorf("parse: %v", err) 39 } 40 41 upMatches, downMatches := matchExact(create, destroy) 42 43 msg := collectErrors(create, destroy) 44 if msg != "" && !fuzzyMatch { 45 return fmt.Errorf("matchExact:%v", msg) 46 } 47 48 if fuzzyMatch && create.Size() == 0 && destroy.Size() == 0 { 49 return fmt.Errorf("required fuzzy-match but there is nothing left to match") 50 } 51 if fuzzyMatch { 52 upMatches, downMatches, err = matchFuzzy(create, destroy) 53 if err != nil { 54 return fmt.Errorf("fuzzyMatch: %v", err) 55 } 56 msg := collectErrors(create, destroy) 57 if msg != "" { 58 return fmt.Errorf("matchFuzzy: %v", msg) 59 } 60 } 61 62 stateFlags := "-state=" + localStatePath 63 64 if err := upDownScript(upMatches, stateFlags, upFile); err != nil { 65 return fmt.Errorf("writing the up script: %v", err) 66 } 67 if err := upDownScript(downMatches, stateFlags, downFile); err != nil { 68 return fmt.Errorf("writing the down script: %v", err) 69 } 70 71 return nil 72 } 73 74 func doMoveAfter(script, before, after string) error { 75 beforePlanPath := before + ".tfplan" 76 beforePlanFile, err := os.Open(beforePlanPath) 77 if err != nil { 78 return fmt.Errorf("opening the terraform BEFORE plan file: %v", err) 79 } 80 defer beforePlanFile.Close() 81 82 afterPlanPath := after + ".tfplan" 83 afterPlanFile, err := os.Open(afterPlanPath) 84 if err != nil { 85 return fmt.Errorf("opening the terraform AFTER plan file: %v", err) 86 } 87 defer beforePlanFile.Close() 88 89 upPath := script + "_up.sh" 90 upFile, err := os.Create(upPath) 91 if err != nil { 92 return fmt.Errorf("creating the up file: %v", err) 93 } 94 defer upFile.Close() 95 96 downPath := script + "_down.sh" 97 downFile, err := os.Create(downPath) 98 if err != nil { 99 return fmt.Errorf("creating the down file: %v", err) 100 } 101 defer downFile.Close() 102 103 beforeCreate, beforeDestroy, err := parse(beforePlanFile) 104 if err != nil { 105 return fmt.Errorf("parse BEFORE plan: %v", err) 106 } 107 if beforeCreate.Size() > 0 { 108 return fmt.Errorf("BEFORE plan contains resources to create: %v", 109 sorted(beforeCreate.List())) 110 } 111 112 afterCreate, afterDestroy, err := parse(afterPlanFile) 113 if err != nil { 114 return fmt.Errorf("parse AFTER plan: %v", err) 115 } 116 if afterDestroy.Size() > 0 { 117 return fmt.Errorf("AFTER plan contains resources to destroy: %v", 118 sorted(afterDestroy.List())) 119 } 120 121 upMatches, downMatches := matchExact(afterCreate, beforeDestroy) 122 123 msg := collectErrors(afterCreate, beforeDestroy) 124 if msg != "" { 125 return fmt.Errorf("matchExact:%v", msg) 126 } 127 128 beforeStatePath := before + ".tfstate" 129 afterStatePath := after + ".tfstate" 130 131 upStateFlags := fmt.Sprintf("-state=%s -state-out=%s", beforeStatePath, afterStatePath) 132 downStateFlags := fmt.Sprintf("-state=%s -state-out=%s", afterStatePath, beforeStatePath) 133 134 if err := upDownScript(upMatches, upStateFlags, upFile); err != nil { 135 return fmt.Errorf("writing the up script: %v", err) 136 } 137 if err := upDownScript(downMatches, downStateFlags, downFile); err != nil { 138 return fmt.Errorf("writing the down script: %v", err) 139 } 140 141 return nil 142 } 143 144 func doMoveBefore(script, before, after string) error { 145 beforePlanPath := before + ".tfplan" 146 beforePlanFile, err := os.Open(beforePlanPath) 147 if err != nil { 148 return fmt.Errorf("opening the terraform BEFORE plan file: %v", err) 149 } 150 defer beforePlanFile.Close() 151 152 upPath := script + "_up.sh" 153 upFile, err := os.Create(upPath) 154 if err != nil { 155 return fmt.Errorf("creating the up file: %v", err) 156 } 157 defer upFile.Close() 158 159 downPath := script + "_down.sh" 160 downFile, err := os.Create(downPath) 161 if err != nil { 162 return fmt.Errorf("creating the down file: %v", err) 163 } 164 defer downFile.Close() 165 166 beforeCreate, beforeDestroy, err := parse(beforePlanFile) 167 if err != nil { 168 return fmt.Errorf("parse BEFORE plan: %v", err) 169 } 170 if beforeCreate.Size() == 0 { 171 return fmt.Errorf("BEFORE plan does not contain resources to create") 172 } 173 if beforeDestroy.Size() > 0 { 174 return fmt.Errorf("BEFORE plan contains resources to destroy: %s", 175 sorted(beforeDestroy.List())) 176 } 177 178 upMatches, downMatches := matchExact(beforeCreate, beforeCreate) 179 180 beforeStatePath := before + ".tfstate" 181 afterStatePath := after + ".tfstate" 182 183 upStateFlags := fmt.Sprintf("-state=%s -state-out=%s", afterStatePath, beforeStatePath) 184 downStateFlags := fmt.Sprintf("-state=%s -state-out=%s", beforeStatePath, afterStatePath) 185 186 if err := upDownScript(upMatches, upStateFlags, upFile); err != nil { 187 return fmt.Errorf("writing the up script: %v", err) 188 } 189 if err := upDownScript(downMatches, downStateFlags, downFile); err != nil { 190 return fmt.Errorf("writing the down script: %v", err) 191 } 192 193 return nil 194 } 195 196 func collectErrors(create *strset.Set, destroy *strset.Set) string { 197 msg := "" 198 if create.Size() != 0 { 199 msg += "\nunmatched create:\n " + strings.Join(sorted(create.List()), "\n ") 200 } 201 if destroy.Size() != 0 { 202 msg += "\nunmatched destroy:\n " + strings.Join(sorted(destroy.List()), "\n ") 203 } 204 return msg 205 } 206 207 // Parse the output of "terraform plan" and return two sets, the first a set of elements 208 // to be created and the second a set of elements to be destroyed. The two sets are 209 // unordered. 210 // 211 // For example: 212 // " # module.ci.aws_instance.docker will be destroyed" 213 // " # aws_instance.docker will be created" 214 // " # module.ci.module.workers["windows-vs2019"].aws_autoscaling_schedule.night_mode will be destroyed" 215 // " # module.workers["windows-vs2019"].aws_autoscaling_schedule.night_mode will be created" 216 func parse(rd io.Reader) (*strset.Set, *strset.Set, error) { 217 var re = regexp.MustCompile(`# (.+) will be (.+)`) 218 219 create := set.NewStringSet() 220 destroy := set.NewStringSet() 221 222 scanner := bufio.NewScanner(rd) 223 for scanner.Scan() { 224 line := scanner.Text() 225 if m := re.FindStringSubmatch(line); m != nil { 226 if len(m) != 3 { 227 return create, destroy, 228 fmt.Errorf("could not parse line %q: %q", line, m) 229 } 230 switch m[2] { 231 case "created": 232 create.Add(m[1]) 233 case "destroyed": 234 destroy.Add(m[1]) 235 case "read during apply": 236 // do nothing 237 default: 238 return create, destroy, 239 fmt.Errorf("line %q, unexpected action %q", line, m[2]) 240 } 241 } 242 } 243 244 if err := scanner.Err(); err != nil { 245 return create, destroy, err 246 } 247 248 return create, destroy, nil 249 } 250 251 // Given two unordered sets create and destroy, perform an exact match from destroy to create. 252 // 253 // Return two maps, the first that exact matches each old element in destroy to the 254 // corresponding new element in create (up), the second that matches in the opposite 255 // direction (down). 256 // 257 // Modify the two input sets so that they contain only the remaining (if any) unmatched elements. 258 // 259 // The criterium used to perform a matchExact is that one of the two elements must be a 260 // prefix of the other. 261 // Note that the longest element could be the old or the new one, it depends on the inputs. 262 func matchExact(create, destroy *strset.Set) (map[string]string, map[string]string) { 263 // old -> new (or equvalenty: destroy -> create) 264 upMatches := map[string]string{} 265 downMatches := map[string]string{} 266 267 // 1. Create and destroy give us the direction: 268 // terraform state mv destroy[i] create[j] 269 // 2. But, for each resource, we need to know i,j so that we can match which old state 270 // we want to move to which new state, for example both are theoretically valid: 271 // terraform state mv module.ci.aws_instance.docker aws_instance.docker 272 // terraform state mv aws_instance.docker module.ci.aws_instance.docker 273 274 for _, d := range destroy.List() { 275 for _, c := range create.List() { 276 if strings.HasSuffix(c, d) || strings.HasSuffix(d, c) { 277 upMatches[d] = c 278 downMatches[c] = d 279 // Remove matched elements from the two sets. 280 destroy.Remove(d) 281 create.Remove(c) 282 } 283 } 284 } 285 286 // Now the two sets create, destroy contain only unmatched elements. 287 return upMatches, downMatches 288 } 289 290 // Given two unordered sets create and destroy, that have already been processed by 291 // matchExact(), perform a fuzzy match from destroy to create. 292 // 293 // Return two maps, the first that fuzzy matches each old element in destroy to the 294 // corresponding new element in create (up), the second that matches in the opposite 295 // direction (down). 296 // 297 // Modify the two input sets so that they contain only the remaining (if any) unmatched elements. 298 // 299 // The criterium used to perform a matchFuzzy is that one of the two elements must be a 300 // fuzzy match of the other, according to some definition of fuzzy. 301 // Note that the longest element could be the old or the new one, it depends on the inputs. 302 func matchFuzzy(create, destroy *strset.Set) (map[string]string, map[string]string, error) { 303 // old -> new (or equvalenty: destroy -> create) 304 upMatches := map[string]string{} 305 downMatches := map[string]string{} 306 307 type candidate struct { 308 distance int 309 create string 310 destroy string 311 } 312 candidates := []candidate{} 313 314 for _, d := range destroy.List() { 315 for _, c := range create.List() { 316 // Here we could also use a custom NGramSizes via 317 // stringosim.QGramSimilarityOptions 318 dist := stringosim.QGram([]rune(d), []rune(c)) 319 candidates = append(candidates, candidate{dist, c, d}) 320 } 321 } 322 sort.Slice(candidates, 323 func(i, j int) bool { return candidates[i].distance < candidates[j].distance }) 324 325 for len(candidates) > 0 { 326 bestCandidate := candidates[0] 327 tmpCandidates := []candidate{} 328 329 for _, c := range candidates[1:] { 330 if bestCandidate.distance == c.distance { 331 if (bestCandidate.create == c.create) || (bestCandidate.destroy == c.destroy) { 332 return map[string]string{}, map[string]string{}, 333 fmt.Errorf("ambiguous migration: {%s} -> {%s} or {%s} -> {%s}", 334 bestCandidate.create, bestCandidate.destroy, 335 c.create, c.destroy, 336 ) 337 } 338 } 339 if (bestCandidate.create != c.create) && (bestCandidate.destroy != c.destroy) { 340 tmpCandidates = append(tmpCandidates, candidate{c.distance, c.create, c.destroy}) 341 } 342 343 } 344 345 candidates = tmpCandidates 346 upMatches[bestCandidate.destroy] = bestCandidate.create 347 downMatches[bestCandidate.create] = bestCandidate.destroy 348 destroy.Remove(bestCandidate.destroy) 349 create.Remove(bestCandidate.create) 350 } 351 352 return upMatches, downMatches, nil 353 } 354 355 // Given a map old->new, create a script that for each element in the map issues the 356 // command: "terraform state mv old new". 357 func upDownScript(matches map[string]string, stateFlags string, out io.Writer) error { 358 fmt.Fprintf(out, "#! /bin/sh\n") 359 fmt.Fprintf(out, "# DO NOT EDIT. Generated by terravalet.\n") 360 fmt.Fprintf(out, "# terravalet_output_format=2\n") 361 fmt.Fprintf(out, "#\n") 362 fmt.Fprintf(out, "# This script will move %d items.\n\n", len(matches)) 363 fmt.Fprintf(out, "set -e\n\n") 364 365 // -lock=false greatly speeds up operations when the state has many elements 366 // and is safe as long as we use -state=FILE, since this keeps operations 367 // strictly local, without considering the configured backend. 368 cmd := fmt.Sprintf("terraform state mv -lock=false %s", stateFlags) 369 370 // Go maps are unordered. We want instead a stable iteration order, to make it 371 // possible to compare scripts. 372 destroys := make([]string, 0, len(matches)) 373 for d := range matches { 374 destroys = append(destroys, d) 375 } 376 sort.Strings(destroys) 377 378 i := 1 379 for _, d := range destroys { 380 fmt.Fprintf(out, "%s \\\n '%s' \\\n '%s'\n\n", cmd, d, matches[d]) 381 i++ 382 } 383 return nil 384 } 385 386 // sorted returns a sorted slice of strings. 387 // Useful to be able to write 388 // 389 // ... sorted(create.List()) ... 390 // 391 // instead of 392 // 393 // elems := create.List() 394 // sort.Strings(elems) 395 // ... elems ... 396 // 397 func sorted(in []string) []string { 398 sort.Strings(in) 399 return in 400 }