github.com/nir0s/nomad@v0.8.7-rc1/command/job_plan.go (about) 1 package command 2 3 import ( 4 "fmt" 5 "sort" 6 "strings" 7 "time" 8 9 "github.com/hashicorp/nomad/api" 10 "github.com/hashicorp/nomad/scheduler" 11 "github.com/posener/complete" 12 ) 13 14 const ( 15 jobModifyIndexHelp = `To submit the job with version verification run: 16 17 nomad job run -check-index %d %s 18 19 When running the job with the check-index flag, the job will only be run if the 20 server side version matches the job modify index returned. If the index has 21 changed, another user has modified the job and the plan's results are 22 potentially invalid.` 23 ) 24 25 type JobPlanCommand struct { 26 Meta 27 JobGetter 28 } 29 30 func (c *JobPlanCommand) Help() string { 31 helpText := ` 32 Usage: nomad job plan [options] <path> 33 Alias: nomad plan 34 35 Plan invokes a dry-run of the scheduler to determine the effects of submitting 36 either a new or updated version of a job. The plan will not result in any 37 changes to the cluster but gives insight into whether the job could be run 38 successfully and how it would affect existing allocations. 39 40 If the supplied path is "-", the jobfile is read from stdin. Otherwise 41 it is read from the file at the supplied path or downloaded and 42 read from URL specified. 43 44 A job modify index is returned with the plan. This value can be used when 45 submitting the job using "nomad run -check-index", which will check that the job 46 was not modified between the plan and run command before invoking the 47 scheduler. This ensures the job has not been modified since the plan. 48 49 A structured diff between the local and remote job is displayed to 50 give insight into what the scheduler will attempt to do and why. 51 52 If the job has specified the region, the -region flag and NOMAD_REGION 53 environment variable are overridden and the job's region is used. 54 55 Plan will return one of the following exit codes: 56 * 0: No allocations created or destroyed. 57 * 1: Allocations created or destroyed. 58 * 255: Error determining plan results. 59 60 General Options: 61 62 ` + generalOptionsUsage() + ` 63 64 Plan Options: 65 66 -diff 67 Determines whether the diff between the remote job and planned job is shown. 68 Defaults to true. 69 70 -policy-override 71 Sets the flag to force override any soft mandatory Sentinel policies. 72 73 -verbose 74 Increase diff verbosity. 75 ` 76 return strings.TrimSpace(helpText) 77 } 78 79 func (c *JobPlanCommand) Synopsis() string { 80 return "Dry-run a job update to determine its effects" 81 } 82 83 func (c *JobPlanCommand) AutocompleteFlags() complete.Flags { 84 return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), 85 complete.Flags{ 86 "-diff": complete.PredictNothing, 87 "-policy-override": complete.PredictNothing, 88 "-verbose": complete.PredictNothing, 89 }) 90 } 91 92 func (c *JobPlanCommand) AutocompleteArgs() complete.Predictor { 93 return complete.PredictOr(complete.PredictFiles("*.nomad"), complete.PredictFiles("*.hcl")) 94 } 95 96 func (c *JobPlanCommand) Name() string { return "job plan" } 97 func (c *JobPlanCommand) Run(args []string) int { 98 var diff, policyOverride, verbose bool 99 100 flags := c.Meta.FlagSet(c.Name(), FlagSetClient) 101 flags.Usage = func() { c.Ui.Output(c.Help()) } 102 flags.BoolVar(&diff, "diff", true, "") 103 flags.BoolVar(&policyOverride, "policy-override", false, "") 104 flags.BoolVar(&verbose, "verbose", false, "") 105 106 if err := flags.Parse(args); err != nil { 107 return 255 108 } 109 110 // Check that we got exactly one job 111 args = flags.Args() 112 if len(args) != 1 { 113 c.Ui.Error("This command takes one argument: <path>") 114 c.Ui.Error(commandErrorText(c)) 115 return 255 116 } 117 118 path := args[0] 119 // Get Job struct from Jobfile 120 job, err := c.JobGetter.ApiJob(args[0]) 121 if err != nil { 122 c.Ui.Error(fmt.Sprintf("Error getting job struct: %s", err)) 123 return 255 124 } 125 126 // Get the HTTP client 127 client, err := c.Meta.Client() 128 if err != nil { 129 c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) 130 return 255 131 } 132 133 // Force the region to be that of the job. 134 if r := job.Region; r != nil { 135 client.SetRegion(*r) 136 } 137 138 // Force the namespace to be that of the job. 139 if n := job.Namespace; n != nil { 140 client.SetNamespace(*n) 141 } 142 143 // Setup the options 144 opts := &api.PlanOptions{} 145 if diff { 146 opts.Diff = true 147 } 148 if policyOverride { 149 opts.PolicyOverride = true 150 } 151 152 // Submit the job 153 resp, _, err := client.Jobs().PlanOpts(job, opts, nil) 154 if err != nil { 155 c.Ui.Error(fmt.Sprintf("Error during plan: %s", err)) 156 return 255 157 } 158 159 // Print the diff if not disabled 160 if diff { 161 c.Ui.Output(fmt.Sprintf("%s\n", 162 c.Colorize().Color(strings.TrimSpace(formatJobDiff(resp.Diff, verbose))))) 163 } 164 165 // Print the scheduler dry-run output 166 c.Ui.Output(c.Colorize().Color("[bold]Scheduler dry-run:[reset]")) 167 c.Ui.Output(c.Colorize().Color(formatDryRun(resp, job))) 168 c.Ui.Output("") 169 170 // Print any warnings if there are any 171 if resp.Warnings != "" { 172 c.Ui.Output( 173 c.Colorize().Color(fmt.Sprintf("[bold][yellow]Job Warnings:\n%s[reset]\n", resp.Warnings))) 174 } 175 176 // Print the job index info 177 c.Ui.Output(c.Colorize().Color(formatJobModifyIndex(resp.JobModifyIndex, path))) 178 return getExitCode(resp) 179 } 180 181 // getExitCode returns 0: 182 // * 0: No allocations created or destroyed. 183 // * 1: Allocations created or destroyed. 184 func getExitCode(resp *api.JobPlanResponse) int { 185 // Check for changes 186 for _, d := range resp.Annotations.DesiredTGUpdates { 187 if d.Stop+d.Place+d.Migrate+d.DestructiveUpdate+d.Canary > 0 { 188 return 1 189 } 190 } 191 192 return 0 193 } 194 195 // formatJobModifyIndex produces a help string that displays the job modify 196 // index and how to submit a job with it. 197 func formatJobModifyIndex(jobModifyIndex uint64, jobName string) string { 198 help := fmt.Sprintf(jobModifyIndexHelp, jobModifyIndex, jobName) 199 out := fmt.Sprintf("[reset][bold]Job Modify Index: %d[reset]\n%s", jobModifyIndex, help) 200 return out 201 } 202 203 // formatDryRun produces a string explaining the results of the dry run. 204 func formatDryRun(resp *api.JobPlanResponse, job *api.Job) string { 205 var rolling *api.Evaluation 206 for _, eval := range resp.CreatedEvals { 207 if eval.TriggeredBy == "rolling-update" { 208 rolling = eval 209 } 210 } 211 212 var out string 213 if len(resp.FailedTGAllocs) == 0 { 214 out = "[bold][green]- All tasks successfully allocated.[reset]\n" 215 } else { 216 // Change the output depending on if we are a system job or not 217 if job.Type != nil && *job.Type == "system" { 218 out = "[bold][yellow]- WARNING: Failed to place allocations on all nodes.[reset]\n" 219 } else { 220 out = "[bold][yellow]- WARNING: Failed to place all allocations.[reset]\n" 221 } 222 sorted := sortedTaskGroupFromMetrics(resp.FailedTGAllocs) 223 for _, tg := range sorted { 224 metrics := resp.FailedTGAllocs[tg] 225 226 noun := "allocation" 227 if metrics.CoalescedFailures > 0 { 228 noun += "s" 229 } 230 out += fmt.Sprintf("%s[yellow]Task Group %q (failed to place %d %s):\n[reset]", strings.Repeat(" ", 2), tg, metrics.CoalescedFailures+1, noun) 231 out += fmt.Sprintf("[yellow]%s[reset]\n\n", formatAllocMetrics(metrics, false, strings.Repeat(" ", 4))) 232 } 233 if rolling == nil { 234 out = strings.TrimSuffix(out, "\n") 235 } 236 } 237 238 if rolling != nil { 239 out += fmt.Sprintf("[green]- Rolling update, next evaluation will be in %s.\n", rolling.Wait) 240 } 241 242 if next := resp.NextPeriodicLaunch; !next.IsZero() && !job.IsParameterized() { 243 loc, err := job.Periodic.GetLocation() 244 if err != nil { 245 out += fmt.Sprintf("[yellow]- Invalid time zone: %v", err) 246 } else { 247 now := time.Now().In(loc) 248 out += fmt.Sprintf("[green]- If submitted now, next periodic launch would be at %s (%s from now).\n", 249 formatTime(next), formatTimeDifference(now, next, time.Second)) 250 } 251 } 252 253 out = strings.TrimSuffix(out, "\n") 254 return out 255 } 256 257 // formatJobDiff produces an annotated diff of the job. If verbose mode is 258 // set, added or deleted task groups and tasks are expanded. 259 func formatJobDiff(job *api.JobDiff, verbose bool) string { 260 marker, _ := getDiffString(job.Type) 261 out := fmt.Sprintf("%s[bold]Job: %q\n", marker, job.ID) 262 263 // Determine the longest markers and fields so that the output can be 264 // properly aligned. 265 longestField, longestMarker := getLongestPrefixes(job.Fields, job.Objects) 266 for _, tg := range job.TaskGroups { 267 if _, l := getDiffString(tg.Type); l > longestMarker { 268 longestMarker = l 269 } 270 } 271 272 // Only show the job's field and object diffs if the job is edited or 273 // verbose mode is set. 274 if job.Type == "Edited" || verbose { 275 fo := alignedFieldAndObjects(job.Fields, job.Objects, 0, longestField, longestMarker) 276 out += fo 277 if len(fo) > 0 { 278 out += "\n" 279 } 280 } 281 282 // Print the task groups 283 for _, tg := range job.TaskGroups { 284 _, mLength := getDiffString(tg.Type) 285 kPrefix := longestMarker - mLength 286 out += fmt.Sprintf("%s\n", formatTaskGroupDiff(tg, kPrefix, verbose)) 287 } 288 289 return out 290 } 291 292 // formatTaskGroupDiff produces an annotated diff of a task group. If the 293 // verbose field is set, the task groups fields and objects are expanded even if 294 // the full object is an addition or removal. tgPrefix is the number of spaces to prefix 295 // the output of the task group. 296 func formatTaskGroupDiff(tg *api.TaskGroupDiff, tgPrefix int, verbose bool) string { 297 marker, _ := getDiffString(tg.Type) 298 out := fmt.Sprintf("%s%s[bold]Task Group: %q[reset]", marker, strings.Repeat(" ", tgPrefix), tg.Name) 299 300 // Append the updates and colorize them 301 if l := len(tg.Updates); l > 0 { 302 order := make([]string, 0, l) 303 for updateType := range tg.Updates { 304 order = append(order, updateType) 305 } 306 307 sort.Strings(order) 308 updates := make([]string, 0, l) 309 for _, updateType := range order { 310 count := tg.Updates[updateType] 311 var color string 312 switch updateType { 313 case scheduler.UpdateTypeIgnore: 314 case scheduler.UpdateTypeCreate: 315 color = "[green]" 316 case scheduler.UpdateTypeDestroy: 317 color = "[red]" 318 case scheduler.UpdateTypeMigrate: 319 color = "[blue]" 320 case scheduler.UpdateTypeInplaceUpdate: 321 color = "[cyan]" 322 case scheduler.UpdateTypeDestructiveUpdate: 323 color = "[yellow]" 324 case scheduler.UpdateTypeCanary: 325 color = "[light_yellow]" 326 } 327 updates = append(updates, fmt.Sprintf("[reset]%s%d %s", color, count, updateType)) 328 } 329 out += fmt.Sprintf(" (%s[reset])\n", strings.Join(updates, ", ")) 330 } else { 331 out += "[reset]\n" 332 } 333 334 // Determine the longest field and markers so the output is properly 335 // aligned 336 longestField, longestMarker := getLongestPrefixes(tg.Fields, tg.Objects) 337 for _, task := range tg.Tasks { 338 if _, l := getDiffString(task.Type); l > longestMarker { 339 longestMarker = l 340 } 341 } 342 343 // Only show the task groups's field and object diffs if the group is edited or 344 // verbose mode is set. 345 subStartPrefix := tgPrefix + 2 346 if tg.Type == "Edited" || verbose { 347 fo := alignedFieldAndObjects(tg.Fields, tg.Objects, subStartPrefix, longestField, longestMarker) 348 out += fo 349 if len(fo) > 0 { 350 out += "\n" 351 } 352 } 353 354 // Output the tasks 355 for _, task := range tg.Tasks { 356 _, mLength := getDiffString(task.Type) 357 prefix := longestMarker - mLength 358 out += fmt.Sprintf("%s\n", formatTaskDiff(task, subStartPrefix, prefix, verbose)) 359 } 360 361 return out 362 } 363 364 // formatTaskDiff produces an annotated diff of a task. If the verbose field is 365 // set, the tasks fields and objects are expanded even if the full object is an 366 // addition or removal. startPrefix is the number of spaces to prefix the output of 367 // the task and taskPrefix is the number of spaces to put between the marker and 368 // task name output. 369 func formatTaskDiff(task *api.TaskDiff, startPrefix, taskPrefix int, verbose bool) string { 370 marker, _ := getDiffString(task.Type) 371 out := fmt.Sprintf("%s%s%s[bold]Task: %q", 372 strings.Repeat(" ", startPrefix), marker, strings.Repeat(" ", taskPrefix), task.Name) 373 if len(task.Annotations) != 0 { 374 out += fmt.Sprintf(" [reset](%s)", colorAnnotations(task.Annotations)) 375 } 376 377 if task.Type == "None" { 378 return out 379 } else if (task.Type == "Deleted" || task.Type == "Added") && !verbose { 380 // Exit early if the job was not edited and it isn't verbose output 381 return out 382 } else { 383 out += "\n" 384 } 385 386 subStartPrefix := startPrefix + 2 387 longestField, longestMarker := getLongestPrefixes(task.Fields, task.Objects) 388 out += alignedFieldAndObjects(task.Fields, task.Objects, subStartPrefix, longestField, longestMarker) 389 return out 390 } 391 392 // formatObjectDiff produces an annotated diff of an object. startPrefix is the 393 // number of spaces to prefix the output of the object and keyPrefix is the number 394 // of spaces to put between the marker and object name output. 395 func formatObjectDiff(diff *api.ObjectDiff, startPrefix, keyPrefix int) string { 396 start := strings.Repeat(" ", startPrefix) 397 marker, markerLen := getDiffString(diff.Type) 398 out := fmt.Sprintf("%s%s%s%s {\n", start, marker, strings.Repeat(" ", keyPrefix), diff.Name) 399 400 // Determine the length of the longest name and longest diff marker to 401 // properly align names and values 402 longestField, longestMarker := getLongestPrefixes(diff.Fields, diff.Objects) 403 subStartPrefix := startPrefix + keyPrefix + 2 404 out += alignedFieldAndObjects(diff.Fields, diff.Objects, subStartPrefix, longestField, longestMarker) 405 406 endprefix := strings.Repeat(" ", startPrefix+markerLen+keyPrefix) 407 return fmt.Sprintf("%s\n%s}", out, endprefix) 408 } 409 410 // formatFieldDiff produces an annotated diff of a field. startPrefix is the 411 // number of spaces to prefix the output of the field, keyPrefix is the number 412 // of spaces to put between the marker and field name output and valuePrefix is 413 // the number of spaces to put infront of the value for aligning values. 414 func formatFieldDiff(diff *api.FieldDiff, startPrefix, keyPrefix, valuePrefix int) string { 415 marker, _ := getDiffString(diff.Type) 416 out := fmt.Sprintf("%s%s%s%s: %s", 417 strings.Repeat(" ", startPrefix), 418 marker, strings.Repeat(" ", keyPrefix), 419 diff.Name, 420 strings.Repeat(" ", valuePrefix)) 421 422 switch diff.Type { 423 case "Added": 424 out += fmt.Sprintf("%q", diff.New) 425 case "Deleted": 426 out += fmt.Sprintf("%q", diff.Old) 427 case "Edited": 428 out += fmt.Sprintf("%q => %q", diff.Old, diff.New) 429 default: 430 out += fmt.Sprintf("%q", diff.New) 431 } 432 433 // Color the annotations where possible 434 if l := len(diff.Annotations); l != 0 { 435 out += fmt.Sprintf(" (%s)", colorAnnotations(diff.Annotations)) 436 } 437 438 return out 439 } 440 441 // alignedFieldAndObjects is a helper method that prints fields and objects 442 // properly aligned. 443 func alignedFieldAndObjects(fields []*api.FieldDiff, objects []*api.ObjectDiff, 444 startPrefix, longestField, longestMarker int) string { 445 446 var out string 447 numFields := len(fields) 448 numObjects := len(objects) 449 haveObjects := numObjects != 0 450 for i, field := range fields { 451 _, mLength := getDiffString(field.Type) 452 kPrefix := longestMarker - mLength 453 vPrefix := longestField - len(field.Name) 454 out += formatFieldDiff(field, startPrefix, kPrefix, vPrefix) 455 456 // Avoid a dangling new line 457 if i+1 != numFields || haveObjects { 458 out += "\n" 459 } 460 } 461 462 for i, object := range objects { 463 _, mLength := getDiffString(object.Type) 464 kPrefix := longestMarker - mLength 465 out += formatObjectDiff(object, startPrefix, kPrefix) 466 467 // Avoid a dangling new line 468 if i+1 != numObjects { 469 out += "\n" 470 } 471 } 472 473 return out 474 } 475 476 // getLongestPrefixes takes a list of fields and objects and determines the 477 // longest field name and the longest marker. 478 func getLongestPrefixes(fields []*api.FieldDiff, objects []*api.ObjectDiff) (longestField, longestMarker int) { 479 for _, field := range fields { 480 if l := len(field.Name); l > longestField { 481 longestField = l 482 } 483 if _, l := getDiffString(field.Type); l > longestMarker { 484 longestMarker = l 485 } 486 } 487 for _, obj := range objects { 488 if _, l := getDiffString(obj.Type); l > longestMarker { 489 longestMarker = l 490 } 491 } 492 return longestField, longestMarker 493 } 494 495 // getDiffString returns a colored diff marker and the length of the string 496 // without color annotations. 497 func getDiffString(diffType string) (string, int) { 498 switch diffType { 499 case "Added": 500 return "[green]+[reset] ", 2 501 case "Deleted": 502 return "[red]-[reset] ", 2 503 case "Edited": 504 return "[light_yellow]+/-[reset] ", 4 505 default: 506 return "", 0 507 } 508 } 509 510 // colorAnnotations returns a comma concatenated list of the annotations where 511 // the annotations are colored where possible. 512 func colorAnnotations(annotations []string) string { 513 l := len(annotations) 514 if l == 0 { 515 return "" 516 } 517 518 colored := make([]string, l) 519 for i, annotation := range annotations { 520 switch annotation { 521 case "forces create": 522 colored[i] = fmt.Sprintf("[green]%s[reset]", annotation) 523 case "forces destroy": 524 colored[i] = fmt.Sprintf("[red]%s[reset]", annotation) 525 case "forces in-place update": 526 colored[i] = fmt.Sprintf("[cyan]%s[reset]", annotation) 527 case "forces create/destroy update": 528 colored[i] = fmt.Sprintf("[yellow]%s[reset]", annotation) 529 default: 530 colored[i] = annotation 531 } 532 } 533 534 return strings.Join(colored, ", ") 535 }