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  }