github.com/jaredpalmer/terraform@v1.1.0-alpha20210908.0.20210911170307-88705c943a03/internal/plans/planfile/tfplan.go (about) 1 package planfile 2 3 import ( 4 "fmt" 5 "io" 6 "io/ioutil" 7 8 "google.golang.org/protobuf/proto" 9 10 "github.com/hashicorp/terraform/internal/addrs" 11 "github.com/hashicorp/terraform/internal/lang/marks" 12 "github.com/hashicorp/terraform/internal/plans" 13 "github.com/hashicorp/terraform/internal/plans/internal/planproto" 14 "github.com/hashicorp/terraform/internal/states" 15 "github.com/hashicorp/terraform/version" 16 "github.com/zclconf/go-cty/cty" 17 ) 18 19 const tfplanFormatVersion = 3 20 const tfplanFilename = "tfplan" 21 22 // --------------------------------------------------------------------------- 23 // This file deals with the internal structure of the "tfplan" sub-file within 24 // the plan file format. It's all private API, wrapped by methods defined 25 // elsewhere. This is the only file that should import the 26 // ../internal/planproto package, which contains the ugly stubs generated 27 // by the protobuf compiler. 28 // --------------------------------------------------------------------------- 29 30 // readTfplan reads a protobuf-encoded description from the plan portion of 31 // a plan file, which is stored in a special file in the archive called 32 // "tfplan". 33 func readTfplan(r io.Reader) (*plans.Plan, error) { 34 src, err := ioutil.ReadAll(r) 35 if err != nil { 36 return nil, err 37 } 38 39 var rawPlan planproto.Plan 40 err = proto.Unmarshal(src, &rawPlan) 41 if err != nil { 42 return nil, fmt.Errorf("parse error: %s", err) 43 } 44 45 if rawPlan.Version != tfplanFormatVersion { 46 return nil, fmt.Errorf("unsupported plan file format version %d; only version %d is supported", rawPlan.Version, tfplanFormatVersion) 47 } 48 49 if rawPlan.TerraformVersion != version.String() { 50 return nil, fmt.Errorf("plan file was created by Terraform %s, but this is %s; plan files cannot be transferred between different Terraform versions", rawPlan.TerraformVersion, version.String()) 51 } 52 53 plan := &plans.Plan{ 54 VariableValues: map[string]plans.DynamicValue{}, 55 Changes: &plans.Changes{ 56 Outputs: []*plans.OutputChangeSrc{}, 57 Resources: []*plans.ResourceInstanceChangeSrc{}, 58 }, 59 60 ProviderSHA256s: map[string][]byte{}, 61 } 62 63 switch rawPlan.UiMode { 64 case planproto.Mode_NORMAL: 65 plan.UIMode = plans.NormalMode 66 case planproto.Mode_DESTROY: 67 plan.UIMode = plans.DestroyMode 68 case planproto.Mode_REFRESH_ONLY: 69 plan.UIMode = plans.RefreshOnlyMode 70 default: 71 return nil, fmt.Errorf("plan has invalid mode %s", rawPlan.UiMode) 72 } 73 74 for _, rawOC := range rawPlan.OutputChanges { 75 name := rawOC.Name 76 change, err := changeFromTfplan(rawOC.Change) 77 if err != nil { 78 return nil, fmt.Errorf("invalid plan for output %q: %s", name, err) 79 } 80 81 plan.Changes.Outputs = append(plan.Changes.Outputs, &plans.OutputChangeSrc{ 82 // All output values saved in the plan file are root module outputs, 83 // since we don't retain others. (They can be easily recomputed 84 // during apply). 85 Addr: addrs.OutputValue{Name: name}.Absolute(addrs.RootModuleInstance), 86 ChangeSrc: *change, 87 Sensitive: rawOC.Sensitive, 88 }) 89 } 90 91 for _, rawRC := range rawPlan.ResourceChanges { 92 change, err := resourceChangeFromTfplan(rawRC) 93 if err != nil { 94 // errors from resourceChangeFromTfplan already include context 95 return nil, err 96 } 97 98 plan.Changes.Resources = append(plan.Changes.Resources, change) 99 } 100 101 for _, rawTargetAddr := range rawPlan.TargetAddrs { 102 target, diags := addrs.ParseTargetStr(rawTargetAddr) 103 if diags.HasErrors() { 104 return nil, fmt.Errorf("plan contains invalid target address %q: %s", target, diags.Err()) 105 } 106 plan.TargetAddrs = append(plan.TargetAddrs, target.Subject) 107 } 108 109 for _, rawReplaceAddr := range rawPlan.ForceReplaceAddrs { 110 addr, diags := addrs.ParseAbsResourceInstanceStr(rawReplaceAddr) 111 if diags.HasErrors() { 112 return nil, fmt.Errorf("plan contains invalid force-replace address %q: %s", addr, diags.Err()) 113 } 114 plan.ForceReplaceAddrs = append(plan.ForceReplaceAddrs, addr) 115 } 116 117 for name, rawHashObj := range rawPlan.ProviderHashes { 118 if len(rawHashObj.Sha256) == 0 { 119 return nil, fmt.Errorf("no SHA256 hash for provider %q plugin", name) 120 } 121 122 plan.ProviderSHA256s[name] = rawHashObj.Sha256 123 } 124 125 for name, rawVal := range rawPlan.Variables { 126 val, err := valueFromTfplan(rawVal) 127 if err != nil { 128 return nil, fmt.Errorf("invalid value for input variable %q: %s", name, err) 129 } 130 plan.VariableValues[name] = val 131 } 132 133 if rawBackend := rawPlan.Backend; rawBackend == nil { 134 return nil, fmt.Errorf("plan file has no backend settings; backend settings are required") 135 } else { 136 config, err := valueFromTfplan(rawBackend.Config) 137 if err != nil { 138 return nil, fmt.Errorf("plan file has invalid backend configuration: %s", err) 139 } 140 plan.Backend = plans.Backend{ 141 Type: rawBackend.Type, 142 Config: config, 143 Workspace: rawBackend.Workspace, 144 } 145 } 146 147 return plan, nil 148 } 149 150 func resourceChangeFromTfplan(rawChange *planproto.ResourceInstanceChange) (*plans.ResourceInstanceChangeSrc, error) { 151 if rawChange == nil { 152 // Should never happen in practice, since protobuf can't represent 153 // a nil value in a list. 154 return nil, fmt.Errorf("resource change object is absent") 155 } 156 157 ret := &plans.ResourceInstanceChangeSrc{} 158 159 if rawChange.Addr == "" { 160 // If "Addr" isn't populated then seems likely that this is a plan 161 // file created by an earlier version of Terraform, which had the 162 // same information spread over various other fields: 163 // ModulePath, Mode, Name, Type, and InstanceKey. 164 return nil, fmt.Errorf("no instance address for resource instance change; perhaps this plan was created by a different version of Terraform?") 165 } 166 167 instAddr, diags := addrs.ParseAbsResourceInstanceStr(rawChange.Addr) 168 if diags.HasErrors() { 169 return nil, fmt.Errorf("invalid resource instance address %q: %w", rawChange.Addr, diags.Err()) 170 } 171 prevRunAddr := instAddr 172 if rawChange.PrevRunAddr != "" { 173 prevRunAddr, diags = addrs.ParseAbsResourceInstanceStr(rawChange.PrevRunAddr) 174 if diags.HasErrors() { 175 return nil, fmt.Errorf("invalid resource instance previous run address %q: %w", rawChange.PrevRunAddr, diags.Err()) 176 } 177 } 178 179 providerAddr, diags := addrs.ParseAbsProviderConfigStr(rawChange.Provider) 180 if diags.HasErrors() { 181 return nil, diags.Err() 182 } 183 ret.ProviderAddr = providerAddr 184 185 ret.Addr = instAddr 186 ret.PrevRunAddr = prevRunAddr 187 188 if rawChange.DeposedKey != "" { 189 if len(rawChange.DeposedKey) != 8 { 190 return nil, fmt.Errorf("deposed object for %s has invalid deposed key %q", ret.Addr, rawChange.DeposedKey) 191 } 192 ret.DeposedKey = states.DeposedKey(rawChange.DeposedKey) 193 } 194 195 ret.RequiredReplace = cty.NewPathSet() 196 for _, p := range rawChange.RequiredReplace { 197 path, err := pathFromTfplan(p) 198 if err != nil { 199 return nil, fmt.Errorf("invalid path in required replace: %s", err) 200 } 201 ret.RequiredReplace.Add(path) 202 } 203 204 change, err := changeFromTfplan(rawChange.Change) 205 if err != nil { 206 return nil, fmt.Errorf("invalid plan for resource %s: %s", ret.Addr, err) 207 } 208 209 ret.ChangeSrc = *change 210 211 switch rawChange.ActionReason { 212 case planproto.ResourceInstanceActionReason_NONE: 213 ret.ActionReason = plans.ResourceInstanceChangeNoReason 214 case planproto.ResourceInstanceActionReason_REPLACE_BECAUSE_CANNOT_UPDATE: 215 ret.ActionReason = plans.ResourceInstanceReplaceBecauseCannotUpdate 216 case planproto.ResourceInstanceActionReason_REPLACE_BECAUSE_TAINTED: 217 ret.ActionReason = plans.ResourceInstanceReplaceBecauseTainted 218 case planproto.ResourceInstanceActionReason_REPLACE_BY_REQUEST: 219 ret.ActionReason = plans.ResourceInstanceReplaceByRequest 220 default: 221 return nil, fmt.Errorf("resource has invalid action reason %s", rawChange.ActionReason) 222 } 223 224 if len(rawChange.Private) != 0 { 225 ret.Private = rawChange.Private 226 } 227 228 return ret, nil 229 } 230 231 func changeFromTfplan(rawChange *planproto.Change) (*plans.ChangeSrc, error) { 232 if rawChange == nil { 233 return nil, fmt.Errorf("change object is absent") 234 } 235 236 ret := &plans.ChangeSrc{} 237 238 // -1 indicates that there is no index. We'll customize these below 239 // depending on the change action, and then decode. 240 beforeIdx, afterIdx := -1, -1 241 242 switch rawChange.Action { 243 case planproto.Action_NOOP: 244 ret.Action = plans.NoOp 245 beforeIdx = 0 246 afterIdx = 0 247 case planproto.Action_CREATE: 248 ret.Action = plans.Create 249 afterIdx = 0 250 case planproto.Action_READ: 251 ret.Action = plans.Read 252 beforeIdx = 0 253 afterIdx = 1 254 case planproto.Action_UPDATE: 255 ret.Action = plans.Update 256 beforeIdx = 0 257 afterIdx = 1 258 case planproto.Action_DELETE: 259 ret.Action = plans.Delete 260 beforeIdx = 0 261 case planproto.Action_CREATE_THEN_DELETE: 262 ret.Action = plans.CreateThenDelete 263 beforeIdx = 0 264 afterIdx = 1 265 case planproto.Action_DELETE_THEN_CREATE: 266 ret.Action = plans.DeleteThenCreate 267 beforeIdx = 0 268 afterIdx = 1 269 default: 270 return nil, fmt.Errorf("invalid change action %s", rawChange.Action) 271 } 272 273 if beforeIdx != -1 { 274 if l := len(rawChange.Values); l <= beforeIdx { 275 return nil, fmt.Errorf("incorrect number of values (%d) for %s change", l, rawChange.Action) 276 } 277 var err error 278 ret.Before, err = valueFromTfplan(rawChange.Values[beforeIdx]) 279 if err != nil { 280 return nil, fmt.Errorf("invalid \"before\" value: %s", err) 281 } 282 if ret.Before == nil { 283 return nil, fmt.Errorf("missing \"before\" value: %s", err) 284 } 285 } 286 if afterIdx != -1 { 287 if l := len(rawChange.Values); l <= afterIdx { 288 return nil, fmt.Errorf("incorrect number of values (%d) for %s change", l, rawChange.Action) 289 } 290 var err error 291 ret.After, err = valueFromTfplan(rawChange.Values[afterIdx]) 292 if err != nil { 293 return nil, fmt.Errorf("invalid \"after\" value: %s", err) 294 } 295 if ret.After == nil { 296 return nil, fmt.Errorf("missing \"after\" value: %s", err) 297 } 298 } 299 300 sensitive := cty.NewValueMarks(marks.Sensitive) 301 beforeValMarks, err := pathValueMarksFromTfplan(rawChange.BeforeSensitivePaths, sensitive) 302 if err != nil { 303 return nil, fmt.Errorf("failed to decode before sensitive paths: %s", err) 304 } 305 afterValMarks, err := pathValueMarksFromTfplan(rawChange.AfterSensitivePaths, sensitive) 306 if err != nil { 307 return nil, fmt.Errorf("failed to decode after sensitive paths: %s", err) 308 } 309 if len(beforeValMarks) > 0 { 310 ret.BeforeValMarks = beforeValMarks 311 } 312 if len(afterValMarks) > 0 { 313 ret.AfterValMarks = afterValMarks 314 } 315 316 return ret, nil 317 } 318 319 func valueFromTfplan(rawV *planproto.DynamicValue) (plans.DynamicValue, error) { 320 if len(rawV.Msgpack) == 0 { // len(0) because that's the default value for a "bytes" in protobuf 321 return nil, fmt.Errorf("dynamic value does not have msgpack serialization") 322 } 323 324 return plans.DynamicValue(rawV.Msgpack), nil 325 } 326 327 // writeTfplan serializes the given plan into the protobuf-based format used 328 // for the "tfplan" portion of a plan file. 329 func writeTfplan(plan *plans.Plan, w io.Writer) error { 330 if plan == nil { 331 return fmt.Errorf("cannot write plan file for nil plan") 332 } 333 if plan.Changes == nil { 334 return fmt.Errorf("cannot write plan file with nil changeset") 335 } 336 337 rawPlan := &planproto.Plan{ 338 Version: tfplanFormatVersion, 339 TerraformVersion: version.String(), 340 ProviderHashes: map[string]*planproto.Hash{}, 341 342 Variables: map[string]*planproto.DynamicValue{}, 343 OutputChanges: []*planproto.OutputChange{}, 344 ResourceChanges: []*planproto.ResourceInstanceChange{}, 345 } 346 347 switch plan.UIMode { 348 case plans.NormalMode: 349 rawPlan.UiMode = planproto.Mode_NORMAL 350 case plans.DestroyMode: 351 rawPlan.UiMode = planproto.Mode_DESTROY 352 case plans.RefreshOnlyMode: 353 rawPlan.UiMode = planproto.Mode_REFRESH_ONLY 354 default: 355 return fmt.Errorf("plan has unsupported mode %s", plan.UIMode) 356 } 357 358 for _, oc := range plan.Changes.Outputs { 359 // When serializing a plan we only retain the root outputs, since 360 // changes to these are externally-visible side effects (e.g. via 361 // terraform_remote_state). 362 if !oc.Addr.Module.IsRoot() { 363 continue 364 } 365 366 name := oc.Addr.OutputValue.Name 367 368 // Writing outputs as cty.DynamicPseudoType forces the stored values 369 // to also contain dynamic type information, so we can recover the 370 // original type when we read the values back in readTFPlan. 371 protoChange, err := changeToTfplan(&oc.ChangeSrc) 372 if err != nil { 373 return fmt.Errorf("cannot write output value %q: %s", name, err) 374 } 375 376 rawPlan.OutputChanges = append(rawPlan.OutputChanges, &planproto.OutputChange{ 377 Name: name, 378 Change: protoChange, 379 Sensitive: oc.Sensitive, 380 }) 381 } 382 383 for _, rc := range plan.Changes.Resources { 384 rawRC, err := resourceChangeToTfplan(rc) 385 if err != nil { 386 return err 387 } 388 rawPlan.ResourceChanges = append(rawPlan.ResourceChanges, rawRC) 389 } 390 391 for _, targetAddr := range plan.TargetAddrs { 392 rawPlan.TargetAddrs = append(rawPlan.TargetAddrs, targetAddr.String()) 393 } 394 395 for _, replaceAddr := range plan.ForceReplaceAddrs { 396 rawPlan.ForceReplaceAddrs = append(rawPlan.ForceReplaceAddrs, replaceAddr.String()) 397 } 398 399 for name, hash := range plan.ProviderSHA256s { 400 rawPlan.ProviderHashes[name] = &planproto.Hash{ 401 Sha256: hash, 402 } 403 } 404 405 for name, val := range plan.VariableValues { 406 rawPlan.Variables[name] = valueToTfplan(val) 407 } 408 409 if plan.Backend.Type == "" || plan.Backend.Config == nil { 410 // This suggests a bug in the code that created the plan, since it 411 // ought to always have a backend populated, even if it's the default 412 // "local" backend with a local state file. 413 return fmt.Errorf("plan does not have a backend configuration") 414 } 415 416 rawPlan.Backend = &planproto.Backend{ 417 Type: plan.Backend.Type, 418 Config: valueToTfplan(plan.Backend.Config), 419 Workspace: plan.Backend.Workspace, 420 } 421 422 src, err := proto.Marshal(rawPlan) 423 if err != nil { 424 return fmt.Errorf("serialization error: %s", err) 425 } 426 427 _, err = w.Write(src) 428 if err != nil { 429 return fmt.Errorf("failed to write plan to plan file: %s", err) 430 } 431 432 return nil 433 } 434 435 func resourceChangeToTfplan(change *plans.ResourceInstanceChangeSrc) (*planproto.ResourceInstanceChange, error) { 436 ret := &planproto.ResourceInstanceChange{} 437 438 if change.PrevRunAddr.Resource.Resource.Type == "" { 439 // Suggests that an old caller wasn't yet updated to populate this 440 // properly. All code that generates plans should populate this field, 441 // even if it's just to write in the same value as in change.Addr. 442 change.PrevRunAddr = change.Addr 443 } 444 445 ret.Addr = change.Addr.String() 446 ret.PrevRunAddr = change.PrevRunAddr.String() 447 if ret.PrevRunAddr == ret.Addr { 448 // In the on-disk format we leave PrevRunAddr unpopulated in the common 449 // case where it's the same as Addr, and then fill it back in again on 450 // read. 451 ret.PrevRunAddr = "" 452 } 453 454 ret.DeposedKey = string(change.DeposedKey) 455 ret.Provider = change.ProviderAddr.String() 456 457 requiredReplace := change.RequiredReplace.List() 458 ret.RequiredReplace = make([]*planproto.Path, 0, len(requiredReplace)) 459 for _, p := range requiredReplace { 460 path, err := pathToTfplan(p) 461 if err != nil { 462 return nil, fmt.Errorf("invalid path in required replace: %s", err) 463 } 464 ret.RequiredReplace = append(ret.RequiredReplace, path) 465 } 466 467 valChange, err := changeToTfplan(&change.ChangeSrc) 468 if err != nil { 469 return nil, fmt.Errorf("failed to serialize resource %s change: %s", change.Addr, err) 470 } 471 ret.Change = valChange 472 473 switch change.ActionReason { 474 case plans.ResourceInstanceChangeNoReason: 475 ret.ActionReason = planproto.ResourceInstanceActionReason_NONE 476 case plans.ResourceInstanceReplaceBecauseCannotUpdate: 477 ret.ActionReason = planproto.ResourceInstanceActionReason_REPLACE_BECAUSE_CANNOT_UPDATE 478 case plans.ResourceInstanceReplaceBecauseTainted: 479 ret.ActionReason = planproto.ResourceInstanceActionReason_REPLACE_BECAUSE_TAINTED 480 case plans.ResourceInstanceReplaceByRequest: 481 ret.ActionReason = planproto.ResourceInstanceActionReason_REPLACE_BY_REQUEST 482 default: 483 return nil, fmt.Errorf("resource %s has unsupported action reason %s", change.Addr, change.ActionReason) 484 } 485 486 if len(change.Private) > 0 { 487 ret.Private = change.Private 488 } 489 490 return ret, nil 491 } 492 493 func changeToTfplan(change *plans.ChangeSrc) (*planproto.Change, error) { 494 ret := &planproto.Change{} 495 496 before := valueToTfplan(change.Before) 497 after := valueToTfplan(change.After) 498 499 beforeSensitivePaths, err := pathValueMarksToTfplan(change.BeforeValMarks) 500 if err != nil { 501 return nil, err 502 } 503 afterSensitivePaths, err := pathValueMarksToTfplan(change.AfterValMarks) 504 if err != nil { 505 return nil, err 506 } 507 ret.BeforeSensitivePaths = beforeSensitivePaths 508 ret.AfterSensitivePaths = afterSensitivePaths 509 510 switch change.Action { 511 case plans.NoOp: 512 ret.Action = planproto.Action_NOOP 513 ret.Values = []*planproto.DynamicValue{before} // before and after should be identical 514 case plans.Create: 515 ret.Action = planproto.Action_CREATE 516 ret.Values = []*planproto.DynamicValue{after} 517 case plans.Read: 518 ret.Action = planproto.Action_READ 519 ret.Values = []*planproto.DynamicValue{before, after} 520 case plans.Update: 521 ret.Action = planproto.Action_UPDATE 522 ret.Values = []*planproto.DynamicValue{before, after} 523 case plans.Delete: 524 ret.Action = planproto.Action_DELETE 525 ret.Values = []*planproto.DynamicValue{before} 526 case plans.DeleteThenCreate: 527 ret.Action = planproto.Action_DELETE_THEN_CREATE 528 ret.Values = []*planproto.DynamicValue{before, after} 529 case plans.CreateThenDelete: 530 ret.Action = planproto.Action_CREATE_THEN_DELETE 531 ret.Values = []*planproto.DynamicValue{before, after} 532 default: 533 return nil, fmt.Errorf("invalid change action %s", change.Action) 534 } 535 536 return ret, nil 537 } 538 539 func valueToTfplan(val plans.DynamicValue) *planproto.DynamicValue { 540 if val == nil { 541 // protobuf can't represent nil, so we'll represent it as a 542 // DynamicValue that has no serializations at all. 543 return &planproto.DynamicValue{} 544 } 545 return &planproto.DynamicValue{ 546 Msgpack: []byte(val), 547 } 548 } 549 550 func pathValueMarksFromTfplan(paths []*planproto.Path, marks cty.ValueMarks) ([]cty.PathValueMarks, error) { 551 ret := make([]cty.PathValueMarks, 0, len(paths)) 552 for _, p := range paths { 553 path, err := pathFromTfplan(p) 554 if err != nil { 555 return nil, err 556 } 557 ret = append(ret, cty.PathValueMarks{ 558 Path: path, 559 Marks: marks, 560 }) 561 } 562 return ret, nil 563 } 564 565 func pathValueMarksToTfplan(pvm []cty.PathValueMarks) ([]*planproto.Path, error) { 566 ret := make([]*planproto.Path, 0, len(pvm)) 567 for _, p := range pvm { 568 path, err := pathToTfplan(p.Path) 569 if err != nil { 570 return nil, err 571 } 572 ret = append(ret, path) 573 } 574 return ret, nil 575 } 576 577 func pathFromTfplan(path *planproto.Path) (cty.Path, error) { 578 ret := make([]cty.PathStep, 0, len(path.Steps)) 579 for _, step := range path.Steps { 580 switch s := step.Selector.(type) { 581 case *planproto.Path_Step_ElementKey: 582 dynamicVal, err := valueFromTfplan(s.ElementKey) 583 if err != nil { 584 return nil, fmt.Errorf("error decoding path index step: %s", err) 585 } 586 ty, err := dynamicVal.ImpliedType() 587 if err != nil { 588 return nil, fmt.Errorf("error determining path index type: %s", err) 589 } 590 val, err := dynamicVal.Decode(ty) 591 if err != nil { 592 return nil, fmt.Errorf("error decoding path index value: %s", err) 593 } 594 ret = append(ret, cty.IndexStep{Key: val}) 595 case *planproto.Path_Step_AttributeName: 596 ret = append(ret, cty.GetAttrStep{Name: s.AttributeName}) 597 default: 598 return nil, fmt.Errorf("Unsupported path step %t", step.Selector) 599 } 600 } 601 return ret, nil 602 } 603 604 func pathToTfplan(path cty.Path) (*planproto.Path, error) { 605 steps := make([]*planproto.Path_Step, 0, len(path)) 606 for _, step := range path { 607 switch s := step.(type) { 608 case cty.IndexStep: 609 value, err := plans.NewDynamicValue(s.Key, s.Key.Type()) 610 if err != nil { 611 return nil, fmt.Errorf("Error encoding path step: %s", err) 612 } 613 steps = append(steps, &planproto.Path_Step{ 614 Selector: &planproto.Path_Step_ElementKey{ 615 ElementKey: valueToTfplan(value), 616 }, 617 }) 618 case cty.GetAttrStep: 619 steps = append(steps, &planproto.Path_Step{ 620 Selector: &planproto.Path_Step_AttributeName{ 621 AttributeName: s.Name, 622 }, 623 }) 624 default: 625 return nil, fmt.Errorf("Unsupported path step %#v (%t)", step, step) 626 } 627 } 628 return &planproto.Path{ 629 Steps: steps, 630 }, nil 631 }