github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/command/job_plan.go (about) 1 package command 2 3 import ( 4 "fmt" 5 "os" 6 "sort" 7 "strings" 8 "time" 9 10 "github.com/hashicorp/nomad/api" 11 "github.com/hashicorp/nomad/helper/pointer" 12 "github.com/hashicorp/nomad/scheduler" 13 "github.com/posener/complete" 14 ) 15 16 const ( 17 jobModifyIndexHelp = `To submit the job with version verification run: 18 19 nomad job run -check-index %d %s%s 20 21 When running the job with the check-index flag, the job will only be run if the 22 job modify index given matches the server-side version. If the index has 23 changed, another user has modified the job and the plan's results are 24 potentially invalid.` 25 26 // preemptionDisplayThreshold is an upper bound used to limit and summarize 27 // the details of preempted jobs in the output 28 preemptionDisplayThreshold = 10 29 ) 30 31 type JobPlanCommand struct { 32 Meta 33 JobGetter 34 } 35 36 func (c *JobPlanCommand) Help() string { 37 helpText := ` 38 Usage: nomad job plan [options] <path> 39 Alias: nomad plan 40 41 Plan invokes a dry-run of the scheduler to determine the effects of submitting 42 either a new or updated version of a job. The plan will not result in any 43 changes to the cluster but gives insight into whether the job could be run 44 successfully and how it would affect existing allocations. 45 46 If the supplied path is "-", the jobfile is read from stdin. Otherwise 47 it is read from the file at the supplied path or downloaded and 48 read from URL specified. 49 50 A job modify index is returned with the plan. This value can be used when 51 submitting the job using "nomad run -check-index", which will check that the job 52 was not modified between the plan and run command before invoking the 53 scheduler. This ensures the job has not been modified since the plan. 54 Multiregion jobs do not return a job modify index. 55 56 A structured diff between the local and remote job is displayed to 57 give insight into what the scheduler will attempt to do and why. 58 59 If the job has specified the region, the -region flag and NOMAD_REGION 60 environment variable are overridden and the job's region is used. 61 62 Plan will return one of the following exit codes: 63 * 0: No allocations created or destroyed. 64 * 1: Allocations created or destroyed. 65 * 255: Error determining plan results. 66 67 The plan command will set the vault_token of the job based on the following 68 precedence, going from highest to lowest: the -vault-token flag, the 69 $VAULT_TOKEN environment variable and finally the value in the job file. 70 71 When ACLs are enabled, this command requires a token with the 'submit-job' 72 capability for the job's namespace. 73 74 General Options: 75 76 ` + generalOptionsUsage(usageOptsDefault) + ` 77 78 Plan Options: 79 80 -diff 81 Determines whether the diff between the remote job and planned job is shown. 82 Defaults to true. 83 84 -json 85 Parses the job file as JSON. If the outer object has a Job field, such as 86 from "nomad job inspect" or "nomad run -output", the value of the field is 87 used as the job. 88 89 -hcl1 90 Parses the job file as HCLv1. Takes precedence over "-hcl2-strict". 91 92 -hcl2-strict 93 Whether an error should be produced from the HCL2 parser where a variable 94 has been supplied which is not defined within the root variables. Defaults 95 to true, but ignored if "-hcl1" is also defined. 96 97 -policy-override 98 Sets the flag to force override any soft mandatory Sentinel policies. 99 100 -vault-token 101 Used to validate if the user submitting the job has permission to run the job 102 according to its Vault policies. A Vault token must be supplied if the vault 103 stanza allow_unauthenticated is disabled in the Nomad server configuration. 104 If the -vault-token flag is set, the passed Vault token is added to the jobspec 105 before sending to the Nomad servers. This allows passing the Vault token 106 without storing it in the job file. This overrides the token found in the 107 $VAULT_TOKEN environment variable and the vault_token field in the job file. 108 This token is cleared from the job after validating and cannot be used within 109 the job executing environment. Use the vault stanza when templating in a job 110 with a Vault token. 111 112 -vault-namespace 113 If set, the passed Vault namespace is stored in the job before sending to the 114 Nomad servers. 115 116 -var 'key=value' 117 Variable for template, can be used multiple times. 118 119 -var-file=path 120 Path to HCL2 file containing user variables. 121 122 -verbose 123 Increase diff verbosity. 124 ` 125 return strings.TrimSpace(helpText) 126 } 127 128 func (c *JobPlanCommand) Synopsis() string { 129 return "Dry-run a job update to determine its effects" 130 } 131 132 func (c *JobPlanCommand) AutocompleteFlags() complete.Flags { 133 return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), 134 complete.Flags{ 135 "-diff": complete.PredictNothing, 136 "-policy-override": complete.PredictNothing, 137 "-verbose": complete.PredictNothing, 138 "-json": complete.PredictNothing, 139 "-hcl1": complete.PredictNothing, 140 "-hcl2-strict": complete.PredictNothing, 141 "-vault-token": complete.PredictAnything, 142 "-vault-namespace": complete.PredictAnything, 143 "-var": complete.PredictAnything, 144 "-var-file": complete.PredictFiles("*.var"), 145 }) 146 } 147 148 func (c *JobPlanCommand) AutocompleteArgs() complete.Predictor { 149 return complete.PredictOr( 150 complete.PredictFiles("*.nomad"), 151 complete.PredictFiles("*.hcl"), 152 complete.PredictFiles("*.json"), 153 ) 154 } 155 156 func (c *JobPlanCommand) Name() string { return "job plan" } 157 func (c *JobPlanCommand) Run(args []string) int { 158 var diff, policyOverride, verbose bool 159 var vaultToken, vaultNamespace string 160 161 flagSet := c.Meta.FlagSet(c.Name(), FlagSetClient) 162 flagSet.Usage = func() { c.Ui.Output(c.Help()) } 163 flagSet.BoolVar(&diff, "diff", true, "") 164 flagSet.BoolVar(&policyOverride, "policy-override", false, "") 165 flagSet.BoolVar(&verbose, "verbose", false, "") 166 flagSet.BoolVar(&c.JobGetter.JSON, "json", false, "") 167 flagSet.BoolVar(&c.JobGetter.HCL1, "hcl1", false, "") 168 flagSet.BoolVar(&c.JobGetter.Strict, "hcl2-strict", true, "") 169 flagSet.StringVar(&vaultToken, "vault-token", "", "") 170 flagSet.StringVar(&vaultNamespace, "vault-namespace", "", "") 171 flagSet.Var(&c.JobGetter.Vars, "var", "") 172 flagSet.Var(&c.JobGetter.VarFiles, "var-file", "") 173 174 if err := flagSet.Parse(args); err != nil { 175 return 255 176 } 177 178 // Check that we got exactly one job 179 args = flagSet.Args() 180 if len(args) != 1 { 181 c.Ui.Error("This command takes one argument: <path>") 182 c.Ui.Error(commandErrorText(c)) 183 return 255 184 } 185 186 if c.JobGetter.HCL1 { 187 c.JobGetter.Strict = false 188 } 189 190 if err := c.JobGetter.Validate(); err != nil { 191 c.Ui.Error(fmt.Sprintf("Invalid job options: %s", err)) 192 return 255 193 } 194 195 path := args[0] 196 // Get Job struct from Jobfile 197 job, err := c.JobGetter.Get(path) 198 if err != nil { 199 c.Ui.Error(fmt.Sprintf("Error getting job struct: %s", err)) 200 return 255 201 } 202 203 // Get the HTTP client 204 client, err := c.Meta.Client() 205 if err != nil { 206 c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) 207 return 255 208 } 209 210 // Force the region to be that of the job. 211 if r := job.Region; r != nil { 212 client.SetRegion(*r) 213 } 214 215 // Force the namespace to be that of the job. 216 if n := job.Namespace; n != nil { 217 client.SetNamespace(*n) 218 } 219 220 // Parse the Vault token. 221 if vaultToken == "" { 222 // Check the environment variable 223 vaultToken = os.Getenv("VAULT_TOKEN") 224 } 225 226 // Set the vault token. 227 if vaultToken != "" { 228 job.VaultToken = pointer.Of(vaultToken) 229 } 230 231 // Set the vault namespace. 232 if vaultNamespace != "" { 233 job.VaultNamespace = pointer.Of(vaultNamespace) 234 } 235 236 // Setup the options 237 opts := &api.PlanOptions{} 238 if diff { 239 opts.Diff = true 240 } 241 if policyOverride { 242 opts.PolicyOverride = true 243 } 244 245 if job.IsMultiregion() { 246 return c.multiregionPlan(client, job, opts, diff, verbose) 247 } 248 249 // Submit the job 250 resp, _, err := client.Jobs().PlanOpts(job, opts, nil) 251 if err != nil { 252 c.Ui.Error(fmt.Sprintf("Error during plan: %s", err)) 253 return 255 254 } 255 256 runArgs := strings.Builder{} 257 for _, varArg := range c.JobGetter.Vars { 258 runArgs.WriteString(fmt.Sprintf("-var=%q ", varArg)) 259 } 260 261 for _, varFile := range c.JobGetter.VarFiles { 262 runArgs.WriteString(fmt.Sprintf("-var-file=%q ", varFile)) 263 } 264 265 exitCode := c.outputPlannedJob(job, resp, diff, verbose) 266 c.Ui.Output(c.Colorize().Color(formatJobModifyIndex(resp.JobModifyIndex, runArgs.String(), path))) 267 return exitCode 268 } 269 270 func (c *JobPlanCommand) multiregionPlan(client *api.Client, job *api.Job, opts *api.PlanOptions, diff, verbose bool) int { 271 272 var exitCode int 273 plans := map[string]*api.JobPlanResponse{} 274 275 // collect all the plans first so that we can report all errors 276 for _, region := range job.Multiregion.Regions { 277 regionName := region.Name 278 client.SetRegion(regionName) 279 280 // Submit the job for this region 281 resp, _, err := client.Jobs().PlanOpts(job, opts, nil) 282 if err != nil { 283 c.Ui.Error(fmt.Sprintf("Error during plan for region %q: %s", regionName, err)) 284 exitCode = 255 285 } 286 plans[regionName] = resp 287 } 288 289 if exitCode > 0 { 290 return exitCode 291 } 292 293 for regionName, resp := range plans { 294 c.Ui.Output(c.Colorize().Color(fmt.Sprintf("[bold]Region: %q[reset]", regionName))) 295 regionExitCode := c.outputPlannedJob(job, resp, diff, verbose) 296 if regionExitCode > exitCode { 297 exitCode = regionExitCode 298 } 299 } 300 return exitCode 301 } 302 303 func (c *JobPlanCommand) outputPlannedJob(job *api.Job, resp *api.JobPlanResponse, diff, verbose bool) int { 304 305 // Print the diff if not disabled 306 if diff { 307 c.Ui.Output(fmt.Sprintf("%s\n", 308 c.Colorize().Color(strings.TrimSpace(formatJobDiff(resp.Diff, verbose))))) 309 } 310 311 // Print the scheduler dry-run output 312 c.Ui.Output(c.Colorize().Color("[bold]Scheduler dry-run:[reset]")) 313 c.Ui.Output(c.Colorize().Color(formatDryRun(resp, job))) 314 c.Ui.Output("") 315 316 // Print any warnings if there are any 317 if resp.Warnings != "" { 318 c.Ui.Output( 319 c.Colorize().Color(fmt.Sprintf("[bold][yellow]Job Warnings:\n%s[reset]\n", resp.Warnings))) 320 } 321 322 // Print preemptions if there are any 323 if resp.Annotations != nil && len(resp.Annotations.PreemptedAllocs) > 0 { 324 c.addPreemptions(resp) 325 } 326 327 return getExitCode(resp) 328 } 329 330 // addPreemptions shows details about preempted allocations 331 func (c *JobPlanCommand) addPreemptions(resp *api.JobPlanResponse) { 332 c.Ui.Output(c.Colorize().Color("[bold][yellow]Preemptions:\n[reset]")) 333 if len(resp.Annotations.PreemptedAllocs) < preemptionDisplayThreshold { 334 var allocs []string 335 allocs = append(allocs, "Alloc ID|Job ID|Task Group") 336 for _, alloc := range resp.Annotations.PreemptedAllocs { 337 allocs = append(allocs, fmt.Sprintf("%s|%s|%s", alloc.ID, alloc.JobID, alloc.TaskGroup)) 338 } 339 c.Ui.Output(formatList(allocs)) 340 return 341 } 342 // Display in a summary format if the list is too large 343 // Group by job type and job ids 344 allocDetails := make(map[string]map[namespaceIdPair]int) 345 numJobs := 0 346 for _, alloc := range resp.Annotations.PreemptedAllocs { 347 id := namespaceIdPair{alloc.JobID, alloc.Namespace} 348 countMap := allocDetails[alloc.JobType] 349 if countMap == nil { 350 countMap = make(map[namespaceIdPair]int) 351 } 352 cnt, ok := countMap[id] 353 if !ok { 354 // First time we are seeing this job, increment counter 355 numJobs++ 356 } 357 countMap[id] = cnt + 1 358 allocDetails[alloc.JobType] = countMap 359 } 360 361 // Show counts grouped by job ID if its less than a threshold 362 var output []string 363 if numJobs < preemptionDisplayThreshold { 364 output = append(output, "Job ID|Namespace|Job Type|Preemptions") 365 for jobType, jobCounts := range allocDetails { 366 for jobId, count := range jobCounts { 367 output = append(output, fmt.Sprintf("%s|%s|%s|%d", jobId.id, jobId.namespace, jobType, count)) 368 } 369 } 370 } else { 371 // Show counts grouped by job type 372 output = append(output, "Job Type|Preemptions") 373 for jobType, jobCounts := range allocDetails { 374 total := 0 375 for _, count := range jobCounts { 376 total += count 377 } 378 output = append(output, fmt.Sprintf("%s|%d", jobType, total)) 379 } 380 } 381 c.Ui.Output(formatList(output)) 382 383 } 384 385 type namespaceIdPair struct { 386 id string 387 namespace string 388 } 389 390 // getExitCode returns 0: 391 // * 0: No allocations created or destroyed. 392 // * 1: Allocations created or destroyed. 393 func getExitCode(resp *api.JobPlanResponse) int { 394 if resp.Diff.Type == "None" { 395 return 0 396 } 397 398 // Check for changes 399 for _, d := range resp.Annotations.DesiredTGUpdates { 400 if d.Stop+d.Place+d.Migrate+d.DestructiveUpdate+d.Canary > 0 { 401 return 1 402 } 403 } 404 405 return 0 406 } 407 408 // formatJobModifyIndex produces a help string that displays the job modify 409 // index and how to submit a job with it. 410 func formatJobModifyIndex(jobModifyIndex uint64, args string, jobName string) string { 411 help := fmt.Sprintf(jobModifyIndexHelp, jobModifyIndex, args, jobName) 412 out := fmt.Sprintf("[reset][bold]Job Modify Index: %d[reset]\n%s", jobModifyIndex, help) 413 return out 414 } 415 416 // formatDryRun produces a string explaining the results of the dry run. 417 func formatDryRun(resp *api.JobPlanResponse, job *api.Job) string { 418 var rolling *api.Evaluation 419 for _, eval := range resp.CreatedEvals { 420 if eval.TriggeredBy == "rolling-update" { 421 rolling = eval 422 } 423 } 424 425 var out string 426 if len(resp.FailedTGAllocs) == 0 { 427 out = "[bold][green]- All tasks successfully allocated.[reset]\n" 428 } else { 429 // Change the output depending on if we are a system job or not 430 if job.Type != nil && *job.Type == "system" { 431 out = "[bold][yellow]- WARNING: Failed to place allocations on all nodes.[reset]\n" 432 } else { 433 out = "[bold][yellow]- WARNING: Failed to place all allocations.[reset]\n" 434 } 435 sorted := sortedTaskGroupFromMetrics(resp.FailedTGAllocs) 436 for _, tg := range sorted { 437 metrics := resp.FailedTGAllocs[tg] 438 439 noun := "allocation" 440 if metrics.CoalescedFailures > 0 { 441 noun += "s" 442 } 443 out += fmt.Sprintf("%s[yellow]Task Group %q (failed to place %d %s):\n[reset]", strings.Repeat(" ", 2), tg, metrics.CoalescedFailures+1, noun) 444 out += fmt.Sprintf("[yellow]%s[reset]\n\n", formatAllocMetrics(metrics, false, strings.Repeat(" ", 4))) 445 } 446 if rolling == nil { 447 out = strings.TrimSuffix(out, "\n") 448 } 449 } 450 451 if rolling != nil { 452 out += fmt.Sprintf("[green]- Rolling update, next evaluation will be in %s.\n", rolling.Wait) 453 } 454 455 if next := resp.NextPeriodicLaunch; !next.IsZero() && !job.IsParameterized() { 456 loc, err := job.Periodic.GetLocation() 457 if err != nil { 458 out += fmt.Sprintf("[yellow]- Invalid time zone: %v", err) 459 } else { 460 now := time.Now().In(loc) 461 out += fmt.Sprintf("[green]- If submitted now, next periodic launch would be at %s (%s from now).\n", 462 formatTime(next), formatTimeDifference(now, next, time.Second)) 463 } 464 } 465 466 out = strings.TrimSuffix(out, "\n") 467 return out 468 } 469 470 // formatJobDiff produces an annotated diff of the job. If verbose mode is 471 // set, added or deleted task groups and tasks are expanded. 472 func formatJobDiff(job *api.JobDiff, verbose bool) string { 473 marker, _ := getDiffString(job.Type) 474 out := fmt.Sprintf("%s[bold]Job: %q\n", marker, job.ID) 475 476 // Determine the longest markers and fields so that the output can be 477 // properly aligned. 478 longestField, longestMarker := getLongestPrefixes(job.Fields, job.Objects) 479 for _, tg := range job.TaskGroups { 480 if _, l := getDiffString(tg.Type); l > longestMarker { 481 longestMarker = l 482 } 483 } 484 485 // Only show the job's field and object diffs if the job is edited or 486 // verbose mode is set. 487 if job.Type == "Edited" || verbose { 488 fo := alignedFieldAndObjects(job.Fields, job.Objects, 0, longestField, longestMarker) 489 out += fo 490 if len(fo) > 0 { 491 out += "\n" 492 } 493 } 494 495 // Print the task groups 496 for _, tg := range job.TaskGroups { 497 _, mLength := getDiffString(tg.Type) 498 kPrefix := longestMarker - mLength 499 out += fmt.Sprintf("%s\n", formatTaskGroupDiff(tg, kPrefix, verbose)) 500 } 501 502 return out 503 } 504 505 // formatTaskGroupDiff produces an annotated diff of a task group. If the 506 // verbose field is set, the task groups fields and objects are expanded even if 507 // the full object is an addition or removal. tgPrefix is the number of spaces to prefix 508 // the output of the task group. 509 func formatTaskGroupDiff(tg *api.TaskGroupDiff, tgPrefix int, verbose bool) string { 510 marker, _ := getDiffString(tg.Type) 511 out := fmt.Sprintf("%s%s[bold]Task Group: %q[reset]", marker, strings.Repeat(" ", tgPrefix), tg.Name) 512 513 // Append the updates and colorize them 514 if l := len(tg.Updates); l > 0 { 515 order := make([]string, 0, l) 516 for updateType := range tg.Updates { 517 order = append(order, updateType) 518 } 519 520 sort.Strings(order) 521 updates := make([]string, 0, l) 522 for _, updateType := range order { 523 count := tg.Updates[updateType] 524 var color string 525 switch updateType { 526 case scheduler.UpdateTypeIgnore: 527 case scheduler.UpdateTypeCreate: 528 color = "[green]" 529 case scheduler.UpdateTypeDestroy: 530 color = "[red]" 531 case scheduler.UpdateTypeMigrate: 532 color = "[blue]" 533 case scheduler.UpdateTypeInplaceUpdate: 534 color = "[cyan]" 535 case scheduler.UpdateTypeDestructiveUpdate: 536 color = "[yellow]" 537 case scheduler.UpdateTypeCanary: 538 color = "[light_yellow]" 539 } 540 updates = append(updates, fmt.Sprintf("[reset]%s%d %s", color, count, updateType)) 541 } 542 out += fmt.Sprintf(" (%s[reset])\n", strings.Join(updates, ", ")) 543 } else { 544 out += "[reset]\n" 545 } 546 547 // Determine the longest field and markers so the output is properly 548 // aligned 549 longestField, longestMarker := getLongestPrefixes(tg.Fields, tg.Objects) 550 for _, task := range tg.Tasks { 551 if _, l := getDiffString(task.Type); l > longestMarker { 552 longestMarker = l 553 } 554 } 555 556 // Only show the task groups's field and object diffs if the group is edited or 557 // verbose mode is set. 558 subStartPrefix := tgPrefix + 2 559 if tg.Type == "Edited" || verbose { 560 fo := alignedFieldAndObjects(tg.Fields, tg.Objects, subStartPrefix, longestField, longestMarker) 561 out += fo 562 if len(fo) > 0 { 563 out += "\n" 564 } 565 } 566 567 // Output the tasks 568 for _, task := range tg.Tasks { 569 _, mLength := getDiffString(task.Type) 570 prefix := longestMarker - mLength 571 out += fmt.Sprintf("%s\n", formatTaskDiff(task, subStartPrefix, prefix, verbose)) 572 } 573 574 return out 575 } 576 577 // formatTaskDiff produces an annotated diff of a task. If the verbose field is 578 // set, the tasks fields and objects are expanded even if the full object is an 579 // addition or removal. startPrefix is the number of spaces to prefix the output of 580 // the task and taskPrefix is the number of spaces to put between the marker and 581 // task name output. 582 func formatTaskDiff(task *api.TaskDiff, startPrefix, taskPrefix int, verbose bool) string { 583 marker, _ := getDiffString(task.Type) 584 out := fmt.Sprintf("%s%s%s[bold]Task: %q", 585 strings.Repeat(" ", startPrefix), marker, strings.Repeat(" ", taskPrefix), task.Name) 586 if len(task.Annotations) != 0 { 587 out += fmt.Sprintf(" [reset](%s)", colorAnnotations(task.Annotations)) 588 } 589 590 if task.Type == "None" { 591 return out 592 } else if (task.Type == "Deleted" || task.Type == "Added") && !verbose { 593 // Exit early if the job was not edited and it isn't verbose output 594 return out 595 } else { 596 out += "\n" 597 } 598 599 subStartPrefix := startPrefix + 2 600 longestField, longestMarker := getLongestPrefixes(task.Fields, task.Objects) 601 out += alignedFieldAndObjects(task.Fields, task.Objects, subStartPrefix, longestField, longestMarker) 602 return out 603 } 604 605 // formatObjectDiff produces an annotated diff of an object. startPrefix is the 606 // number of spaces to prefix the output of the object and keyPrefix is the number 607 // of spaces to put between the marker and object name output. 608 func formatObjectDiff(diff *api.ObjectDiff, startPrefix, keyPrefix int) string { 609 start := strings.Repeat(" ", startPrefix) 610 marker, markerLen := getDiffString(diff.Type) 611 out := fmt.Sprintf("%s%s%s%s {\n", start, marker, strings.Repeat(" ", keyPrefix), diff.Name) 612 613 // Determine the length of the longest name and longest diff marker to 614 // properly align names and values 615 longestField, longestMarker := getLongestPrefixes(diff.Fields, diff.Objects) 616 subStartPrefix := startPrefix + keyPrefix + 2 617 out += alignedFieldAndObjects(diff.Fields, diff.Objects, subStartPrefix, longestField, longestMarker) 618 619 endprefix := strings.Repeat(" ", startPrefix+markerLen+keyPrefix) 620 return fmt.Sprintf("%s\n%s}", out, endprefix) 621 } 622 623 // formatFieldDiff produces an annotated diff of a field. startPrefix is the 624 // number of spaces to prefix the output of the field, keyPrefix is the number 625 // of spaces to put between the marker and field name output and valuePrefix is 626 // the number of spaces to put infront of the value for aligning values. 627 func formatFieldDiff(diff *api.FieldDiff, startPrefix, keyPrefix, valuePrefix int) string { 628 marker, _ := getDiffString(diff.Type) 629 out := fmt.Sprintf("%s%s%s%s: %s", 630 strings.Repeat(" ", startPrefix), 631 marker, strings.Repeat(" ", keyPrefix), 632 diff.Name, 633 strings.Repeat(" ", valuePrefix)) 634 635 switch diff.Type { 636 case "Added": 637 out += fmt.Sprintf("%q", diff.New) 638 case "Deleted": 639 out += fmt.Sprintf("%q", diff.Old) 640 case "Edited": 641 out += fmt.Sprintf("%q => %q", diff.Old, diff.New) 642 default: 643 out += fmt.Sprintf("%q", diff.New) 644 } 645 646 // Color the annotations where possible 647 if l := len(diff.Annotations); l != 0 { 648 out += fmt.Sprintf(" (%s)", colorAnnotations(diff.Annotations)) 649 } 650 651 return out 652 } 653 654 // alignedFieldAndObjects is a helper method that prints fields and objects 655 // properly aligned. 656 func alignedFieldAndObjects(fields []*api.FieldDiff, objects []*api.ObjectDiff, 657 startPrefix, longestField, longestMarker int) string { 658 659 var out string 660 numFields := len(fields) 661 numObjects := len(objects) 662 haveObjects := numObjects != 0 663 for i, field := range fields { 664 _, mLength := getDiffString(field.Type) 665 kPrefix := longestMarker - mLength 666 vPrefix := longestField - len(field.Name) 667 out += formatFieldDiff(field, startPrefix, kPrefix, vPrefix) 668 669 // Avoid a dangling new line 670 if i+1 != numFields || haveObjects { 671 out += "\n" 672 } 673 } 674 675 for i, object := range objects { 676 _, mLength := getDiffString(object.Type) 677 kPrefix := longestMarker - mLength 678 out += formatObjectDiff(object, startPrefix, kPrefix) 679 680 // Avoid a dangling new line 681 if i+1 != numObjects { 682 out += "\n" 683 } 684 } 685 686 return out 687 } 688 689 // getLongestPrefixes takes a list of fields and objects and determines the 690 // longest field name and the longest marker. 691 func getLongestPrefixes(fields []*api.FieldDiff, objects []*api.ObjectDiff) (longestField, longestMarker int) { 692 for _, field := range fields { 693 if l := len(field.Name); l > longestField { 694 longestField = l 695 } 696 if _, l := getDiffString(field.Type); l > longestMarker { 697 longestMarker = l 698 } 699 } 700 for _, obj := range objects { 701 if _, l := getDiffString(obj.Type); l > longestMarker { 702 longestMarker = l 703 } 704 } 705 return longestField, longestMarker 706 } 707 708 // getDiffString returns a colored diff marker and the length of the string 709 // without color annotations. 710 func getDiffString(diffType string) (string, int) { 711 switch diffType { 712 case "Added": 713 return "[green]+[reset] ", 2 714 case "Deleted": 715 return "[red]-[reset] ", 2 716 case "Edited": 717 return "[light_yellow]+/-[reset] ", 4 718 default: 719 return "", 0 720 } 721 } 722 723 // colorAnnotations returns a comma concatenated list of the annotations where 724 // the annotations are colored where possible. 725 func colorAnnotations(annotations []string) string { 726 l := len(annotations) 727 if l == 0 { 728 return "" 729 } 730 731 colored := make([]string, l) 732 for i, annotation := range annotations { 733 switch annotation { 734 case "forces create": 735 colored[i] = fmt.Sprintf("[green]%s[reset]", annotation) 736 case "forces destroy": 737 colored[i] = fmt.Sprintf("[red]%s[reset]", annotation) 738 case "forces in-place update": 739 colored[i] = fmt.Sprintf("[cyan]%s[reset]", annotation) 740 case "forces create/destroy update": 741 colored[i] = fmt.Sprintf("[yellow]%s[reset]", annotation) 742 default: 743 colored[i] = annotation 744 } 745 } 746 747 return strings.Join(colored, ", ") 748 }