go.ligato.io/vpp-agent/v3@v3.5.0/cmd/agentctl/commands/config.go (about)

     1  //  Copyright (c) 2019 Cisco and/or its affiliates.
     2  //
     3  //  Licensed under the Apache License, Version 2.0 (the "License");
     4  //  you may not use this file except in compliance with the License.
     5  //  You may obtain a copy of the License at:
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  //  Unless required by applicable law or agreed to in writing, software
    10  //  distributed under the License is distributed on an "AS IS" BASIS,
    11  //  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  //  See the License for the specific language governing permissions and
    13  //  limitations under the License.
    14  
    15  package commands
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"io"
    21  	"os"
    22  	"sort"
    23  	"strconv"
    24  	"strings"
    25  	"time"
    26  
    27  	yaml2 "github.com/ghodss/yaml"
    28  	"github.com/olekukonko/tablewriter"
    29  	"github.com/sirupsen/logrus"
    30  	"github.com/spf13/cobra"
    31  	"google.golang.org/grpc"
    32  	"google.golang.org/grpc/metadata"
    33  	"google.golang.org/protobuf/encoding/protojson"
    34  	"google.golang.org/protobuf/proto"
    35  
    36  	"go.ligato.io/vpp-agent/v3/client"
    37  	"go.ligato.io/vpp-agent/v3/cmd/agentctl/api/types"
    38  	agentcli "go.ligato.io/vpp-agent/v3/cmd/agentctl/cli"
    39  	kvs "go.ligato.io/vpp-agent/v3/plugins/kvscheduler/api"
    40  	"go.ligato.io/vpp-agent/v3/proto/ligato/configurator"
    41  	"go.ligato.io/vpp-agent/v3/proto/ligato/kvscheduler"
    42  )
    43  
    44  func NewConfigCommand(cli agentcli.Cli) *cobra.Command {
    45  	cmd := &cobra.Command{
    46  		Use:   "config",
    47  		Short: "Manage agent configuration",
    48  	}
    49  	cmd.AddCommand(
    50  		newConfigGetCommand(cli),
    51  		newConfigUpdateCommand(cli),
    52  		newConfigDeleteCommand(cli),
    53  		newConfigRetrieveCommand(cli),
    54  		newConfigWatchCommand(cli),
    55  		newConfigResyncCommand(cli),
    56  		newConfigHistoryCommand(cli),
    57  	)
    58  	return cmd
    59  }
    60  
    61  func newConfigGetCommand(cli agentcli.Cli) *cobra.Command {
    62  	var (
    63  		opts ConfigGetOptions
    64  	)
    65  	cmd := &cobra.Command{
    66  		Use:   "get",
    67  		Short: "Get config from agent",
    68  		Args:  cobra.NoArgs,
    69  		RunE: func(cmd *cobra.Command, args []string) error {
    70  			return runConfigGet(cli, opts)
    71  		},
    72  	}
    73  	flags := cmd.Flags()
    74  	flags.StringVarP(&opts.Format, "format", "f", "", "Format output")
    75  	flags.StringSliceVar(&opts.Labels, "labels", []string{}, "Output only config items that have given labels. "+
    76  		"Format of labels is: \"<string>=<string>\" key-value pairs separated by comma. "+
    77  		"If the key is prefixed with \"!\" the config items that contain that label are excluded from the result. "+
    78  		"Empty keys and duplicated keys are not allowed. "+
    79  		"If value of label is empty, equals sign can be omitted. "+
    80  		"For example: --labels=\"foo=bar\",\"baz=\",\"qux\", \"!quux=corge\", \"!grault\"")
    81  	return cmd
    82  }
    83  
    84  type ConfigGetOptions struct {
    85  	Format string
    86  	Labels []string
    87  }
    88  
    89  func runConfigGet(cli agentcli.Cli, opts ConfigGetOptions) error {
    90  	// get generic client
    91  	c, err := cli.Client().GenericClient()
    92  	if err != nil {
    93  		return err
    94  	}
    95  
    96  	// create dynamically config that can hold all remote known models
    97  	// (not using local model registry that gives only locally available models)
    98  	knownModels, err := c.KnownModels("config")
    99  	if err != nil {
   100  		return fmt.Errorf("getting registered models: %w", err)
   101  	}
   102  	config, err := client.NewDynamicConfig(knownModels)
   103  	if err != nil {
   104  		return fmt.Errorf("can't create all-config proto message dynamically due to: %w", err)
   105  	}
   106  
   107  	// fill labels map
   108  	labels, err := parseLabels(opts.Labels)
   109  	if err != nil {
   110  		return fmt.Errorf("parsing labels failed: %w", err)
   111  	}
   112  
   113  	// retrieve data into config
   114  	err = c.GetFilteredConfig(client.Filter{Labels: labels}, config)
   115  	if err != nil {
   116  		return fmt.Errorf("can't retrieve configuration due to: %w", err)
   117  	}
   118  
   119  	// handle data output
   120  	format := opts.Format
   121  	if len(format) == 0 {
   122  		format = `yaml`
   123  	}
   124  	if err := formatAsTemplate(cli.Out(), format, config); err != nil {
   125  		return err
   126  	}
   127  	return nil
   128  }
   129  
   130  func newConfigUpdateCommand(cli agentcli.Cli) *cobra.Command {
   131  	var (
   132  		opts ConfigUpdateOptions
   133  	)
   134  	cmd := &cobra.Command{
   135  		Use:   "update",
   136  		Short: "Update config in agent",
   137  		Long:  "Update configuration in agent from file",
   138  		Args:  cobra.MaximumNArgs(1),
   139  		RunE: func(cmd *cobra.Command, args []string) error {
   140  			return runConfigUpdate(cli, opts, args)
   141  		},
   142  	}
   143  	flags := cmd.Flags()
   144  	flags.StringVarP(&opts.Format, "format", "f", "", "Format output")
   145  	flags.BoolVar(&opts.Replace, "replace", false, "Replaces all existing config")
   146  	// TODO implement waitdone also for generic client
   147  	// flags.BoolVar(&opts.WaitDone, "waitdone", false, "Waits until config update is done")
   148  	// TODO implement transaction output when verbose is used
   149  	// flags.BoolVarP(&opts.Verbose, "verbose", "v", false, "Show verbose output")
   150  	flags.DurationVarP(&opts.Timeout, "timeout", "t",
   151  		5*time.Minute, "Timeout for sending updated data")
   152  	flags.StringSliceVar(&opts.Labels, "labels", []string{}, "Labels associated with updated config items. "+
   153  		"Format of labels is: \"<string>=<string>\" key-value pairs separated by comma. "+
   154  		"Empty keys and duplicated keys are not allowed. "+
   155  		"If value of label is empty, equals sign can be omitted. "+
   156  		"For example: --labels=\"foo=bar\",\"baz=\",\"qux\"")
   157  	return cmd
   158  }
   159  
   160  type ConfigUpdateOptions struct {
   161  	Format  string
   162  	Replace bool
   163  	// WaitDone bool
   164  	// Verbose  bool
   165  	Timeout time.Duration
   166  	Labels  []string
   167  }
   168  
   169  func runConfigUpdate(cli agentcli.Cli, opts ConfigUpdateOptions, args []string) error {
   170  	ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout)
   171  	defer cancel()
   172  
   173  	// get input file
   174  	if len(args) == 0 {
   175  		return fmt.Errorf("missing file argument")
   176  	}
   177  	file := args[0]
   178  	b, err := os.ReadFile(file)
   179  	if err != nil {
   180  		return fmt.Errorf("reading file %s: %w", file, err)
   181  	}
   182  
   183  	// get generic client
   184  	c, err := cli.Client().GenericClient()
   185  	if err != nil {
   186  		return err
   187  	}
   188  
   189  	// create dynamically config that can hold all remote known models
   190  	// (not using local model registry that gives only locally available models)
   191  	knownModels, err := c.KnownModels("config")
   192  	if err != nil {
   193  		return fmt.Errorf("getting registered models: %w", err)
   194  	}
   195  	config, err := client.NewDynamicConfig(knownModels)
   196  	if err != nil {
   197  		return fmt.Errorf("can't create all-config proto message dynamically due to: %w", err)
   198  	}
   199  
   200  	// filling dynamically created config with data from input file
   201  	bj, err := yaml2.YAMLToJSON(b)
   202  	if err != nil {
   203  		return fmt.Errorf("converting to JSON: %w", err)
   204  	}
   205  	err = protojson.Unmarshal(bj, config)
   206  	if err != nil {
   207  		return fmt.Errorf("can't unmarshall input file data "+
   208  			"into dynamically created config due to: %v", err)
   209  	}
   210  	logrus.Infof("loaded config :\n%s", config)
   211  
   212  	// extracting proto messages from dynamically created config structure
   213  	// (generic client wants single proto messages and not one big hierarchical config)
   214  	configMessages, err := client.DynamicConfigExport(config)
   215  	if err != nil {
   216  		return fmt.Errorf("can't extract single configuration proto messages "+
   217  			"from one big configuration proto message due to: %v", err)
   218  	}
   219  
   220  	// fill labels map
   221  	labels, err := parseLabels(opts.Labels)
   222  	if err != nil {
   223  		return fmt.Errorf("parsing labels failed: %w", err)
   224  	}
   225  	if len(opts.Labels) == 0 {
   226  		labels["io.ligato.from-client"] = "agentctl"
   227  	}
   228  
   229  	// update/resync configuration
   230  	_, err = c.UpdateItems(ctx, createUpdateItems(configMessages, labels), opts.Replace)
   231  	if err != nil {
   232  		return fmt.Errorf("update failed: %w", err)
   233  	}
   234  
   235  	// handle configuration update result and command output
   236  	format := opts.Format
   237  	if len(format) == 0 {
   238  		format = `{{.}}`
   239  	}
   240  	if err := formatAsTemplate(cli.Out(), format, "OK"); err != nil {
   241  		return err
   242  	}
   243  
   244  	return nil
   245  }
   246  
   247  func newConfigDeleteCommand(cli agentcli.Cli) *cobra.Command {
   248  	var (
   249  		opts ConfigDeleteOptions
   250  	)
   251  	cmd := &cobra.Command{
   252  		Use:   "delete",
   253  		Short: "Delete config in agent",
   254  		Long:  "Delete configuration in agent",
   255  		Args:  cobra.MaximumNArgs(1),
   256  		RunE: func(cmd *cobra.Command, args []string) error {
   257  			return runConfigDelete(cli, opts, args)
   258  		},
   259  	}
   260  	flags := cmd.Flags()
   261  	flags.StringVarP(&opts.Format, "format", "f", "", "Format output")
   262  	flags.BoolVar(&opts.WaitDone, "waitdone", false, "Waits until config update is done")
   263  	flags.BoolVarP(&opts.Verbose, "verbose", "v", false, "Show verbose output")
   264  	return cmd
   265  }
   266  
   267  type ConfigDeleteOptions struct {
   268  	Format   string
   269  	WaitDone bool
   270  	Verbose  bool
   271  }
   272  
   273  func runConfigDelete(cli agentcli.Cli, opts ConfigDeleteOptions, args []string) error {
   274  	ctx, cancel := context.WithCancel(context.Background())
   275  	defer cancel()
   276  
   277  	c, err := cli.Client().ConfiguratorClient()
   278  	if err != nil {
   279  		return err
   280  	}
   281  
   282  	if len(args) == 0 {
   283  		return fmt.Errorf("missing file argument")
   284  	}
   285  	file := args[0]
   286  	b, err := os.ReadFile(file)
   287  	if err != nil {
   288  		return fmt.Errorf("reading file %s: %w", file, err)
   289  	}
   290  
   291  	var update = &configurator.Config{}
   292  	bj, err := yaml2.YAMLToJSON(b)
   293  	if err != nil {
   294  		return fmt.Errorf("converting to JSON: %w", err)
   295  	}
   296  	err = protojson.Unmarshal(bj, update)
   297  	if err != nil {
   298  		return err
   299  	}
   300  	logrus.Infof("loaded config delete:\n%s", update)
   301  
   302  	var data interface{}
   303  
   304  	var header metadata.MD
   305  	resp, err := c.Delete(ctx, &configurator.DeleteRequest{
   306  		Delete:   update,
   307  		WaitDone: opts.WaitDone,
   308  	}, grpc.Header(&header))
   309  	if err != nil {
   310  		logrus.Warnf("delete failed: %v", err)
   311  		data = err
   312  	} else {
   313  		data = resp
   314  	}
   315  
   316  	if opts.Verbose {
   317  		logrus.Debugf("grpc header: %+v", header)
   318  		if seqNum, ok := header["seqnum"]; ok {
   319  			ref, _ := strconv.Atoi(seqNum[0])
   320  			txns, err := cli.Client().SchedulerHistory(ctx, types.SchedulerHistoryOptions{
   321  				SeqNum: ref,
   322  			})
   323  			if err != nil {
   324  				logrus.Warnf("getting history for seqNum %d failed: %v", ref, err)
   325  			} else {
   326  				data = txns
   327  			}
   328  		}
   329  	}
   330  
   331  	format := opts.Format
   332  	if len(format) == 0 {
   333  		format = `{{.}}`
   334  	}
   335  	if err := formatAsTemplate(cli.Out(), format, data); err != nil {
   336  		return err
   337  	}
   338  
   339  	return nil
   340  }
   341  
   342  func newConfigRetrieveCommand(cli agentcli.Cli) *cobra.Command {
   343  	var (
   344  		opts ConfigRetrieveOptions
   345  	)
   346  	cmd := &cobra.Command{
   347  		Use:     "retrieve",
   348  		Aliases: []string{"ret", "read", "dump"},
   349  		Short:   "Retrieve currently running config",
   350  		Args:    cobra.NoArgs,
   351  		RunE: func(cmd *cobra.Command, args []string) error {
   352  			return runConfigRetrieve(cli, opts)
   353  		},
   354  	}
   355  	flags := cmd.Flags()
   356  	flags.StringVarP(&opts.Format, "format", "f", "", "Format output")
   357  	return cmd
   358  }
   359  
   360  type ConfigRetrieveOptions struct {
   361  	Format string
   362  }
   363  
   364  func runConfigRetrieve(cli agentcli.Cli, opts ConfigRetrieveOptions) error {
   365  	ctx, cancel := context.WithCancel(context.Background())
   366  	defer cancel()
   367  
   368  	c, err := cli.Client().ConfiguratorClient()
   369  	if err != nil {
   370  		return err
   371  	}
   372  	resp, err := c.Dump(ctx, &configurator.DumpRequest{})
   373  	if err != nil {
   374  		return err
   375  	}
   376  
   377  	format := opts.Format
   378  	if len(format) == 0 {
   379  		format = `yaml`
   380  	}
   381  	if err := formatAsTemplate(cli.Out(), format, resp.Dump); err != nil {
   382  		return err
   383  	}
   384  
   385  	return nil
   386  }
   387  
   388  func newConfigWatchCommand(cli agentcli.Cli) *cobra.Command {
   389  	var (
   390  		opts ConfigWatchOptions
   391  	)
   392  	cmd := &cobra.Command{
   393  		Use:     "watch",
   394  		Aliases: []string{"notify", "subscribe"},
   395  		Short:   "Watch events",
   396  		Example: "Filter events by VPP interface name 'loop1'" +
   397  			`{{.CommandPath}} config watch --filter='{"vpp_notification":{"interface":{"state":{"name":"loop1"}}}}'` +
   398  			"" +
   399  			"Filter events by VPP interface UPDOWN type" +
   400  			`{{.CommandPath}} config watch --filter='{"vpp_notification":{"interface":{"type":"UPDOWN"}}}'`,
   401  		Args: cobra.NoArgs,
   402  		RunE: func(cmd *cobra.Command, args []string) error {
   403  			return runConfigWatch(cli, opts)
   404  		},
   405  	}
   406  	flags := cmd.Flags()
   407  	flags.StringArrayVar(&opts.Filters, "filter", nil, "Filter(s) for notifications (multiple filters are used with AND operator). Value should be JSON data of configurator.Notification.")
   408  	flags.StringVarP(&opts.Format, "format", "f", "", "Format output")
   409  	return cmd
   410  }
   411  
   412  type ConfigWatchOptions struct {
   413  	Format  string
   414  	Filters []string
   415  }
   416  
   417  func runConfigWatch(cli agentcli.Cli, opts ConfigWatchOptions) error {
   418  	ctx, cancel := context.WithCancel(context.Background())
   419  	defer cancel()
   420  
   421  	c, err := cli.Client().ConfiguratorClient()
   422  	if err != nil {
   423  		return err
   424  	}
   425  
   426  	filters, err := prepareNotifyFilters(opts.Filters)
   427  	if err != nil {
   428  		return fmt.Errorf("filters error: %w", err)
   429  	}
   430  
   431  	var nextIdx uint32
   432  	stream, err := c.Notify(ctx, &configurator.NotifyRequest{
   433  		Idx:     nextIdx,
   434  		Filters: filters,
   435  	})
   436  	if err != nil {
   437  		return err
   438  	}
   439  
   440  	format := opts.Format
   441  	if len(format) == 0 {
   442  		format = `------------------
   443   NOTIFICATION #{{.NextIdx}}
   444  ------------------
   445  {{if .Notification.GetVppNotification}}Source: VPP
   446  Value: {{protomulti .Notification.GetVppNotification}}
   447  {{else if .Notification.GetLinuxNotification}}Source: LINUX
   448  Value:  {{protomulti .Notification.GetLinuxNotification}}
   449  {{else}}Source: {{printf "%T" .Notification.GetNotification}}
   450  Value:  {{protomulti .Notification.GetNotification}}
   451  {{end}}`
   452  	}
   453  
   454  	for {
   455  		notif, err := stream.Recv()
   456  		if err == io.EOF {
   457  			break
   458  		} else if err != nil {
   459  			return err
   460  		}
   461  
   462  		logrus.Debugf("Notification[%d]: %v",
   463  			notif.NextIdx-1, notif.Notification)
   464  
   465  		if err := formatAsTemplate(cli.Out(), format, notif); err != nil {
   466  			return err
   467  		}
   468  	}
   469  
   470  	return nil
   471  }
   472  
   473  func prepareNotifyFilters(filters []string) ([]*configurator.Notification, error) {
   474  	var list []*configurator.Notification
   475  	for _, filter := range filters {
   476  		notif := &configurator.Notification{}
   477  		err := protojson.Unmarshal([]byte(filter), notif)
   478  		if err != nil {
   479  			return nil, err
   480  		}
   481  		list = append(list, notif)
   482  	}
   483  	return list, nil
   484  }
   485  
   486  func newConfigResyncCommand(cli agentcli.Cli) *cobra.Command {
   487  	var (
   488  		opts ConfigResyncOptions
   489  	)
   490  	cmd := &cobra.Command{
   491  		Use:   "resync",
   492  		Short: "Run config resync",
   493  		Args:  cobra.NoArgs,
   494  		RunE: func(cmd *cobra.Command, args []string) error {
   495  			return runConfigResync(cli, opts)
   496  		},
   497  	}
   498  	flags := cmd.Flags()
   499  	flags.StringVarP(&opts.Format, "format", "f", "", "Format output")
   500  	flags.BoolVar(&opts.Verbose, "verbose", false, "Run resync in verbose mode")
   501  	flags.BoolVar(&opts.Retry, "retry", false, "Run resync with retries")
   502  	return cmd
   503  }
   504  
   505  type ConfigResyncOptions struct {
   506  	Format  string
   507  	Verbose bool
   508  	Retry   bool
   509  }
   510  
   511  // TODO: define default format with go template
   512  const defaultFormatConfigResync = `json`
   513  
   514  func runConfigResync(cli agentcli.Cli, opts ConfigResyncOptions) error {
   515  	ctx, cancel := context.WithCancel(context.Background())
   516  	defer cancel()
   517  
   518  	rectxn, err := cli.Client().SchedulerResync(ctx, types.SchedulerResyncOptions{
   519  		Retry:   opts.Retry,
   520  		Verbose: opts.Verbose,
   521  	})
   522  	if err != nil {
   523  		return err
   524  	}
   525  
   526  	format := opts.Format
   527  	if len(format) == 0 {
   528  		format = defaultFormatConfigResync
   529  	}
   530  	if err := formatAsTemplate(cli.Out(), format, rectxn); err != nil {
   531  		return err
   532  	}
   533  
   534  	return nil
   535  }
   536  
   537  func newConfigHistoryCommand(cli agentcli.Cli) *cobra.Command {
   538  	var (
   539  		opts ConfigHistoryOptions
   540  	)
   541  	cmd := &cobra.Command{
   542  		Use:   "history [REF]",
   543  		Short: "Show config history",
   544  		Long: `Show history of config changes and status updates
   545  
   546  Prints a table of most important information about the history of changes to 
   547  config and status updates that have occurred. You can filter the output by
   548  specifying a reference to sequence number (txn ID).
   549  
   550  Type can be one of:
   551   - config change  (NB - full resync)
   552   - status update  (SB)
   553   - config sync    (NB - upstream resync)
   554   - status sync    (NB - downstream resync)
   555   - retry #X for Y (retry of TX)
   556  `,
   557  		Example: `
   558  # Show entire history
   559  {{.CommandPath}} config history
   560  
   561  # Show entire history with details
   562  {{.CommandPath}} config history --details
   563  
   564  # Show entire history in transaction log format
   565  {{.CommandPath}} config history -f log
   566  
   567  # Show entire history in classic log format
   568  {{.CommandPath}} config history -f log
   569  
   570  # Show history point with sequence number 3
   571  {{.CommandPath}} config history 3
   572  
   573  # Show history point with seq. number 3 in log format
   574  {{.CommandPath}} config history -f log 3
   575  `,
   576  		Args: cobra.MaximumNArgs(1),
   577  		RunE: func(cmd *cobra.Command, args []string) error {
   578  			if len(args) > 0 {
   579  				opts.TxnRef = args[0]
   580  			}
   581  			return runConfigHistory(cli, opts)
   582  		},
   583  	}
   584  	flags := cmd.Flags()
   585  	flags.StringVarP(&opts.Format, "format", "f", "", "Format output")
   586  	flags.BoolVar(&opts.Details, "details", false, "Include details")
   587  	return cmd
   588  }
   589  
   590  type ConfigHistoryOptions struct {
   591  	Format  string
   592  	Details bool
   593  	TxnRef  string
   594  }
   595  
   596  func runConfigHistory(cli agentcli.Cli, opts ConfigHistoryOptions) (err error) {
   597  	ctx, cancel := context.WithCancel(context.Background())
   598  	defer cancel()
   599  
   600  	ref := -1
   601  	if opts.TxnRef != "" {
   602  		ref, err = strconv.Atoi(opts.TxnRef)
   603  		if err != nil {
   604  			return fmt.Errorf("invalid reference: %q, use number > 0", opts.TxnRef)
   605  		}
   606  	}
   607  
   608  	// register remote models into the default registry
   609  	_, err = cli.Client().ModelList(ctx, types.ModelListOptions{
   610  		Class: "config",
   611  	})
   612  	if err != nil {
   613  		return err
   614  	}
   615  
   616  	txns, err := cli.Client().SchedulerHistory(ctx, types.SchedulerHistoryOptions{
   617  		SeqNum: ref,
   618  	})
   619  	if err != nil {
   620  		return err
   621  	}
   622  
   623  	format := opts.Format
   624  	if len(format) == 0 {
   625  		printHistoryTable(cli.Out(), txns, opts.Details)
   626  	} else if format == "log" {
   627  		format = "{{.}}"
   628  	}
   629  	if err := formatAsTemplate(cli.Out(), format, txns); err != nil {
   630  		return err
   631  	}
   632  
   633  	return nil
   634  }
   635  
   636  func printHistoryTable(out io.Writer, txns kvs.RecordedTxns, withDetails bool) {
   637  	table := tablewriter.NewWriter(out)
   638  	header := []string{
   639  		"Seq", "Type", "Start", "Input", "Operations", "Result", "Summary",
   640  	}
   641  	if withDetails {
   642  		header = append(header, "Details")
   643  	}
   644  	table.SetHeader(header)
   645  	table.SetAutoWrapText(false)
   646  	table.SetAutoFormatHeaders(true)
   647  	table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
   648  	table.SetAlignment(tablewriter.ALIGN_LEFT)
   649  	table.SetCenterSeparator("")
   650  	table.SetColumnSeparator("")
   651  	table.SetRowSeparator("")
   652  	table.SetHeaderLine(false)
   653  	table.SetBorder(false)
   654  	table.SetTablePadding("\t")
   655  	for _, txn := range txns {
   656  		typ := getTxnType(txn)
   657  		clr := getTxnColor(txn)
   658  		age := shortHumanDuration(time.Since(txn.Start))
   659  		var result string
   660  		var resClr int
   661  		var detail string
   662  		var summary string
   663  		var input string
   664  		if len(txn.Values) > 0 {
   665  			input = fmt.Sprintf("%-2d values", len(txn.Values))
   666  		} else {
   667  			input = "<none>"
   668  		}
   669  		var operation string
   670  		if len(txn.Executed) > 0 {
   671  			operation = txnOperations(txn)
   672  			summary = txnValueStates(txn)
   673  		} else {
   674  			operation = "<none>"
   675  			summary = "<none>"
   676  		}
   677  		errs := txnErrors(txn)
   678  		if errs != nil {
   679  			result = "error"
   680  			resClr = tablewriter.FgHiRedColor
   681  			if len(errs) > 1 {
   682  				result = fmt.Sprintf("%d errors", len(errs))
   683  			}
   684  		} else if len(txn.Executed) > 0 {
   685  			result = "ok"
   686  			resClr = tablewriter.FgGreenColor
   687  		}
   688  		if withDetails {
   689  			for _, e := range errs {
   690  				if detail != "" {
   691  					detail += "\n"
   692  				}
   693  				detail += fmt.Sprintf("%v", e.Error())
   694  			}
   695  			if reasons := txnPendingReasons(txn); reasons != "" {
   696  				if detail != "" {
   697  					detail += "\n"
   698  				}
   699  				detail += reasons
   700  			}
   701  		}
   702  		row := []string{
   703  			fmt.Sprint(txn.SeqNum),
   704  			typ,
   705  			age,
   706  			input,
   707  			operation,
   708  			result,
   709  			summary,
   710  		}
   711  		if withDetails {
   712  			row = append(row, detail)
   713  		}
   714  		clrs := []tablewriter.Colors{
   715  			{},
   716  			{tablewriter.Normal, clr},
   717  			{},
   718  			{},
   719  			{},
   720  			{resClr},
   721  			{},
   722  			{},
   723  		}
   724  		table.Rich(row, clrs)
   725  	}
   726  	table.Render()
   727  }
   728  
   729  func getTxnColor(txn *kvs.RecordedTxn) int {
   730  	var clr int
   731  	switch txn.TxnType {
   732  	case kvs.NBTransaction:
   733  		if txn.ResyncType == kvs.NotResync {
   734  			clr = tablewriter.FgYellowColor
   735  		} else if txn.ResyncType == kvs.FullResync {
   736  			clr = tablewriter.FgHiYellowColor
   737  		} else {
   738  			clr = tablewriter.FgYellowColor
   739  		}
   740  	case kvs.SBNotification:
   741  		clr = tablewriter.FgCyanColor
   742  	case kvs.RetryFailedOps:
   743  		clr = tablewriter.FgMagentaColor
   744  	}
   745  	return clr
   746  }
   747  
   748  func getTxnType(txn *kvs.RecordedTxn) string {
   749  	switch txn.TxnType {
   750  	case kvs.SBNotification:
   751  		return "status update"
   752  	case kvs.NBTransaction:
   753  		if txn.ResyncType == kvs.FullResync {
   754  			return "config replace"
   755  		} else if txn.ResyncType == kvs.UpstreamResync {
   756  			return "config sync"
   757  		} else if txn.ResyncType == kvs.DownstreamResync {
   758  			return "status sync"
   759  		}
   760  		return "config change"
   761  	case kvs.RetryFailedOps:
   762  		return fmt.Sprintf("retry #%d for %d", txn.RetryAttempt, txn.RetryForTxn)
   763  	}
   764  	return "?"
   765  }
   766  
   767  func txnValueStates(txn *kvs.RecordedTxn) string {
   768  	opermap := map[string]int{}
   769  	for _, r := range txn.Executed {
   770  		opermap[r.NewState.String()]++
   771  	}
   772  	var opers []string
   773  	for k, v := range opermap {
   774  		opers = append(opers, fmt.Sprintf("%s:%v", k, v))
   775  	}
   776  	sort.Strings(opers)
   777  	return strings.Join(opers, ", ")
   778  }
   779  
   780  func txnOperations(txn *kvs.RecordedTxn) string {
   781  	opermap := map[string]int{}
   782  	for _, r := range txn.Executed {
   783  		opermap[r.Operation.String()]++
   784  	}
   785  	var opers []string
   786  	for k, v := range opermap {
   787  		opers = append(opers, fmt.Sprintf("%s:%v", k, v))
   788  	}
   789  	sort.Strings(opers)
   790  	return strings.Join(opers, ", ")
   791  }
   792  
   793  func txnPendingReasons(txn *kvs.RecordedTxn) string {
   794  	var details []string
   795  	for _, r := range txn.Executed {
   796  		if r.NewState == kvscheduler.ValueState_PENDING {
   797  			// TODO: include pending resons in details
   798  			detail := fmt.Sprintf("[%s] %s -> %s", r.Operation, r.Key, r.NewState)
   799  			details = append(details, detail)
   800  		}
   801  	}
   802  	return strings.Join(details, "\n")
   803  }
   804  
   805  func txnErrors(txn *kvs.RecordedTxn) Errors {
   806  	var errs Errors
   807  	for _, r := range txn.Executed {
   808  		if r.NewErrMsg != "" {
   809  			r.NewErr = fmt.Errorf("[%s] %s -> %s: %v", r.Operation, r.Key, r.NewState, r.NewErrMsg)
   810  			errs = append(errs, r.NewErr)
   811  		}
   812  	}
   813  	return errs
   814  }
   815  
   816  // parseLabels parses labels obtained from command line flags
   817  // This function does not allow duplicate or empty ("") keys
   818  func parseLabels(rawLabels []string) (map[string]string, error) {
   819  	labels := make(map[string]string)
   820  	if len(rawLabels) == 0 {
   821  		return labels, nil
   822  	}
   823  	var lkey, lval string
   824  	for _, rawLabel := range rawLabels {
   825  		i := strings.IndexByte(rawLabel, '=')
   826  		if i == -1 {
   827  			lkey, lval = rawLabel, ""
   828  		} else {
   829  			lkey, lval = rawLabel[:i], rawLabel[i+1:]
   830  		}
   831  		if lkey == "" || lkey == "!" {
   832  			return nil, fmt.Errorf("key of label %s is empty", rawLabel)
   833  		}
   834  		if _, ok := labels[lkey]; ok {
   835  			return nil, fmt.Errorf("label key %s is duplicated", lkey)
   836  		}
   837  		labels[lkey] = lval
   838  	}
   839  	return labels, nil
   840  }
   841  
   842  func createUpdateItems(msgs []proto.Message, labels map[string]string) []client.UpdateItem {
   843  	var result []client.UpdateItem
   844  	for _, msg := range msgs {
   845  		result = append(result, client.UpdateItem{Message: msg, Labels: labels})
   846  	}
   847  	return result
   848  }