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