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  }