istioctl/cmd/add-to-mesh.go (603 lines of code) (raw):

// Copyright Istio Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cmd import ( "context" "fmt" "io" "os" "strconv" "strings" ) import ( "github.com/hashicorp/go-multierror" "github.com/spf13/cobra" meshconfig "istio.io/api/mesh/v1alpha1" "istio.io/api/networking/v1alpha3" "istio.io/pkg/log" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" k8s_labels "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "sigs.k8s.io/yaml" ) import ( "github.com/apache/dubbo-go-pixiu/istioctl/pkg/clioptions" "github.com/apache/dubbo-go-pixiu/istioctl/pkg/util/handlers" "github.com/apache/dubbo-go-pixiu/pilot/pkg/model" "github.com/apache/dubbo-go-pixiu/pkg/config/constants" "github.com/apache/dubbo-go-pixiu/pkg/config/mesh" istioProtocol "github.com/apache/dubbo-go-pixiu/pkg/config/protocol" "github.com/apache/dubbo-go-pixiu/pkg/config/schema/collections" "github.com/apache/dubbo-go-pixiu/pkg/kube" "github.com/apache/dubbo-go-pixiu/pkg/kube/inject" "github.com/apache/dubbo-go-pixiu/pkg/url" ) var crdFactory = createDynamicInterface // For most common ports allow the protocol to be guessed, this isn't meant // to replace /etc/services. Fully qualified proto[-extra]:port is the // recommended usage. var portsToName = map[int32]string{ 80: "http", 443: "https", 3306: "mysql", 8080: "http", } // vmServiceOpts contains the options of a mesh expansion service running on VM. type vmServiceOpts struct { Name string Namespace string ServiceAccount string IP []string PortList model.PortList Labels map[string]string Annotations map[string]string } func addToMeshCmd() *cobra.Command { addToMeshCmd := &cobra.Command{ Use: "add-to-mesh", Aliases: []string{"add"}, Short: "Add workloads into Istio service mesh", Long: `'istioctl experimental add-to-mesh' restarts pods with an Istio sidecar or configures meshed pod access to external services. Use 'add-to-mesh' as an alternate to namespace-wide auto injection for troubleshooting compatibility. The 'remove-from-mesh' command can be used to restart with the sidecar removed.`, Example: ` # Restart all productpage pods with an Istio sidecar istioctl experimental add-to-mesh service productpage # Restart just pods from the productpage-v1 deployment istioctl experimental add-to-mesh deployment productpage-v1 # Restart just pods from the details-v1 deployment istioctl x add deployment details-v1 # Control how meshed pods see an external service istioctl experimental add-to-mesh external-service vmhttp 172.12.23.125,172.12.23.126 \ http:9080 tcp:8888 --labels app=test,version=v1 --annotations env=stage --serviceaccount stageAdmin`, Args: func(cmd *cobra.Command, args []string) error { if len(args) != 0 { return fmt.Errorf("unknown resource type %q", args[0]) } return nil }, RunE: func(cmd *cobra.Command, args []string) error { cmd.HelpFunc()(cmd, args) return nil }, } addToMeshCmd.AddCommand(svcMeshifyCmd()) addToMeshCmd.AddCommand(deploymentMeshifyCmd()) externalSvcMeshifyCmd := externalSvcMeshifyCmd() hideInheritedFlags(externalSvcMeshifyCmd, "meshConfigFile", "meshConfigMapName", "injectConfigFile", "injectConfigMapName", "valuesFile") addToMeshCmd.AddCommand(externalSvcMeshifyCmd) addToMeshCmd.PersistentFlags().StringVar(&meshConfigFile, "meshConfigFile", "", "Mesh configuration filename. Takes precedence over --meshConfigMapName if set") addToMeshCmd.PersistentFlags().StringVar(&injectConfigFile, "injectConfigFile", "", "Injection configuration filename. Cannot be used with --injectConfigMapName") addToMeshCmd.PersistentFlags().StringVar(&valuesFile, "valuesFile", "", "Injection values configuration filename.") addToMeshCmd.PersistentFlags().StringVar(&meshConfigMapName, "meshConfigMapName", defaultMeshConfigMapName, fmt.Sprintf("ConfigMap name for Istio mesh configuration, key should be %q", configMapKey)) addToMeshCmd.PersistentFlags().StringVar(&injectConfigMapName, "injectConfigMapName", defaultInjectConfigMapName, fmt.Sprintf("ConfigMap name for Istio sidecar injection, key should be %q.", injectConfigMapKey)) addToMeshCmd.Long += "\n\n" + ExperimentalMsg return addToMeshCmd } func deploymentMeshifyCmd() *cobra.Command { var opts clioptions.ControlPlaneOptions cmd := &cobra.Command{ Use: "deployment <deployment>", Aliases: []string{"deploy", "dep"}, Short: "Add deployment to Istio service mesh", // nolint: lll Long: `'istioctl experimental add-to-mesh deployment' restarts pods with the Istio sidecar. Use 'add-to-mesh' to test deployments for compatibility with Istio. It can be used instead of namespace-wide auto-injection of sidecars and is especially helpful for compatibility testing. If your deployment does not function after using 'add-to-mesh' you must re-deploy it and troubleshoot it for Istio compatibility. See ` + url.DeploymentRequirements + ` See also 'istioctl experimental remove-from-mesh deployment' which does the reverse.`, Example: ` # Restart pods from the productpage-v1 deployment with Istio sidecar istioctl experimental add-to-mesh deployment productpage-v1 # Restart pods from the details-v1 deployment with Istio sidecar istioctl x add-to-mesh deploy details-v1 # Restart pods from the ratings-v1 deployment with Istio sidecar istioctl x add dep ratings-v1`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { return fmt.Errorf("expecting deployment name") } client, err := interfaceFactory(kubeconfig) if err != nil { return err } ns := handlers.HandleNamespace(namespace, defaultNamespace) writer := cmd.OutOrStdout() var valuesConfig string var sidecarTemplate inject.RawTemplates meshConfig, err := setupParameters(&sidecarTemplate, &valuesConfig, opts.Revision) if err != nil { return err } dep, err := client.AppsV1().Deployments(ns).Get(context.TODO(), args[0], metav1.GetOptions{}) if err != nil { return fmt.Errorf("deployment %q does not exist", args[0]) } return injectSideCarIntoDeployment(client, dep, sidecarTemplate, valuesConfig, args[0], ns, opts.Revision, meshConfig, writer, func(warning string) { fmt.Fprintln(cmd.ErrOrStderr(), warning) }) }, } cmd.Long += "\n\n" + ExperimentalMsg opts.AttachControlPlaneFlags(cmd) return cmd } func svcMeshifyCmd() *cobra.Command { var opts clioptions.ControlPlaneOptions cmd := &cobra.Command{ Use: "service <service>", Aliases: []string{"svc"}, Short: "Add Service to Istio service mesh", // nolint: lll Long: `istioctl experimental add-to-mesh service restarts pods with the Istio sidecar. Use 'add-to-mesh' to test deployments for compatibility with Istio. It can be used instead of namespace-wide auto-injection of sidecars and is especially helpful for compatibility testing. If your service does not function after using 'add-to-mesh' you must re-deploy it and troubleshoot it for Istio compatibility. See ` + url.DeploymentRequirements + ` See also 'istioctl experimental remove-from-mesh service' which does the reverse.`, Example: ` # Restart all productpage pods with an Istio sidecar istioctl experimental add-to-mesh service productpage # Restart all details-v1 pods with an Istio sidecar istioctl x add-to-mesh svc details-v1 # Restart all ratings-v1 pods with an Istio sidecar istioctl x add svc ratings-v1`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { return fmt.Errorf("expecting service name") } client, err := interfaceFactory(kubeconfig) if err != nil { return err } ns := handlers.HandleNamespace(namespace, defaultNamespace) writer := cmd.OutOrStdout() var valuesConfig string var sidecarTemplate inject.RawTemplates meshConfig, err := setupParameters(&sidecarTemplate, &valuesConfig, opts.Revision) if err != nil { return err } matchingDeployments, err := findDeploymentsForSvc(client, ns, args[0]) if err != nil { return err } if len(matchingDeployments) == 0 { _, _ = fmt.Fprintf(writer, "No deployments found for service %s.%s\n", args[0], ns) return nil } return injectSideCarIntoDeployments(client, matchingDeployments, sidecarTemplate, valuesConfig, args[0], ns, opts.Revision, meshConfig, writer, func(warning string) { fmt.Fprintln(cmd.ErrOrStderr(), warning) }) }, } cmd.Long += "\n\n" + ExperimentalMsg opts.AttachControlPlaneFlags(cmd) return cmd } func injectSideCarIntoDeployments(client kubernetes.Interface, deps []appsv1.Deployment, sidecarTemplate inject.RawTemplates, valuesConfig, name, namespace string, revision string, meshConfig *meshconfig.MeshConfig, writer io.Writer, warningHandler func(string)) error { var errs error for _, dep := range deps { err := injectSideCarIntoDeployment(client, &dep, sidecarTemplate, valuesConfig, name, namespace, revision, meshConfig, writer, warningHandler) if err != nil { errs = multierror.Append(errs, err) } } return errs } func externalSvcMeshifyCmd() *cobra.Command { cmd := &cobra.Command{ Use: "external-service <svcname> <ip> [name1:]port1 [[name2:]port2] ...", Aliases: []string{"es"}, Short: "Add external service (e.g. services running on a VM) to Istio service mesh", Long: `istioctl experimental add-to-mesh external-service create a ServiceEntry and a Service without selector for the specified external service in Istio service mesh. The typical usage scenario is Mesh Expansion on VMs. See also 'istioctl experimental remove-from-mesh external-service' which does the reverse.`, Example: ` # Control how meshed pods contact 172.12.23.125 and .126 istioctl experimental add-to-mesh external-service vmhttp 172.12.23.125,172.12.23.126 \ http:9080 tcp:8888 --labels app=test,version=v1 --annotations env=stage --serviceaccount stageAdmin`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) < 3 { return fmt.Errorf("provide service name, IP and Port List") } client, err := interfaceFactory(kubeconfig) if err != nil { return err } seClient, err := crdFactory(kubeconfig) if err != nil { return err } writer := cmd.OutOrStdout() ns := handlers.HandleNamespace(namespace, defaultNamespace) _, err = client.CoreV1().Services(ns).Get(context.TODO(), args[0], metav1.GetOptions{}) if err != nil { return addServiceOnVMToMesh(seClient, client, ns, args, resourceLabels, annotations, svcAcctAnn, writer) } return fmt.Errorf("service %q already exists, skip", args[0]) }, } cmd.PersistentFlags().StringSliceVarP(&resourceLabels, "labels", "l", nil, "List of labels to apply if creating a service/endpoint; e.g. -l env=prod,vers=2") cmd.PersistentFlags().StringSliceVarP(&annotations, "annotations", "a", nil, "List of string annotations to apply if creating a service/endpoint; e.g. -a foo=bar,x=y") cmd.PersistentFlags().StringVarP(&svcAcctAnn, "serviceaccount", "s", "default", "Service account to link to the service") cmd.Long += "\n\n" + ExperimentalMsg return cmd } func setupParameters(sidecarTemplate *inject.RawTemplates, valuesConfig *string, revision string) (*meshconfig.MeshConfig, error) { var meshConfig *meshconfig.MeshConfig var err error injectConfigMapName = defaultInjectWebhookConfigName if meshConfigFile != "" { if meshConfig, err = mesh.ReadMeshConfig(meshConfigFile); err != nil { return nil, err } } else { if meshConfig, err = getMeshConfigFromConfigMap(kubeconfig, "add-to-mesh", revision); err != nil { return nil, err } } if injectConfigFile != "" { injectionConfig, err := os.ReadFile(injectConfigFile) // nolint: vetshadow if err != nil { return nil, err } injectConfig, err := readInjectConfigFile(injectionConfig) if err != nil { return nil, multierror.Append(err, fmt.Errorf("loading --injectConfigFile")) } *sidecarTemplate = injectConfig } else if *sidecarTemplate, err = getInjectConfigFromConfigMap(kubeconfig, revision); err != nil { return nil, err } if valuesFile != "" { valuesConfigBytes, err := os.ReadFile(valuesFile) // nolint: vetshadow if err != nil { return nil, err } *valuesConfig = string(valuesConfigBytes) } else if *valuesConfig, err = getValuesFromConfigMap(kubeconfig, revision); err != nil { return nil, err } return meshConfig, err } func injectSideCarIntoDeployment(client kubernetes.Interface, dep *appsv1.Deployment, sidecarTemplate inject.RawTemplates, valuesConfig, svcName, svcNamespace string, revision string, meshConfig *meshconfig.MeshConfig, writer io.Writer, warningHandler func(string)) error { var errs error log.Debugf("updating deployment %s.%s with Istio sidecar injected", dep.Name, dep.Namespace) templs, err := inject.ParseTemplates(sidecarTemplate) if err != nil { return err } vc, err := inject.NewValuesConfig(valuesConfig) if err != nil { return err } newDep, err := inject.IntoObject(nil, templs, vc, revision, meshConfig, dep, warningHandler) if err != nil { errs = multierror.Append(errs, fmt.Errorf("failed to inject sidecar to deployment resource %s.%s for service %s.%s due to %v", dep.Name, dep.Namespace, svcName, svcNamespace, err)) return errs } res, b := newDep.(*appsv1.Deployment) if !b { errs = multierror.Append(errs, fmt.Errorf("failed to create new deployment resource %s.%s for service %s.%s due to %v", dep.Name, dep.Namespace, svcName, svcNamespace, err)) return errs } if _, err = client.AppsV1().Deployments(svcNamespace).Update(context.TODO(), res, metav1.UpdateOptions{}); err != nil { errs = multierror.Append(errs, fmt.Errorf("failed to update deployment %s.%s for service %s.%s due to %v", dep.Name, dep.Namespace, svcName, svcNamespace, err)) return errs } d := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: dep.Name, Namespace: dep.Namespace, UID: dep.UID, OwnerReferences: dep.OwnerReferences, }, } if _, err = client.AppsV1().Deployments(svcNamespace).UpdateStatus(context.TODO(), d, metav1.UpdateOptions{}); err != nil { errs = multierror.Append(errs, fmt.Errorf("failed to update deployment status %s.%s for service %s.%s due to %v", dep.Name, dep.Namespace, svcName, svcNamespace, err)) return errs } _, _ = fmt.Fprintf(writer, "deployment %s.%s updated successfully with Istio sidecar injected.\n"+ "Next Step: Add related labels to the deployment to align with Istio's requirement: %s\n", dep.Name, dep.Namespace, url.DeploymentRequirements) return errs } func findDeploymentsForSvc(client kubernetes.Interface, ns, name string) ([]appsv1.Deployment, error) { svc, err := client.CoreV1().Services(ns).Get(context.TODO(), name, metav1.GetOptions{}) if err != nil { return nil, err } svcSelector := k8s_labels.SelectorFromSet(svc.Spec.Selector) if svcSelector.Empty() { return nil, nil } deployments, err := client.AppsV1().Deployments(ns).List(context.TODO(), metav1.ListOptions{}) if err != nil { return nil, err } deps := make([]appsv1.Deployment, 0, len(deployments.Items)) for _, dep := range deployments.Items { depLabels := k8s_labels.Set(dep.Spec.Selector.MatchLabels) if svcSelector.Matches(depLabels) { deps = append(deps, dep) } } return deps, nil } func createDynamicInterface(kubeconfig string) (dynamic.Interface, error) { restConfig, err := kube.BuildClientConfig(kubeconfig, configContext) if err != nil { return nil, err } dynamicClient, err := dynamic.NewForConfig(restConfig) if err != nil { return nil, err } return dynamicClient, nil } func convertPortList(ports []string) (model.PortList, error) { portList := model.PortList{} for _, p := range ports { np, err := str2NamedPort(p) if err != nil { return nil, fmt.Errorf("invalid port format %v", p) } protocol := istioProtocol.Parse(np.name) if protocol == istioProtocol.Unsupported { return nil, fmt.Errorf("protocol %s is not supported by Istio", np.name) } portList = append(portList, &model.Port{ Port: int(np.port), Protocol: protocol, Name: np.name + "-" + strconv.Itoa(int(np.port)), }) } return portList, nil } // namedPort defines the Port and Name tuple needed for services and endpoints. type namedPort struct { port int32 name string } // str2NamedPort parses a proto:port string into a namePort struct. func str2NamedPort(str string) (namedPort, error) { var r namedPort idx := strings.Index(str, ":") if idx >= 0 { r.name = str[:idx] str = str[idx+1:] } p, err := strconv.Atoi(str) if err != nil { return r, err } r.port = int32(p) if len(r.name) == 0 { name, found := portsToName[r.port] r.name = name if !found { r.name = str } } return r, nil } // addServiceOnVMToMesh adds a service running on VM into Istio service mesh func addServiceOnVMToMesh(dynamicClient dynamic.Interface, client kubernetes.Interface, ns string, args, l, a []string, svcAcctAnn string, writer io.Writer) error { svcName := args[0] ips := strings.Split(args[1], ",") portsListStr := args[2:] ports, err := convertPortList(portsListStr) if err != nil { return err } labels := convertToStringMap(l) annotations := convertToStringMap(a) opts := &vmServiceOpts{ Name: svcName, Namespace: ns, PortList: ports, IP: ips, ServiceAccount: svcAcctAnn, Labels: labels, Annotations: annotations, } u := &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": collections.IstioNetworkingV1Alpha3Serviceentries.Resource().APIVersion(), "kind": collections.IstioNetworkingV1Alpha3Serviceentries.Resource().Kind(), "metadata": map[string]interface{}{ "namespace": opts.Namespace, "name": resourceName(opts.Name), }, }, } annotations[corev1.ServiceAccountNameKey] = opts.ServiceAccount s := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: opts.Name, Namespace: opts.Namespace, Annotations: annotations, Labels: labels, }, } // Pre-check Kubernetes service and service entry does not exist. _, err = client.CoreV1().Services(ns).Get(context.TODO(), opts.Name, metav1.GetOptions{}) if err == nil { return fmt.Errorf("service %q already exists, skip", opts.Name) } serviceEntryGVR := schema.GroupVersionResource{ Group: collections.IstioNetworkingV1Alpha3Serviceentries.Resource().Group(), Version: collections.IstioNetworkingV1Alpha3Serviceentries.Resource().Version(), Resource: collections.IstioNetworkingV1Alpha3Serviceentries.Resource().Plural(), } _, err = dynamicClient.Resource(serviceEntryGVR).Namespace(ns).Get(context.TODO(), resourceName(opts.Name), metav1.GetOptions{}) if err == nil { return fmt.Errorf("service entry %q already exists, skip", resourceName(opts.Name)) } if err = generateServiceEntry(u, opts); err != nil { return err } generateK8sService(s, opts) if err = createServiceEntry(dynamicClient, ns, u, opts.Name, writer); err != nil { return err } return createK8sService(client, ns, s, writer) } func generateServiceEntry(u *unstructured.Unstructured, o *vmServiceOpts) error { if o == nil { return fmt.Errorf("empty vm service options") } ports := make([]*v1alpha3.Port, 0, len(o.PortList)) for _, p := range o.PortList { ports = append(ports, &v1alpha3.Port{ Number: uint32(p.Port), Protocol: string(p.Protocol), Name: p.Name, }) } eps := make([]*v1alpha3.WorkloadEntry, 0, len(o.IP)) for _, ip := range o.IP { eps = append(eps, &v1alpha3.WorkloadEntry{ Address: ip, Labels: o.Labels, }) } host := fmt.Sprintf("%v.%v.svc.%s", o.Name, o.Namespace, constants.DefaultKubernetesDomain) spec := &v1alpha3.ServiceEntry{ Hosts: []string{host}, Ports: ports, Endpoints: eps, Resolution: v1alpha3.ServiceEntry_STATIC, Location: v1alpha3.ServiceEntry_MESH_INTERNAL, } iSpec, err := unstructureIstioType(spec) if err != nil { return err } u.Object["spec"] = iSpec return nil } // Because we are placing into an Unstructured, place as a map instead // of structured Istio types. (The go-client can handle the structured data, but the // fake go-client used for mocking cannot.) func unstructureIstioType(spec interface{}) (map[string]interface{}, error) { b, err := yaml.Marshal(spec) if err != nil { return nil, err } iSpec := map[string]interface{}{} err = yaml.Unmarshal(b, &iSpec) if err != nil { return nil, err } return iSpec, nil } func resourceName(hostShortName string) string { return fmt.Sprintf("mesh-expansion-%v", hostShortName) } func generateK8sService(s *corev1.Service, o *vmServiceOpts) { ports := make([]corev1.ServicePort, 0, len(o.PortList)) for _, p := range o.PortList { ports = append(ports, corev1.ServicePort{ Name: strings.ToLower(p.Name), Port: int32(p.Port), }) } spec := corev1.ServiceSpec{ Ports: ports, } s.Spec = spec } func convertToUnsignedInt32Map(s []string) map[string]uint32 { out := make(map[string]uint32, len(s)) for _, l := range s { k, v := splitEqual(l) u64, err := strconv.ParseUint(v, 10, 32) if err != nil { log.Errorf("failed to convert to uint32: %v", err) } out[k] = uint32(u64) } return out } func convertToStringMap(s []string) map[string]string { out := make(map[string]string, len(s)) for _, l := range s { k, v := splitEqual(l) out[k] = v } return out } // splitEqual splits key=value string into key,value. if no = is found // the whole string is the key and value is empty. func splitEqual(str string) (string, string) { idx := strings.Index(str, "=") var k string var v string if idx >= 0 { k = str[:idx] v = str[idx+1:] } else { k = str } return k, v } // createK8sService creates k8s service object for external services in order for DNS query and cluster VIP. func createK8sService(client kubernetes.Interface, ns string, svc *corev1.Service, writer io.Writer) error { if svc == nil { return fmt.Errorf("failed to create vm service") } if _, err := client.CoreV1().Services(ns).Create(context.TODO(), svc, metav1.CreateOptions{}); err != nil { return fmt.Errorf("failed to create kubernetes service %v", err) } if _, err := client.CoreV1().Services(ns).UpdateStatus(context.TODO(), svc, metav1.UpdateOptions{}); err != nil { return fmt.Errorf("failed to create kubernetes service %v", err) } sName := strings.Join([]string{svc.Name, svc.Namespace}, ".") _, _ = fmt.Fprintf(writer, "Kubernetes Service %q has been created in the Istio service mesh"+ " for the external service %q\n", sName, svc.Name) return nil } // createServiceEntry creates an Istio ServiceEntry object in order to register vm service. func createServiceEntry(dynamicClient dynamic.Interface, ns string, u *unstructured.Unstructured, name string, writer io.Writer) error { if u == nil { return fmt.Errorf("failed to create vm service") } serviceEntryGVR := schema.GroupVersionResource{ Group: collections.IstioNetworkingV1Alpha3Serviceentries.Resource().Group(), Version: collections.IstioNetworkingV1Alpha3Serviceentries.Resource().Version(), Resource: collections.IstioNetworkingV1Alpha3Serviceentries.Resource().Plural(), } _, err := dynamicClient.Resource(serviceEntryGVR).Namespace(ns).Create(context.TODO(), u, metav1.CreateOptions{}) if err != nil { return fmt.Errorf("failed to create service entry %v", err) } seName := strings.Join([]string{u.GetName(), u.GetNamespace()}, ".") _, _ = fmt.Fprintf(writer, "ServiceEntry %q has been created in the Istio service mesh"+ " for the external service %q\n", seName, name) return nil }