istio.io/istio@v0.0.0-20240520182934-d79c90f27776/istioctl/pkg/waypoint/waypoint.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 waypoint
    16  
    17  import (
    18  	"cmp"
    19  	"context"
    20  	"fmt"
    21  	"strings"
    22  	"sync"
    23  	"text/tabwriter"
    24  	"time"
    25  
    26  	"github.com/hashicorp/go-multierror"
    27  	"github.com/spf13/cobra"
    28  	"k8s.io/apimachinery/pkg/api/errors"
    29  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    30  	"k8s.io/apimachinery/pkg/types"
    31  	gateway "sigs.k8s.io/gateway-api/apis/v1"
    32  	"sigs.k8s.io/yaml"
    33  
    34  	"istio.io/api/label"
    35  	"istio.io/istio/istioctl/pkg/cli"
    36  	"istio.io/istio/pilot/pkg/model/kstatus"
    37  	"istio.io/istio/pkg/config/constants"
    38  	"istio.io/istio/pkg/config/protocol"
    39  	"istio.io/istio/pkg/config/schema/gvk"
    40  	"istio.io/istio/pkg/kube"
    41  	"istio.io/istio/pkg/slices"
    42  	"istio.io/istio/pkg/util/sets"
    43  )
    44  
    45  var (
    46  	revision      = ""
    47  	waitReady     bool
    48  	allNamespaces bool
    49  
    50  	deleteAll bool
    51  
    52  	trafficType       = ""
    53  	validTrafficTypes = sets.New(constants.ServiceTraffic, constants.WorkloadTraffic, constants.AllTraffic, constants.NoTraffic)
    54  
    55  	waypointName    = constants.DefaultNamespaceWaypoint
    56  	enrollNamespace bool
    57  )
    58  
    59  const waitTimeout = 90 * time.Second
    60  
    61  func Cmd(ctx cli.Context) *cobra.Command {
    62  	makeGateway := func(forApply bool) (*gateway.Gateway, error) {
    63  		ns := ctx.NamespaceOrDefault(ctx.Namespace())
    64  		if ctx.Namespace() == "" && !forApply {
    65  			ns = ""
    66  		}
    67  		// If a user sets the waypoint name to an empty string, set it to the default namespace waypoint name.
    68  		if waypointName == "" {
    69  			waypointName = constants.DefaultNamespaceWaypoint
    70  		} else if waypointName == "none" {
    71  			return nil, fmt.Errorf("invalid name provided for waypoint, 'none' is a reserved value")
    72  		}
    73  		gw := gateway.Gateway{
    74  			TypeMeta: metav1.TypeMeta{
    75  				Kind:       gvk.KubernetesGateway_v1.Kind,
    76  				APIVersion: gvk.KubernetesGateway_v1.GroupVersion(),
    77  			},
    78  			ObjectMeta: metav1.ObjectMeta{
    79  				Name:      waypointName,
    80  				Namespace: ns,
    81  			},
    82  			Spec: gateway.GatewaySpec{
    83  				GatewayClassName: constants.WaypointGatewayClassName,
    84  				Listeners: []gateway.Listener{{
    85  					Name:     "mesh",
    86  					Port:     15008,
    87  					Protocol: gateway.ProtocolType(protocol.HBONE),
    88  				}},
    89  			},
    90  		}
    91  
    92  		// only label if the user has provided their own value, otherwise we let istiod choose a default at runtime (service)
    93  		// this will allow for gateway class to provide a default for that class rather than always forcing service or requiring users to configure correctly
    94  		if trafficType != "" {
    95  			if !validTrafficTypes.Contains(trafficType) {
    96  				return nil, fmt.Errorf("invalid traffic type: %s. Valid options are: %s", trafficType, validTrafficTypes.String())
    97  			}
    98  
    99  			if gw.Labels == nil {
   100  				gw.Labels = map[string]string{}
   101  			}
   102  
   103  			gw.Labels[constants.AmbientWaypointForTrafficTypeLabel] = trafficType
   104  		}
   105  
   106  		if revision != "" {
   107  			gw.Labels = map[string]string{label.IoIstioRev.Name: revision}
   108  		}
   109  		return &gw, nil
   110  	}
   111  	waypointGenerateCmd := &cobra.Command{
   112  		Use:   "generate",
   113  		Short: "Generate a waypoint configuration",
   114  		Long:  "Generate a waypoint configuration as YAML",
   115  		Example: `  # Generate a waypoint as yaml
   116    istioctl x waypoint generate --namespace default`,
   117  		RunE: func(cmd *cobra.Command, args []string) error {
   118  			gw, err := makeGateway(false)
   119  			if err != nil {
   120  				return fmt.Errorf("failed to create gateway: %v", err)
   121  			}
   122  			b, err := yaml.Marshal(gw)
   123  			if err != nil {
   124  				return err
   125  			}
   126  			// strip junk
   127  			res := strings.ReplaceAll(string(b), `  creationTimestamp: null
   128  `, "")
   129  			res = strings.ReplaceAll(res, `status: {}
   130  `, "")
   131  			fmt.Fprint(cmd.OutOrStdout(), res)
   132  			return nil
   133  		},
   134  	}
   135  	waypointGenerateCmd.PersistentFlags().StringVar(&trafficType,
   136  		"for",
   137  		"",
   138  		fmt.Sprintf("Specify the traffic type %s for the waypoint", sets.SortedList(validTrafficTypes)),
   139  	)
   140  	waypointApplyCmd := &cobra.Command{
   141  		Use:   "apply",
   142  		Short: "Apply a waypoint configuration",
   143  		Long:  "Apply a waypoint configuration to the cluster",
   144  		Example: `  # Apply a waypoint to the current namespace
   145    istioctl x waypoint apply
   146  
   147    # Apply a waypoint to a specific namespace and wait for it to be ready
   148    istioctl x waypoint apply --namespace default --wait`,
   149  		RunE: func(cmd *cobra.Command, args []string) error {
   150  			kubeClient, err := ctx.CLIClientWithRevision(revision)
   151  			if err != nil {
   152  				return fmt.Errorf("failed to create Kubernetes client: %v", err)
   153  			}
   154  			ns := ctx.NamespaceOrDefault(ctx.Namespace())
   155  			// If a user decides to enroll their namespace with a waypoint, verify that they have labeled their namespace as ambient.
   156  			// If they don't, the user will be warned and be presented with the command to label their namespace as ambient if they
   157  			// choose to do so.
   158  			//
   159  			// NOTE: This is a warning and not an error because the user may not intend to label their namespace as ambient.
   160  			//
   161  			// e.g. Users are handling ambient redirection per workload rather than at the namespace level.
   162  			if enrollNamespace {
   163  				namespaceIsLabeledAmbient, err := namespaceIsLabeledAmbient(kubeClient, ns)
   164  				if err != nil {
   165  					return fmt.Errorf("failed to check if namespace is labeled ambient: %v", err)
   166  				}
   167  				if !namespaceIsLabeledAmbient {
   168  					fmt.Fprintf(cmd.OutOrStdout(), "Warning: namespace is not enrolled in ambient. Consider running\t"+
   169  						"`"+"kubectl label namespace %s istio.io/dataplane-mode=ambient"+"`\n", ns)
   170  				}
   171  			}
   172  			gw, err := makeGateway(true)
   173  			if err != nil {
   174  				return fmt.Errorf("failed to create gateway: %v", err)
   175  			}
   176  			gwc := kubeClient.GatewayAPI().GatewayV1().Gateways(ctx.NamespaceOrDefault(ctx.Namespace()))
   177  			b, err := yaml.Marshal(gw)
   178  			if err != nil {
   179  				return err
   180  			}
   181  			_, err = gwc.Patch(context.Background(), gw.Name, types.ApplyPatchType, b, metav1.PatchOptions{
   182  				Force:        nil,
   183  				FieldManager: "istioctl",
   184  			})
   185  			if err != nil {
   186  				if errors.IsNotFound(err) {
   187  					return fmt.Errorf("missing Kubernetes Gateway CRDs need to be installed before applying a waypoint: %s", err)
   188  				}
   189  				return err
   190  			}
   191  			if waitReady {
   192  				startTime := time.Now()
   193  				ticker := time.NewTicker(1 * time.Second)
   194  				defer ticker.Stop()
   195  				for range ticker.C {
   196  					programmed := false
   197  					gwc, err := kubeClient.GatewayAPI().GatewayV1().Gateways(ctx.NamespaceOrDefault(ctx.Namespace())).Get(context.TODO(), gw.Name, metav1.GetOptions{})
   198  					if err == nil {
   199  						// Check if gateway has Programmed condition set to true
   200  						for _, cond := range gwc.Status.Conditions {
   201  							if cond.Type == string(gateway.GatewayConditionProgrammed) && string(cond.Status) == "True" {
   202  								programmed = true
   203  								break
   204  							}
   205  						}
   206  					}
   207  					if programmed {
   208  						break
   209  					}
   210  					if time.Since(startTime) > waitTimeout {
   211  						errorMsg := fmt.Sprintf("timed out while waiting for waypoint %v/%v", gw.Namespace, gw.Name)
   212  						if err != nil {
   213  							errorMsg += fmt.Sprintf(": %s", err)
   214  						}
   215  						return fmt.Errorf(errorMsg)
   216  					}
   217  				}
   218  			}
   219  			fmt.Fprintf(cmd.OutOrStdout(), "waypoint %v/%v applied\n", gw.Namespace, gw.Name)
   220  
   221  			// If a user decides to enroll their namespace with a waypoint, label the namespace with the waypoint name
   222  			// after the waypoint has been applied.
   223  			if enrollNamespace {
   224  				err = labelNamespaceWithWaypoint(kubeClient, ns)
   225  				if err != nil {
   226  					return fmt.Errorf("failed to label namespace with waypoint: %v", err)
   227  				}
   228  				fmt.Fprintf(cmd.OutOrStdout(), "namespace %v labeled with \"%v: %v\"\n", ctx.NamespaceOrDefault(ctx.Namespace()),
   229  					constants.AmbientUseWaypointLabel, gw.Name)
   230  			}
   231  			return nil
   232  		},
   233  	}
   234  	waypointApplyCmd.PersistentFlags().StringVar(&trafficType,
   235  		"for",
   236  		"",
   237  		fmt.Sprintf("Specify the traffic type %s for the waypoint", sets.SortedList(validTrafficTypes)),
   238  	)
   239  
   240  	waypointApplyCmd.PersistentFlags().BoolVarP(&enrollNamespace, "enroll-namespace", "", false,
   241  		"If set, the namespace will be labeled with the waypoint name")
   242  
   243  	waypointDeleteCmd := &cobra.Command{
   244  		Use:   "delete",
   245  		Short: "Delete a waypoint configuration",
   246  		Long:  "Delete a waypoint configuration from the cluster",
   247  		Example: `  # Delete a waypoint from the default namespace
   248    istioctl x waypoint delete
   249  
   250    # Delete a waypoint by name, which can obtain from istioctl x waypoint list
   251    istioctl x waypoint delete waypoint-name --namespace default
   252  
   253    # Delete several waypoints by name
   254    istioctl x waypoint delete waypoint-name1 waypoint-name2 --namespace default
   255  
   256    # Delete all waypoints in a specific namespace
   257    istioctl x waypoint delete --all --namespace default`,
   258  		Args: func(cmd *cobra.Command, args []string) error {
   259  			if deleteAll && len(args) > 0 {
   260  				return fmt.Errorf("cannot specify waypoint names when deleting all waypoints")
   261  			}
   262  			if !deleteAll && len(args) == 0 {
   263  				return fmt.Errorf("must either specify a waypoint name or delete all using --all")
   264  			}
   265  			return nil
   266  		},
   267  		RunE: func(cmd *cobra.Command, args []string) error {
   268  			kubeClient, err := ctx.CLIClient()
   269  			if err != nil {
   270  				return fmt.Errorf("failed to create Kubernetes client: %v", err)
   271  			}
   272  			ns := ctx.NamespaceOrDefault(ctx.Namespace())
   273  
   274  			// Delete all waypoints if the --all flag is set
   275  			if deleteAll {
   276  				return deleteWaypoints(cmd, kubeClient, ns, nil)
   277  			}
   278  
   279  			// Delete waypoints by names if provided
   280  			return deleteWaypoints(cmd, kubeClient, ns, args)
   281  		},
   282  	}
   283  	waypointDeleteCmd.Flags().BoolVar(&deleteAll, "all", false, "Delete all waypoints in the namespace")
   284  
   285  	waypointListCmd := &cobra.Command{
   286  		Use:   "list",
   287  		Short: "List managed waypoint configurations",
   288  		Long:  "List managed waypoint configurations in the cluster",
   289  		Example: `  # List all waypoints in a specific namespace
   290    istioctl x waypoint list --namespace default
   291  
   292    # List all waypoints in the cluster
   293    istioctl x waypoint list -A`,
   294  		RunE: func(cmd *cobra.Command, args []string) error {
   295  			writer := cmd.OutOrStdout()
   296  			kubeClient, err := ctx.CLIClient()
   297  			if err != nil {
   298  				return fmt.Errorf("failed to create Kubernetes client: %v", err)
   299  			}
   300  			var ns string
   301  			if allNamespaces {
   302  				ns = ""
   303  			} else {
   304  				ns = ctx.NamespaceOrDefault(ctx.Namespace())
   305  			}
   306  			gws, err := kubeClient.GatewayAPI().GatewayV1().Gateways(ns).
   307  				List(context.Background(), metav1.ListOptions{})
   308  			if err != nil {
   309  				return err
   310  			}
   311  			if len(gws.Items) == 0 {
   312  				fmt.Fprintln(writer, "No waypoints found.")
   313  				return nil
   314  			}
   315  			w := new(tabwriter.Writer).Init(writer, 0, 8, 5, ' ', 0)
   316  			slices.SortFunc(gws.Items, func(i, j gateway.Gateway) int {
   317  				if r := cmp.Compare(i.Namespace, j.Namespace); r != 0 {
   318  					return r
   319  				}
   320  				return cmp.Compare(i.Name, j.Name)
   321  			})
   322  			filteredGws := make([]gateway.Gateway, 0)
   323  			for _, gw := range gws.Items {
   324  				if gw.Spec.GatewayClassName != constants.WaypointGatewayClassName {
   325  					continue
   326  				}
   327  				filteredGws = append(filteredGws, gw)
   328  			}
   329  			if allNamespaces {
   330  				fmt.Fprintln(w, "NAMESPACE\tNAME\tREVISION\tPROGRAMMED")
   331  			} else {
   332  				fmt.Fprintln(w, "NAME\tREVISION\tPROGRAMMED")
   333  			}
   334  			for _, gw := range filteredGws {
   335  				programmed := kstatus.StatusFalse
   336  				rev := gw.Labels[label.IoIstioRev.Name]
   337  				if rev == "" {
   338  					rev = "default"
   339  				}
   340  				for _, cond := range gw.Status.Conditions {
   341  					if cond.Type == string(gateway.GatewayConditionProgrammed) {
   342  						programmed = string(cond.Status)
   343  					}
   344  				}
   345  				if allNamespaces {
   346  					_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", gw.Namespace, gw.Name, rev, programmed)
   347  				} else {
   348  					_, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", gw.Name, rev, programmed)
   349  				}
   350  			}
   351  			return w.Flush()
   352  		},
   353  	}
   354  	waypointListCmd.PersistentFlags().BoolVarP(&allNamespaces, "all-namespaces", "A", false, "List all waypoints in all namespaces")
   355  
   356  	waypointCmd := &cobra.Command{
   357  		Use:   "waypoint",
   358  		Short: "Manage waypoint configuration",
   359  		Long:  "A group of commands used to manage waypoint configuration",
   360  		Example: `  # Apply a waypoint to the current namespace
   361    istioctl x waypoint apply
   362  
   363    # Generate a waypoint as yaml
   364    istioctl x waypoint generate --namespace default
   365  
   366    # List all waypoints in a specific namespace
   367    istioctl x waypoint list --namespace default`,
   368  		Args: func(cmd *cobra.Command, args []string) error {
   369  			if len(args) != 0 {
   370  				return fmt.Errorf("unknown subcommand %q", args[0])
   371  			}
   372  			return nil
   373  		},
   374  		RunE: func(cmd *cobra.Command, args []string) error {
   375  			cmd.HelpFunc()(cmd, args)
   376  			return nil
   377  		},
   378  	}
   379  
   380  	waypointApplyCmd.PersistentFlags().StringVarP(&revision, "revision", "r", "", "The revision to label the waypoint with")
   381  	waypointApplyCmd.PersistentFlags().BoolVarP(&waitReady, "wait", "w", false, "Wait for the waypoint to be ready")
   382  	waypointCmd.AddCommand(waypointApplyCmd)
   383  	waypointGenerateCmd.PersistentFlags().StringVarP(&revision, "revision", "r", "", "The revision to label the waypoint with")
   384  	waypointCmd.AddCommand(waypointGenerateCmd)
   385  	waypointCmd.AddCommand(waypointDeleteCmd)
   386  	waypointCmd.AddCommand(waypointListCmd)
   387  	waypointCmd.PersistentFlags().StringVarP(&waypointName, "name", "", constants.DefaultNamespaceWaypoint, "name of the waypoint")
   388  
   389  	return waypointCmd
   390  }
   391  
   392  // deleteWaypoints handles the deletion of waypoints based on the provided names, or all if names is nil
   393  func deleteWaypoints(cmd *cobra.Command, kubeClient kube.CLIClient, namespace string, names []string) error {
   394  	var multiErr *multierror.Error
   395  	if names == nil {
   396  		// If names is nil, delete all waypoints
   397  		waypoints, err := kubeClient.GatewayAPI().GatewayV1().Gateways(namespace).
   398  			List(context.Background(), metav1.ListOptions{})
   399  		if err != nil {
   400  			return err
   401  		}
   402  		for _, gw := range waypoints.Items {
   403  			names = append(names, gw.Name)
   404  		}
   405  	}
   406  
   407  	var wg sync.WaitGroup
   408  	var mu sync.Mutex
   409  	for _, name := range names {
   410  		wg.Add(1)
   411  		go func(name string) {
   412  			defer wg.Done()
   413  			if err := kubeClient.GatewayAPI().GatewayV1().Gateways(namespace).
   414  				Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
   415  				if errors.IsNotFound(err) {
   416  					fmt.Fprintf(cmd.OutOrStdout(), "waypoint %v/%v not found\n", namespace, name)
   417  				} else {
   418  					mu.Lock()
   419  					multiErr = multierror.Append(multiErr, err)
   420  					mu.Unlock()
   421  				}
   422  			} else {
   423  				fmt.Fprintf(cmd.OutOrStdout(), "waypoint %v/%v deleted\n", namespace, name)
   424  			}
   425  		}(name)
   426  	}
   427  
   428  	wg.Wait()
   429  	return multiErr.ErrorOrNil()
   430  }
   431  
   432  func labelNamespaceWithWaypoint(kubeClient kube.CLIClient, ns string) error {
   433  	nsObj, err := kubeClient.Kube().CoreV1().Namespaces().Get(context.Background(), ns, metav1.GetOptions{})
   434  	if errors.IsNotFound(err) {
   435  		return fmt.Errorf("namespace: %s not found", ns)
   436  	} else if err != nil {
   437  		return fmt.Errorf("failed to get namespace %s: %v", ns, err)
   438  	}
   439  	if nsObj.Labels == nil {
   440  		nsObj.Labels = map[string]string{}
   441  	}
   442  	nsObj.Labels[constants.AmbientUseWaypointLabel] = waypointName
   443  	if _, err := kubeClient.Kube().CoreV1().Namespaces().Update(context.Background(), nsObj, metav1.UpdateOptions{}); err != nil {
   444  		return fmt.Errorf("failed to update namespace %s: %v", ns, err)
   445  	}
   446  	return nil
   447  }
   448  
   449  func namespaceIsLabeledAmbient(kubeClient kube.CLIClient, ns string) (bool, error) {
   450  	nsObj, err := kubeClient.Kube().CoreV1().Namespaces().Get(context.Background(), ns, metav1.GetOptions{})
   451  	if errors.IsNotFound(err) {
   452  		return false, fmt.Errorf("namespace: %s not found", ns)
   453  	} else if err != nil {
   454  		return false, fmt.Errorf("failed to get namespace %s: %v", ns, err)
   455  	}
   456  	if nsObj.Labels == nil {
   457  		return false, nil
   458  	}
   459  	return nsObj.Labels[constants.DataplaneModeLabel] == constants.DataplaneModeAmbient, nil
   460  }