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 `