github.com/manicqin/nomad@v0.9.5/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  	// preemptionDisplayThreshold is an upper bound used to limit and summarize
    25  	// the details of preempted jobs in the output
    26  	preemptionDisplayThreshold = 10
    27  )
    28  
    29  type JobPlanCommand struct {
    30  	Meta
    31  	JobGetter
    32  }
    33  
    34  func (c *JobPlanCommand) Help() string {
    35  	helpText := `
    36  Usage: nomad job plan [options] <path>
    37  Alias: 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  
    64  General Options:
    65  
    66    ` + generalOptionsUsage() + `
    67  
    68  Plan 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  
    83  func (c *JobPlanCommand) Synopsis() string {
    84  	return "Dry-run a job update to determine its effects"
    85  }
    86  
    87  func (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  
    96  func (c *JobPlanCommand) AutocompleteArgs() complete.Predictor {
    97  	return complete.PredictOr(complete.PredictFiles("*.nomad"), complete.PredictFiles("*.hcl"))
    98  }
    99  
   100  func (c *JobPlanCommand) Name() string { return "job plan" }
   101  func (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
   191  func (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  
   245  type 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.
   253  func 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.
   266  func 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.
   273  func 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.
   328  func 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.
   365  func 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.
   438  func 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.
   464  func 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.
   483  func 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.
   512  func 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.
   547  func 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.
   566  func 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.
   581  func 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  }