github.com/eliastor/durgaform@v0.0.0-20220816172711-d0ab2d17673e/internal/command/views/plan.go (about)

     1  package views
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"sort"
     7  	"strings"
     8  
     9  	"github.com/zclconf/go-cty/cty"
    10  
    11  	"github.com/eliastor/durgaform/internal/addrs"
    12  	"github.com/eliastor/durgaform/internal/command/arguments"
    13  	"github.com/eliastor/durgaform/internal/command/format"
    14  	"github.com/eliastor/durgaform/internal/configs/configschema"
    15  	"github.com/eliastor/durgaform/internal/lang/globalref"
    16  	"github.com/eliastor/durgaform/internal/plans"
    17  	"github.com/eliastor/durgaform/internal/plans/objchange"
    18  	"github.com/eliastor/durgaform/internal/durgaform"
    19  	"github.com/eliastor/durgaform/internal/tfdiags"
    20  )
    21  
    22  // The Plan view is used for the plan command.
    23  type Plan interface {
    24  	Operation() Operation
    25  	Hooks() []durgaform.Hook
    26  
    27  	Diagnostics(diags tfdiags.Diagnostics)
    28  	HelpPrompt()
    29  }
    30  
    31  // NewPlan returns an initialized Plan implementation for the given ViewType.
    32  func NewPlan(vt arguments.ViewType, view *View) Plan {
    33  	switch vt {
    34  	case arguments.ViewJSON:
    35  		return &PlanJSON{
    36  			view: NewJSONView(view),
    37  		}
    38  	case arguments.ViewHuman:
    39  		return &PlanHuman{
    40  			view:         view,
    41  			inAutomation: view.RunningInAutomation(),
    42  		}
    43  	default:
    44  		panic(fmt.Sprintf("unknown view type %v", vt))
    45  	}
    46  }
    47  
    48  // The PlanHuman implementation renders human-readable text logs, suitable for
    49  // a scrolling terminal.
    50  type PlanHuman struct {
    51  	view *View
    52  
    53  	inAutomation bool
    54  }
    55  
    56  var _ Plan = (*PlanHuman)(nil)
    57  
    58  func (v *PlanHuman) Operation() Operation {
    59  	return NewOperation(arguments.ViewHuman, v.inAutomation, v.view)
    60  }
    61  
    62  func (v *PlanHuman) Hooks() []durgaform.Hook {
    63  	return []durgaform.Hook{
    64  		NewUiHook(v.view),
    65  	}
    66  }
    67  
    68  func (v *PlanHuman) Diagnostics(diags tfdiags.Diagnostics) {
    69  	v.view.Diagnostics(diags)
    70  }
    71  
    72  func (v *PlanHuman) HelpPrompt() {
    73  	v.view.HelpPrompt("plan")
    74  }
    75  
    76  // The PlanJSON implementation renders streaming JSON logs, suitable for
    77  // integrating with other software.
    78  type PlanJSON struct {
    79  	view *JSONView
    80  }
    81  
    82  var _ Plan = (*PlanJSON)(nil)
    83  
    84  func (v *PlanJSON) Operation() Operation {
    85  	return &OperationJSON{view: v.view}
    86  }
    87  
    88  func (v *PlanJSON) Hooks() []durgaform.Hook {
    89  	return []durgaform.Hook{
    90  		newJSONHook(v.view),
    91  	}
    92  }
    93  
    94  func (v *PlanJSON) Diagnostics(diags tfdiags.Diagnostics) {
    95  	v.view.Diagnostics(diags)
    96  }
    97  
    98  func (v *PlanJSON) HelpPrompt() {
    99  }
   100  
   101  // The plan renderer is used by the Operation view (for plan and apply
   102  // commands) and the Show view (for the show command).
   103  func renderPlan(plan *plans.Plan, schemas *durgaform.Schemas, view *View) {
   104  	haveRefreshChanges := renderChangesDetectedByRefresh(plan, schemas, view)
   105  
   106  	counts := map[plans.Action]int{}
   107  	var rChanges []*plans.ResourceInstanceChangeSrc
   108  	for _, change := range plan.Changes.Resources {
   109  		if change.Action == plans.NoOp && !change.Moved() {
   110  			continue // We don't show anything for no-op changes
   111  		}
   112  		if change.Action == plans.Delete && change.Addr.Resource.Resource.Mode == addrs.DataResourceMode {
   113  			// Avoid rendering data sources on deletion
   114  			continue
   115  		}
   116  
   117  		rChanges = append(rChanges, change)
   118  
   119  		// Don't count move-only changes
   120  		if change.Action != plans.NoOp {
   121  			counts[change.Action]++
   122  		}
   123  	}
   124  	var changedRootModuleOutputs []*plans.OutputChangeSrc
   125  	for _, output := range plan.Changes.Outputs {
   126  		if !output.Addr.Module.IsRoot() {
   127  			continue
   128  		}
   129  		if output.ChangeSrc.Action == plans.NoOp {
   130  			continue
   131  		}
   132  		changedRootModuleOutputs = append(changedRootModuleOutputs, output)
   133  	}
   134  
   135  	if len(rChanges) == 0 && len(changedRootModuleOutputs) == 0 {
   136  		// If we didn't find any changes to report at all then this is a
   137  		// "No changes" plan. How we'll present this depends on whether
   138  		// the plan is "applyable" and, if so, whether it had refresh changes
   139  		// that we already would've presented above.
   140  
   141  		switch plan.UIMode {
   142  		case plans.RefreshOnlyMode:
   143  			if haveRefreshChanges {
   144  				// We already generated a sufficient prompt about what will
   145  				// happen if applying this change above, so we don't need to
   146  				// say anything more.
   147  				return
   148  			}
   149  
   150  			view.streams.Print(
   151  				view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] Your infrastructure still matches the configuration.[reset]\n\n"),
   152  			)
   153  			view.streams.Println(format.WordWrap(
   154  				"Durgaform has checked that the real remote objects still match the result of your most recent changes, and found no differences.",
   155  				view.outputColumns(),
   156  			))
   157  
   158  		case plans.DestroyMode:
   159  			if haveRefreshChanges {
   160  				view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns()))
   161  				view.streams.Println("")
   162  			}
   163  			view.streams.Print(
   164  				view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] No objects need to be destroyed.[reset]\n\n"),
   165  			)
   166  			view.streams.Println(format.WordWrap(
   167  				"Either you have not created any objects yet or the existing objects were already deleted outside of Durgaform.",
   168  				view.outputColumns(),
   169  			))
   170  
   171  		default:
   172  			if haveRefreshChanges {
   173  				view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns()))
   174  				view.streams.Println("")
   175  			}
   176  			view.streams.Print(
   177  				view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] Your infrastructure matches the configuration.[reset]\n\n"),
   178  			)
   179  
   180  			if haveRefreshChanges {
   181  				if plan.CanApply() {
   182  					// In this case, applying this plan will not change any
   183  					// remote objects but _will_ update the state to match what
   184  					// we detected during refresh, so we'll reassure the user
   185  					// about that.
   186  					view.streams.Println(format.WordWrap(
   187  						"Your configuration already matches the changes detected above, so applying this plan will only update the state to include the changes detected above and won't change any real infrastructure.",
   188  						view.outputColumns(),
   189  					))
   190  				} else {
   191  					// In this case we detected changes during refresh but this isn't
   192  					// a planning mode where we consider those to be applyable. The
   193  					// user must re-run in refresh-only mode in order to update the
   194  					// state to match the upstream changes.
   195  					suggestion := "."
   196  					if !view.runningInAutomation {
   197  						// The normal message includes a specific command line to run.
   198  						suggestion = ":\n  durgaform apply -refresh-only"
   199  					}
   200  					view.streams.Println(format.WordWrap(
   201  						"Your configuration already matches the changes detected above. If you'd like to update the Durgaform state to match, create and apply a refresh-only plan"+suggestion,
   202  						view.outputColumns(),
   203  					))
   204  				}
   205  				return
   206  			}
   207  
   208  			// If we get down here then we're just in the simple situation where
   209  			// the plan isn't applyable at all.
   210  			view.streams.Println(format.WordWrap(
   211  				"Durgaform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.",
   212  				view.outputColumns(),
   213  			))
   214  		}
   215  		return
   216  	}
   217  	if haveRefreshChanges {
   218  		view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns()))
   219  		view.streams.Println("")
   220  	}
   221  
   222  	if len(counts) > 0 {
   223  		headerBuf := &bytes.Buffer{}
   224  		fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(format.WordWrap(planHeaderIntro, view.outputColumns())))
   225  		if counts[plans.Create] > 0 {
   226  			fmt.Fprintf(headerBuf, "%s create\n", format.DiffActionSymbol(plans.Create))
   227  		}
   228  		if counts[plans.Update] > 0 {
   229  			fmt.Fprintf(headerBuf, "%s update in-place\n", format.DiffActionSymbol(plans.Update))
   230  		}
   231  		if counts[plans.Delete] > 0 {
   232  			fmt.Fprintf(headerBuf, "%s destroy\n", format.DiffActionSymbol(plans.Delete))
   233  		}
   234  		if counts[plans.DeleteThenCreate] > 0 {
   235  			fmt.Fprintf(headerBuf, "%s destroy and then create replacement\n", format.DiffActionSymbol(plans.DeleteThenCreate))
   236  		}
   237  		if counts[plans.CreateThenDelete] > 0 {
   238  			fmt.Fprintf(headerBuf, "%s create replacement and then destroy\n", format.DiffActionSymbol(plans.CreateThenDelete))
   239  		}
   240  		if counts[plans.Read] > 0 {
   241  			fmt.Fprintf(headerBuf, "%s read (data resources)\n", format.DiffActionSymbol(plans.Read))
   242  		}
   243  
   244  		view.streams.Print(view.colorize.Color(headerBuf.String()))
   245  	}
   246  
   247  	if len(rChanges) > 0 {
   248  		view.streams.Printf("\nDurgaform will perform the following actions:\n\n")
   249  
   250  		// Note: we're modifying the backing slice of this plan object in-place
   251  		// here. The ordering of resource changes in a plan is not significant,
   252  		// but we can only do this safely here because we can assume that nobody
   253  		// is concurrently modifying our changes while we're trying to print it.
   254  		sort.Slice(rChanges, func(i, j int) bool {
   255  			iA := rChanges[i].Addr
   256  			jA := rChanges[j].Addr
   257  			if iA.String() == jA.String() {
   258  				return rChanges[i].DeposedKey < rChanges[j].DeposedKey
   259  			}
   260  			return iA.Less(jA)
   261  		})
   262  
   263  		for _, rcs := range rChanges {
   264  			if rcs.Action == plans.NoOp && !rcs.Moved() {
   265  				continue
   266  			}
   267  
   268  			providerSchema := schemas.ProviderSchema(rcs.ProviderAddr.Provider)
   269  			if providerSchema == nil {
   270  				// Should never happen
   271  				view.streams.Printf("(schema missing for %s)\n\n", rcs.ProviderAddr)
   272  				continue
   273  			}
   274  			rSchema, _ := providerSchema.SchemaForResourceAddr(rcs.Addr.Resource.Resource)
   275  			if rSchema == nil {
   276  				// Should never happen
   277  				view.streams.Printf("(schema missing for %s)\n\n", rcs.Addr)
   278  				continue
   279  			}
   280  
   281  			view.streams.Println(format.ResourceChange(
   282  				decodeChange(rcs, rSchema),
   283  				rSchema,
   284  				view.colorize,
   285  				format.DiffLanguageProposedChange,
   286  			))
   287  		}
   288  
   289  		// stats is similar to counts above, but:
   290  		// - it considers only resource changes
   291  		// - it simplifies "replace" into both a create and a delete
   292  		stats := map[plans.Action]int{}
   293  		for _, change := range rChanges {
   294  			switch change.Action {
   295  			case plans.CreateThenDelete, plans.DeleteThenCreate:
   296  				stats[plans.Create]++
   297  				stats[plans.Delete]++
   298  			default:
   299  				stats[change.Action]++
   300  			}
   301  		}
   302  		view.streams.Printf(
   303  			view.colorize.Color("[reset][bold]Plan:[reset] %d to add, %d to change, %d to destroy.\n"),
   304  			stats[plans.Create], stats[plans.Update], stats[plans.Delete],
   305  		)
   306  	}
   307  
   308  	// If there is at least one planned change to the root module outputs
   309  	// then we'll render a summary of those too.
   310  	if len(changedRootModuleOutputs) > 0 {
   311  		view.streams.Println(
   312  			view.colorize.Color("[reset]\n[bold]Changes to Outputs:[reset]") +
   313  				format.OutputChanges(changedRootModuleOutputs, view.colorize),
   314  		)
   315  
   316  		if len(counts) == 0 {
   317  			// If we have output changes but not resource changes then we
   318  			// won't have output any indication about the changes at all yet,
   319  			// so we need some extra context about what it would mean to
   320  			// apply a change that _only_ includes output changes.
   321  			view.streams.Println(format.WordWrap(
   322  				"\nYou can apply this plan to save these new output values to the Durgaform state, without changing any real infrastructure.",
   323  				view.outputColumns(),
   324  			))
   325  		}
   326  	}
   327  }
   328  
   329  // renderChangesDetectedByRefresh is a part of renderPlan that generates
   330  // the note about changes detected by refresh (sometimes considered as "drift").
   331  //
   332  // It will only generate output if there's at least one difference detected.
   333  // Otherwise, it will produce nothing at all. To help the caller recognize
   334  // those two situations incase subsequent output must accommodate it,
   335  // renderChangesDetectedByRefresh returns true if it produced at least one
   336  // line of output, and guarantees to always produce whole lines terminated
   337  // by newline characters.
   338  func renderChangesDetectedByRefresh(plan *plans.Plan, schemas *durgaform.Schemas, view *View) (rendered bool) {
   339  	// If this is not a refresh-only plan, we will need to filter out any
   340  	// non-relevant changes to reduce plan output.
   341  	relevant := make(map[string]bool)
   342  	for _, r := range plan.RelevantAttributes {
   343  		relevant[r.Resource.String()] = true
   344  	}
   345  
   346  	var changes []*plans.ResourceInstanceChange
   347  	for _, rcs := range plan.DriftedResources {
   348  		providerSchema := schemas.ProviderSchema(rcs.ProviderAddr.Provider)
   349  		if providerSchema == nil {
   350  			// Should never happen
   351  			view.streams.Printf("(schema missing for %s)\n\n", rcs.ProviderAddr)
   352  			continue
   353  		}
   354  		rSchema, _ := providerSchema.SchemaForResourceAddr(rcs.Addr.Resource.Resource)
   355  		if rSchema == nil {
   356  			// Should never happen
   357  			view.streams.Printf("(schema missing for %s)\n\n", rcs.Addr)
   358  			continue
   359  		}
   360  
   361  		changes = append(changes, decodeChange(rcs, rSchema))
   362  	}
   363  
   364  	// In refresh-only mode, we show all resources marked as drifted,
   365  	// including those which have moved without other changes. In other plan
   366  	// modes, move-only changes will be rendered in the planned changes, so
   367  	// we skip them here.
   368  	var drs []*plans.ResourceInstanceChange
   369  	if plan.UIMode == plans.RefreshOnlyMode {
   370  		drs = changes
   371  	} else {
   372  		for _, dr := range changes {
   373  			change := filterRefreshChange(dr, plan.RelevantAttributes)
   374  			if change.Action != plans.NoOp {
   375  				dr.Change = change
   376  				drs = append(drs, dr)
   377  			}
   378  		}
   379  	}
   380  
   381  	if len(drs) == 0 {
   382  		return false
   383  	}
   384  
   385  	// In an empty plan, we don't show any outside changes, because nothing in
   386  	// the plan could have been affected by those changes. If a user wants to
   387  	// see all external changes, then a refresh-only plan should be executed
   388  	// instead.
   389  	if plan.Changes.Empty() && plan.UIMode != plans.RefreshOnlyMode {
   390  		return false
   391  	}
   392  
   393  	view.streams.Print(
   394  		view.colorize.Color("[reset]\n[bold][cyan]Note:[reset][bold] Objects have changed outside of Durgaform[reset]\n\n"),
   395  	)
   396  	view.streams.Print(format.WordWrap(
   397  		"Durgaform detected the following changes made outside of Terraform since the last \"durgaform apply\" which may have affected this plan:\n\n",
   398  		view.outputColumns(),
   399  	))
   400  
   401  	// Note: we're modifying the backing slice of this plan object in-place
   402  	// here. The ordering of resource changes in a plan is not significant,
   403  	// but we can only do this safely here because we can assume that nobody
   404  	// is concurrently modifying our changes while we're trying to print it.
   405  	sort.Slice(drs, func(i, j int) bool {
   406  		iA := drs[i].Addr
   407  		jA := drs[j].Addr
   408  		if iA.String() == jA.String() {
   409  			return drs[i].DeposedKey < drs[j].DeposedKey
   410  		}
   411  		return iA.Less(jA)
   412  	})
   413  
   414  	for _, rcs := range drs {
   415  		providerSchema := schemas.ProviderSchema(rcs.ProviderAddr.Provider)
   416  		if providerSchema == nil {
   417  			// Should never happen
   418  			view.streams.Printf("(schema missing for %s)\n\n", rcs.ProviderAddr)
   419  			continue
   420  		}
   421  		rSchema, _ := providerSchema.SchemaForResourceAddr(rcs.Addr.Resource.Resource)
   422  		if rSchema == nil {
   423  			// Should never happen
   424  			view.streams.Printf("(schema missing for %s)\n\n", rcs.Addr)
   425  			continue
   426  		}
   427  
   428  		view.streams.Println(format.ResourceChange(
   429  			rcs,
   430  			rSchema,
   431  			view.colorize,
   432  			format.DiffLanguageDetectedDrift,
   433  		))
   434  	}
   435  
   436  	switch plan.UIMode {
   437  	case plans.RefreshOnlyMode:
   438  		view.streams.Println(format.WordWrap(
   439  			"\nThis is a refresh-only plan, so Durgaform will not take any actions to undo these. If you were expecting these changes then you can apply this plan to record the updated values in the Terraform state without changing any remote objects.",
   440  			view.outputColumns(),
   441  		))
   442  	default:
   443  		view.streams.Println(format.WordWrap(
   444  			"\nUnless you have made equivalent changes to your configuration, or ignored the relevant attributes using ignore_changes, the following plan may include actions to undo or respond to these changes.",
   445  			view.outputColumns(),
   446  		))
   447  	}
   448  
   449  	return true
   450  }
   451  
   452  // Filter individual resource changes for display based on the attributes which
   453  // may have contributed to the plan as a whole. In order to continue to use the
   454  // existing diff renderer, we are going to create a fake change for display,
   455  // only showing the attributes we're interested in.
   456  // The resulting change will be a NoOp if it has nothing relevant to the plan.
   457  func filterRefreshChange(change *plans.ResourceInstanceChange, contributing []globalref.ResourceAttr) plans.Change {
   458  
   459  	if change.Action == plans.NoOp {
   460  		return change.Change
   461  	}
   462  
   463  	var relevantAttrs []cty.Path
   464  	resAddr := change.Addr
   465  
   466  	for _, attr := range contributing {
   467  		if !resAddr.ContainingResource().Equal(attr.Resource.ContainingResource()) {
   468  			continue
   469  		}
   470  
   471  		// If the contributing address has no instance key, then the
   472  		// contributing reference applies to all instances.
   473  		if attr.Resource.Resource.Key == addrs.NoKey || resAddr.Equal(attr.Resource) {
   474  			relevantAttrs = append(relevantAttrs, attr.Attr)
   475  		}
   476  	}
   477  
   478  	// If no attributes are relevant in this resource, then we can turn this
   479  	// onto a NoOp change for display.
   480  	if len(relevantAttrs) == 0 {
   481  		return plans.Change{
   482  			Action: plans.NoOp,
   483  			Before: change.Before,
   484  			After:  change.Before,
   485  		}
   486  	}
   487  
   488  	// We have some attributes in this change which were marked as relevant, so
   489  	// we are going to take the Before value and add in only those attributes
   490  	// from the After value which may have contributed to the plan.
   491  
   492  	// If the types don't match because the schema is dynamic, we may not be
   493  	// able to apply the paths to the new values.
   494  	// if we encounter a path that does not apply correctly and the types do
   495  	// not match overall, just assume we need the entire value.
   496  	isDynamic := !change.Before.Type().Equals(change.After.Type())
   497  	failedApply := false
   498  
   499  	before := change.Before
   500  	after, _ := cty.Transform(before, func(path cty.Path, v cty.Value) (cty.Value, error) {
   501  		for i, attrPath := range relevantAttrs {
   502  			// We match prefix in case we are traversing any null or dynamic
   503  			// values and enter in via a shorter path. The traversal is
   504  			// depth-first, so we will always hit the longest match first.
   505  			if attrPath.HasPrefix(path) {
   506  				// remove the path from further consideration
   507  				relevantAttrs = append(relevantAttrs[:i], relevantAttrs[i+1:]...)
   508  
   509  				applied, err := path.Apply(change.After)
   510  				if err != nil {
   511  					failedApply = true
   512  					// Assume the types match for now, and failure to apply is
   513  					// because a parent value is null. If there were dynamic
   514  					// types we'll just restore the entire value.
   515  					return cty.NullVal(v.Type()), nil
   516  				}
   517  
   518  				return applied, err
   519  			}
   520  		}
   521  		return v, nil
   522  	})
   523  
   524  	// A contributing attribute path did not match the after value type in some
   525  	// way, so restore the entire change.
   526  	if isDynamic && failedApply {
   527  		after = change.After
   528  	}
   529  
   530  	action := change.Action
   531  	if before.RawEquals(after) {
   532  		action = plans.NoOp
   533  	}
   534  
   535  	return plans.Change{
   536  		Action: action,
   537  		Before: before,
   538  		After:  after,
   539  	}
   540  }
   541  
   542  func decodeChange(change *plans.ResourceInstanceChangeSrc, schema *configschema.Block) *plans.ResourceInstanceChange {
   543  	changeV, err := change.Decode(schema.ImpliedType())
   544  	if err != nil {
   545  		// Should never happen in here, since we've already been through
   546  		// loads of layers of encode/decode of the planned changes before now.
   547  		panic(fmt.Sprintf("failed to decode plan for %s while rendering diff: %s", change.Addr, err))
   548  	}
   549  
   550  	// We currently have an opt-out that permits the legacy SDK to return values
   551  	// that defy our usual conventions around handling of nesting blocks. To
   552  	// avoid the rendering code from needing to handle all of these, we'll
   553  	// normalize first.
   554  	// (Ideally we'd do this as part of the SDK opt-out implementation in core,
   555  	// but we've added it here for now to reduce risk of unexpected impacts
   556  	// on other code in core.)
   557  	changeV.Change.Before = objchange.NormalizeObjectFromLegacySDK(changeV.Change.Before, schema)
   558  	changeV.Change.After = objchange.NormalizeObjectFromLegacySDK(changeV.Change.After, schema)
   559  	return changeV
   560  }
   561  
   562  const planHeaderIntro = `
   563  Durgaform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
   564  `