istio.io/istio@v0.0.0-20240520182934-d79c90f27776/istioctl/pkg/tag/tag.go (about)

     1  // Copyright Istio Authors
     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 tag
    16  
    17  import (
    18  	"cmp"
    19  	"context"
    20  	"encoding/json"
    21  	"fmt"
    22  	"io"
    23  	"strings"
    24  	"text/tabwriter"
    25  
    26  	"github.com/spf13/cobra"
    27  	"k8s.io/client-go/kubernetes"
    28  	"k8s.io/client-go/rest"
    29  
    30  	"istio.io/istio/istioctl/pkg/cli"
    31  	"istio.io/istio/istioctl/pkg/util"
    32  	"istio.io/istio/istioctl/pkg/util/formatting"
    33  	"istio.io/istio/pkg/config/analysis"
    34  	"istio.io/istio/pkg/config/analysis/analyzers/webhook"
    35  	"istio.io/istio/pkg/config/analysis/diag"
    36  	"istio.io/istio/pkg/config/analysis/local"
    37  	"istio.io/istio/pkg/config/resource"
    38  	"istio.io/istio/pkg/kube"
    39  	"istio.io/istio/pkg/maps"
    40  	"istio.io/istio/pkg/slices"
    41  )
    42  
    43  const (
    44  
    45  	// help strings and long formatted user outputs
    46  	skipConfirmationFlagHelpStr = `The skipConfirmation determines whether the user is prompted for confirmation.
    47  If set to true, the user is not prompted and a Yes response is assumed in all cases.`
    48  	overrideHelpStr = `If true, allow revision tags to be overwritten, otherwise reject revision tag updates that
    49  overwrite existing revision tags.`
    50  	revisionHelpStr = "Control plane revision to reference from a given revision tag"
    51  	tagCreatedStr   = `Revision tag %q created, referencing control plane revision %q. To enable injection using this
    52  revision tag, use 'kubectl label namespace <NAMESPACE> istio.io/rev=%s'
    53  `
    54  	webhookNameHelpStr          = "Name to use for a revision tag's mutating webhook configuration."
    55  	autoInjectNamespacesHelpStr = "If set to true, the sidecars should be automatically injected into all namespaces by default"
    56  )
    57  
    58  // options for CLI
    59  var (
    60  	// revision to point tag webhook at
    61  	revision             = ""
    62  	manifestsPath        = ""
    63  	overwrite            = false
    64  	skipConfirmation     = false
    65  	webhookName          = ""
    66  	autoInjectNamespaces = false
    67  	outputFormat         = util.TableFormat
    68  )
    69  
    70  type tagDescription struct {
    71  	Tag        string   `json:"tag"`
    72  	Revision   string   `json:"revision"`
    73  	Namespaces []string `json:"namespaces"`
    74  }
    75  
    76  func TagCommand(ctx cli.Context) *cobra.Command {
    77  	cmd := &cobra.Command{
    78  		Use:   "tag",
    79  		Short: "Command group used to interact with revision tags",
    80  		Long: `Command group used to interact with revision tags. Revision tags allow for the creation of mutable aliases
    81  referring to control plane revisions for sidecar injection.
    82  
    83  With revision tags, rather than relabeling a namespace from "istio.io/rev=revision-a" to "istio.io/rev=revision-b" to
    84  change which control plane revision handles injection, it's possible to create a revision tag "prod" and label our
    85  namespace "istio.io/rev=prod". The "prod" revision tag could point to "1-7-6" initially and then be changed to point to "1-8-1"
    86  at some later point.
    87  
    88  This allows operators to change which Istio control plane revision should handle injection for a namespace or set of namespaces
    89  without manual relabeling of the "istio.io/rev" tag.
    90  `,
    91  		Args: func(cmd *cobra.Command, args []string) error {
    92  			if len(args) != 0 {
    93  				return fmt.Errorf("unknown subcommand %q", args[0])
    94  			}
    95  			return nil
    96  		},
    97  		RunE: func(cmd *cobra.Command, args []string) error {
    98  			cmd.HelpFunc()(cmd, args)
    99  			return nil
   100  		},
   101  	}
   102  
   103  	cmd.AddCommand(tagSetCommand(ctx))
   104  	cmd.AddCommand(tagGenerateCommand(ctx))
   105  	cmd.AddCommand(tagListCommand(ctx))
   106  	cmd.AddCommand(tagRemoveCommand(ctx))
   107  
   108  	return cmd
   109  }
   110  
   111  func tagSetCommand(ctx cli.Context) *cobra.Command {
   112  	cmd := &cobra.Command{
   113  		Use:   "set <revision-tag>",
   114  		Short: "Create or modify revision tags",
   115  		Long: `Create or modify revision tags. Tag an Istio control plane revision for use with namespace istio.io/rev
   116  injection labels.`,
   117  		Example: `  # Create a revision tag from the "1-8-0" revision
   118    istioctl tag set prod --revision 1-8-0
   119  
   120    # Point namespace "test-ns" at the revision pointed to by the "prod" revision tag
   121    kubectl label ns test-ns istio.io/rev=prod
   122  
   123    # Change the revision tag to reference the "1-8-1" revision
   124    istioctl tag set prod --revision 1-8-1 --overwrite
   125  
   126    # Make revision "1-8-1" the default revision, both resulting in that revision handling injection for "istio-injection=enabled"
   127    # and validating resources cluster-wide
   128    istioctl tag set default --revision 1-8-1
   129  
   130    # Rollout namespace "test-ns" to update workloads to the "1-8-1" revision
   131    kubectl rollout restart deployments -n test-ns
   132  `,
   133  		SuggestFor: []string{"create"},
   134  		Args: func(cmd *cobra.Command, args []string) error {
   135  			if len(args) == 0 {
   136  				return fmt.Errorf("must provide a tag for modification")
   137  			}
   138  			if len(args) > 1 {
   139  				return fmt.Errorf("must provide a single tag for creation")
   140  			}
   141  			return nil
   142  		},
   143  		RunE: func(cmd *cobra.Command, args []string) error {
   144  			kubeClient, err := ctx.CLIClient()
   145  			if err != nil {
   146  				return fmt.Errorf("failed to create Kubernetes client: %v", err)
   147  			}
   148  
   149  			return setTag(context.Background(), kubeClient, args[0], revision, ctx.IstioNamespace(), false, cmd.OutOrStdout(), cmd.OutOrStderr())
   150  		},
   151  	}
   152  
   153  	cmd.PersistentFlags().BoolVar(&overwrite, "overwrite", false, overrideHelpStr)
   154  	cmd.PersistentFlags().StringVarP(&manifestsPath, "manifests", "d", "", util.ManifestsFlagHelpStr)
   155  	cmd.PersistentFlags().BoolVarP(&skipConfirmation, "skip-confirmation", "y", false, skipConfirmationFlagHelpStr)
   156  	cmd.PersistentFlags().StringVarP(&revision, "revision", "r", "", revisionHelpStr)
   157  	cmd.PersistentFlags().StringVarP(&webhookName, "webhook-name", "", "", webhookNameHelpStr)
   158  	cmd.PersistentFlags().BoolVar(&autoInjectNamespaces, "auto-inject-namespaces", false, autoInjectNamespacesHelpStr)
   159  	_ = cmd.MarkPersistentFlagRequired("revision")
   160  
   161  	return cmd
   162  }
   163  
   164  func tagGenerateCommand(ctx cli.Context) *cobra.Command {
   165  	cmd := &cobra.Command{
   166  		Use:   "generate <revision-tag>",
   167  		Short: "Generate configuration for a revision tag to stdout",
   168  		Long: `Create a revision tag and output to the command's stdout. Tag an Istio control plane revision for use with namespace istio.io/rev
   169  injection labels.`,
   170  		Example: `  # Create a revision tag from the "1-8-0" revision
   171    istioctl tag generate prod --revision 1-8-0 > tag.yaml
   172  
   173    # Apply the tag to cluster
   174    kubectl apply -f tag.yaml
   175  
   176    # Point namespace "test-ns" at the revision pointed to by the "prod" revision tag
   177    kubectl label ns test-ns istio.io/rev=prod
   178  
   179    # Rollout namespace "test-ns" to update workloads to the "1-8-0" revision
   180    kubectl rollout restart deployments -n test-ns
   181  `,
   182  		Args: func(cmd *cobra.Command, args []string) error {
   183  			if len(args) == 0 {
   184  				return fmt.Errorf("must provide a tag for modification")
   185  			}
   186  			if len(args) > 1 {
   187  				return fmt.Errorf("must provide a single tag for creation")
   188  			}
   189  			return nil
   190  		},
   191  		RunE: func(cmd *cobra.Command, args []string) error {
   192  			kubeClient, err := ctx.CLIClient()
   193  			if err != nil {
   194  				return fmt.Errorf("failed to create Kubernetes client: %v", err)
   195  			}
   196  
   197  			return setTag(context.Background(), kubeClient, args[0], revision, ctx.IstioNamespace(), true, cmd.OutOrStdout(), cmd.OutOrStderr())
   198  		},
   199  	}
   200  
   201  	cmd.PersistentFlags().BoolVar(&overwrite, "overwrite", false, overrideHelpStr)
   202  	cmd.PersistentFlags().StringVarP(&manifestsPath, "manifests", "d", "", util.ManifestsFlagHelpStr)
   203  	cmd.PersistentFlags().BoolVarP(&skipConfirmation, "skip-confirmation", "y", false, skipConfirmationFlagHelpStr)
   204  	cmd.PersistentFlags().StringVarP(&revision, "revision", "r", "", revisionHelpStr)
   205  	cmd.PersistentFlags().StringVarP(&webhookName, "webhook-name", "", "", webhookNameHelpStr)
   206  	cmd.PersistentFlags().BoolVar(&autoInjectNamespaces, "auto-inject-namespaces", false, autoInjectNamespacesHelpStr)
   207  	_ = cmd.MarkPersistentFlagRequired("revision")
   208  
   209  	return cmd
   210  }
   211  
   212  func tagListCommand(ctx cli.Context) *cobra.Command {
   213  	cmd := &cobra.Command{
   214  		Use:     "list",
   215  		Short:   "List existing revision tags",
   216  		Example: "istioctl tag list",
   217  		Aliases: []string{"show"},
   218  		Args: func(cmd *cobra.Command, args []string) error {
   219  			if len(args) != 0 {
   220  				return fmt.Errorf("tag list command does not accept arguments")
   221  			}
   222  			return nil
   223  		},
   224  		RunE: func(cmd *cobra.Command, args []string) error {
   225  			kubeClient, err := ctx.CLIClient()
   226  			if err != nil {
   227  				return fmt.Errorf("failed to create Kubernetes client: %v", err)
   228  			}
   229  			return listTags(context.Background(), kubeClient.Kube(), cmd.OutOrStdout())
   230  		},
   231  	}
   232  
   233  	cmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", util.TableFormat, "Output format for tag description "+
   234  		"(available formats: table,json)")
   235  	return cmd
   236  }
   237  
   238  func tagRemoveCommand(ctx cli.Context) *cobra.Command {
   239  	cmd := &cobra.Command{
   240  		Use:   "remove <revision-tag>",
   241  		Short: "Remove Istio control plane revision tag",
   242  		Long: `Remove Istio control plane revision tag.
   243  
   244  Removing a revision tag should be done with care. Removing a revision tag will disrupt sidecar injection in namespaces
   245  that reference the tag in an "istio.io/rev" label. Verify that there are no remaining namespaces referencing a
   246  revision tag before removing using the "istioctl tag list" command.
   247  `,
   248  		Example: `  # Remove the revision tag "prod"
   249    istioctl tag remove prod
   250  `,
   251  		Aliases: []string{"delete"},
   252  		Args: func(cmd *cobra.Command, args []string) error {
   253  			if len(args) == 0 {
   254  				return fmt.Errorf("must provide a tag for removal")
   255  			}
   256  			if len(args) > 1 {
   257  				return fmt.Errorf("must provide a single tag for removal")
   258  			}
   259  			return nil
   260  		},
   261  		RunE: func(cmd *cobra.Command, args []string) error {
   262  			kubeClient, err := ctx.CLIClient()
   263  			if err != nil {
   264  				return fmt.Errorf("failed to create Kubernetes client: %v", err)
   265  			}
   266  
   267  			return removeTag(context.Background(), kubeClient.Kube(), args[0], skipConfirmation, cmd.OutOrStdout())
   268  		},
   269  	}
   270  
   271  	cmd.PersistentFlags().BoolVarP(&skipConfirmation, "skip-confirmation", "y", false, skipConfirmationFlagHelpStr)
   272  	return cmd
   273  }
   274  
   275  // setTag creates or modifies a revision tag.
   276  func setTag(ctx context.Context, kubeClient kube.CLIClient, tagName, revision, istioNS string, generate bool, w, stderr io.Writer) error {
   277  	opts := &GenerateOptions{
   278  		Tag:                  tagName,
   279  		Revision:             revision,
   280  		WebhookName:          webhookName,
   281  		ManifestsPath:        manifestsPath,
   282  		Generate:             generate,
   283  		Overwrite:            overwrite,
   284  		AutoInjectNamespaces: autoInjectNamespaces,
   285  		UserManaged:          true,
   286  	}
   287  	tagWhYAML, err := Generate(ctx, kubeClient, opts, istioNS)
   288  	if err != nil {
   289  		return err
   290  	}
   291  	// Check the newly generated webhook does not conflict with existing ones.
   292  	resName := webhookName
   293  	if resName == "" {
   294  		resName = fmt.Sprintf("%s-%s", "istio-revision-tag", tagName)
   295  	}
   296  	if err := analyzeWebhook(resName, istioNS, tagWhYAML, revision, kubeClient.RESTConfig()); err != nil {
   297  		// if we have a conflict, we will fail. If --skip-confirmation is set, we will continue with a
   298  		// warning; when actually applying we will also confirm to ensure the user does not see the
   299  		// warning *after* it has applied
   300  		if !skipConfirmation {
   301  			_, _ = stderr.Write([]byte(err.Error()))
   302  			if !generate {
   303  				if !util.Confirm("Apply anyways? [y/N]", w) {
   304  					return nil
   305  				}
   306  			}
   307  		}
   308  	}
   309  
   310  	if generate {
   311  		_, err := w.Write([]byte(tagWhYAML))
   312  		if err != nil {
   313  			return err
   314  		}
   315  		return nil
   316  	}
   317  
   318  	if err := Create(kubeClient, tagWhYAML, istioNS); err != nil {
   319  		return fmt.Errorf("failed to apply tag webhook MutatingWebhookConfiguration to cluster: %v", err)
   320  	}
   321  	fmt.Fprintf(w, tagCreatedStr, tagName, revision, tagName)
   322  	return nil
   323  }
   324  
   325  func analyzeWebhook(name, istioNamespace, wh, revision string, config *rest.Config) error {
   326  	sa := local.NewSourceAnalyzer(analysis.Combine("webhook", &webhook.Analyzer{}), "", resource.Namespace(istioNamespace), nil)
   327  	if err := sa.AddReaderKubeSource([]local.ReaderSource{{Name: "", Reader: strings.NewReader(wh)}}); err != nil {
   328  		return err
   329  	}
   330  	k, err := kube.NewClient(kube.NewClientConfigForRestConfig(config), "")
   331  	if err != nil {
   332  		return err
   333  	}
   334  	sa.AddRunningKubeSourceWithRevision(k, revision, false)
   335  	res, err := sa.Analyze(make(chan struct{}))
   336  	if err != nil {
   337  		return err
   338  	}
   339  	relevantMessages := diag.Messages{}
   340  	for _, msg := range res.Messages.FilterOutLowerThan(diag.Error) {
   341  		if msg.Resource.Metadata.FullName.Name == resource.LocalName(name) {
   342  			relevantMessages = append(relevantMessages, msg)
   343  		}
   344  	}
   345  	if len(relevantMessages) > 0 {
   346  		o, err := formatting.Print(relevantMessages, formatting.LogFormat, false)
   347  		if err != nil {
   348  			return err
   349  		}
   350  		// nolint
   351  		return fmt.Errorf("creating tag would conflict, pass --skip-confirmation to proceed:\n%v\n", o)
   352  	}
   353  	return nil
   354  }
   355  
   356  // removeTag removes an existing revision tag.
   357  func removeTag(ctx context.Context, kubeClient kubernetes.Interface, tagName string, skipConfirmation bool, w io.Writer) error {
   358  	webhooks, err := GetWebhooksWithTag(ctx, kubeClient, tagName)
   359  	if err != nil {
   360  		return fmt.Errorf("failed to retrieve tag with name %s: %v", tagName, err)
   361  	}
   362  	if len(webhooks) == 0 {
   363  		return fmt.Errorf("cannot remove tag %q: cannot find MutatingWebhookConfiguration for tag", tagName)
   364  	}
   365  
   366  	taggedNamespaces, err := GetNamespacesWithTag(ctx, kubeClient, tagName)
   367  	if err != nil {
   368  		return fmt.Errorf("failed to retrieve namespaces dependent on tag %q", tagName)
   369  	}
   370  	// warn user if deleting a tag that still has namespaces pointed to it
   371  	if len(taggedNamespaces) > 0 && !skipConfirmation {
   372  		if !util.Confirm(buildDeleteTagConfirmation(tagName, taggedNamespaces), w) {
   373  			fmt.Fprintf(w, "Aborting operation.\n")
   374  			return nil
   375  		}
   376  	}
   377  
   378  	// proceed with webhook deletion
   379  	err = DeleteTagWebhooks(ctx, kubeClient, tagName)
   380  	if err != nil {
   381  		return fmt.Errorf("failed to delete Istio revision tag MutatingConfigurationWebhook: %v", err)
   382  	}
   383  
   384  	fmt.Fprintf(w, "Revision tag %s removed\n", tagName)
   385  	return nil
   386  }
   387  
   388  type uniqTag struct {
   389  	revision, tag string
   390  }
   391  
   392  // listTags lists existing revision.
   393  func listTags(ctx context.Context, kubeClient kubernetes.Interface, writer io.Writer) error {
   394  	tagWebhooks, err := GetRevisionWebhooks(ctx, kubeClient)
   395  	if err != nil {
   396  		return fmt.Errorf("failed to retrieve revision tags: %v", err)
   397  	}
   398  	if len(tagWebhooks) == 0 {
   399  		fmt.Fprintf(writer, "No Istio revision tag MutatingWebhookConfigurations to list\n")
   400  		return nil
   401  	}
   402  	rawTags := map[uniqTag]tagDescription{}
   403  	for _, wh := range tagWebhooks {
   404  		tagName := GetWebhookTagName(wh)
   405  		tagRevision, err := GetWebhookRevision(wh)
   406  		if err != nil {
   407  			return fmt.Errorf("error parsing revision from webhook %q: %v", wh.Name, err)
   408  		}
   409  		tagNamespaces, err := GetNamespacesWithTag(ctx, kubeClient, tagName)
   410  		if err != nil {
   411  			return fmt.Errorf("error retrieving namespaces for tag %q: %v", tagName, err)
   412  		}
   413  		tagDesc := tagDescription{
   414  			Tag:        tagName,
   415  			Revision:   tagRevision,
   416  			Namespaces: tagNamespaces,
   417  		}
   418  		key := uniqTag{
   419  			revision: tagRevision,
   420  			tag:      tagName,
   421  		}
   422  		rawTags[key] = tagDesc
   423  	}
   424  	for k := range rawTags {
   425  		if k.tag != "" {
   426  			delete(rawTags, uniqTag{revision: k.revision})
   427  		}
   428  	}
   429  	tags := slices.SortFunc(maps.Values(rawTags), func(a, b tagDescription) int {
   430  		if r := cmp.Compare(a.Revision, b.Revision); r != 0 {
   431  			return r
   432  		}
   433  		return cmp.Compare(a.Tag, b.Tag)
   434  	})
   435  
   436  	switch outputFormat {
   437  	case util.JSONFormat:
   438  		return PrintJSON(writer, tags)
   439  	case util.TableFormat:
   440  	default:
   441  		return fmt.Errorf("unknown format: %s", outputFormat)
   442  	}
   443  	w := new(tabwriter.Writer).Init(writer, 0, 8, 1, ' ', 0)
   444  	fmt.Fprintln(w, "TAG\tREVISION\tNAMESPACES")
   445  	for _, t := range tags {
   446  		fmt.Fprintf(w, "%s\t%s\t%s\n", t.Tag, t.Revision, strings.Join(t.Namespaces, ","))
   447  	}
   448  
   449  	return w.Flush()
   450  }
   451  
   452  func PrintJSON(w io.Writer, res any) error {
   453  	out, err := json.MarshalIndent(res, "", "\t")
   454  	if err != nil {
   455  		return fmt.Errorf("error while marshaling to JSON: %v", err)
   456  	}
   457  	fmt.Fprintln(w, string(out))
   458  	return nil
   459  }
   460  
   461  // buildDeleteTagConfirmation takes a list of webhooks and creates a message prompting confirmation for their deletion.
   462  func buildDeleteTagConfirmation(tag string, taggedNamespaces []string) string {
   463  	var sb strings.Builder
   464  	base := fmt.Sprintf("Caution, found %d namespace(s) still injected by tag %q:", len(taggedNamespaces), tag)
   465  	sb.WriteString(base)
   466  	for _, ns := range taggedNamespaces {
   467  		sb.WriteString(" " + ns)
   468  	}
   469  	sb.WriteString("\nProceed with operation? [y/N]")
   470  
   471  	return sb.String()
   472  }