istio.io/istio@v0.0.0-20240520182934-d79c90f27776/operator/cmd/mesh/manifest_shared_test.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 mesh
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"errors"
    21  	"fmt"
    22  	"os"
    23  	"path/filepath"
    24  	"strings"
    25  
    26  	kerrors "k8s.io/apimachinery/pkg/api/errors"
    27  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    28  	"k8s.io/apimachinery/pkg/runtime/schema"
    29  	"k8s.io/apimachinery/pkg/types"
    30  	"k8s.io/client-go/kubernetes"
    31  	"k8s.io/client-go/kubernetes/scheme"
    32  	"sigs.k8s.io/controller-runtime/pkg/client"
    33  	"sigs.k8s.io/controller-runtime/pkg/client/fake"
    34  	"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
    35  	"sigs.k8s.io/controller-runtime/pkg/envtest"
    36  
    37  	"istio.io/istio/istioctl/pkg/cli"
    38  	"istio.io/istio/operator/pkg/apis/istio/v1alpha1"
    39  	"istio.io/istio/operator/pkg/cache"
    40  	"istio.io/istio/operator/pkg/helmreconciler"
    41  	"istio.io/istio/operator/pkg/manifest"
    42  	"istio.io/istio/operator/pkg/name"
    43  	"istio.io/istio/operator/pkg/object"
    44  	"istio.io/istio/operator/pkg/util/clog"
    45  	"istio.io/istio/pkg/config/constants"
    46  	"istio.io/istio/pkg/kube"
    47  	"istio.io/istio/pkg/log"
    48  )
    49  
    50  // cmdType is one of the commands used to generate and optionally apply a manifest.
    51  type cmdType string
    52  
    53  const (
    54  	// istioctl manifest generate
    55  	cmdGenerate cmdType = "istioctl manifest generate"
    56  	// istioctl install
    57  	cmdApply cmdType = "istioctl install"
    58  	// in-cluster controller
    59  	cmdController cmdType = "operator controller"
    60  )
    61  
    62  // Golden output files add a lot of noise to pull requests. Use a unique suffix so
    63  // we can hide them by default. This should match one of the `linuguist-generated=true`
    64  // lines in istio.io/istio/.gitattributes.
    65  const (
    66  	goldenFileSuffixHideChangesInReview = ".golden.yaml"
    67  	goldenFileSuffixShowChangesInReview = ".golden-show-in-gh-pull-request.yaml"
    68  )
    69  
    70  var (
    71  	// By default, tests only run with manifest generate, since it doesn't require any external fake test environment.
    72  	testedManifestCmds = []cmdType{cmdGenerate}
    73  	// Only used if kubebuilder is installed.
    74  	testenv    *envtest.Environment
    75  	testClient client.Client
    76  
    77  	allNamespacedGVKs = append(helmreconciler.NamespacedResources(),
    78  		schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Endpoints"})
    79  	// CRDs are not in the prune list, but must be considered for tests.
    80  	allClusterGVKs = helmreconciler.ClusterResources
    81  )
    82  
    83  func init() {
    84  	if kubeBuilderInstalled() {
    85  		// TestMode is required to not wait in the go client for resources that will never be created in the test server.
    86  		helmreconciler.TestMode = true
    87  		// Add install and controller to the list of commands to run tests against.
    88  		testedManifestCmds = append(testedManifestCmds, cmdApply, cmdController)
    89  	}
    90  }
    91  
    92  // recreateTestEnv (re)creates a kubebuilder fake API server environment. This is required for testing of the
    93  // controller runtime, which is used in the operator.
    94  func recreateTestEnv() error {
    95  	// If kubebuilder is installed, use that test env for apply and controller testing.
    96  	log.Infof("Recreating kubebuilder test environment\n")
    97  
    98  	if testenv != nil {
    99  		testenv.Stop()
   100  	}
   101  
   102  	var err error
   103  	testenv = &envtest.Environment{}
   104  	testRestConfig, err := testenv.Start()
   105  	if err != nil {
   106  		return err
   107  	}
   108  
   109  	_, err = kubernetes.NewForConfig(testRestConfig)
   110  	testRestConfig.QPS = 50
   111  	testRestConfig.Burst = 100
   112  	if err != nil {
   113  		return err
   114  	}
   115  
   116  	s := scheme.Scheme
   117  	s.AddKnownTypes(v1alpha1.SchemeGroupVersion, &v1alpha1.IstioOperator{})
   118  
   119  	testClient, err = client.New(testRestConfig, client.Options{Scheme: s})
   120  	if err != nil {
   121  		return err
   122  	}
   123  	return nil
   124  }
   125  
   126  var interceptorFunc = interceptor.Funcs{Patch: func(
   127  	ctx context.Context,
   128  	clnt client.WithWatch,
   129  	obj client.Object,
   130  	patch client.Patch,
   131  	opts ...client.PatchOption,
   132  ) error {
   133  	// Apply patches are supposed to upsert, but fake client fails if the object doesn't exist,
   134  	// if an apply patch occurs for an object that doesn't yet exist, create it.
   135  	if patch.Type() != types.ApplyPatchType {
   136  		return clnt.Patch(ctx, obj, patch, opts...)
   137  	}
   138  	check, ok := obj.DeepCopyObject().(client.Object)
   139  	if !ok {
   140  		return errors.New("could not check for object in fake client")
   141  	}
   142  	if err := clnt.Get(ctx, client.ObjectKeyFromObject(obj), check); kerrors.IsNotFound(err) {
   143  		if err := clnt.Create(ctx, check); err != nil {
   144  			return fmt.Errorf("could not inject object creation for fake: %w", err)
   145  		}
   146  	} else if err != nil {
   147  		return err
   148  	}
   149  	obj.SetResourceVersion(check.GetResourceVersion())
   150  	return clnt.Update(ctx, obj)
   151  }}
   152  
   153  // recreateSimpleTestEnv mocks fake kube api server which relies on a simple object tracker
   154  func recreateSimpleTestEnv() {
   155  	log.Infof("Creating simple test environment\n")
   156  	helmreconciler.TestMode = true
   157  	s := scheme.Scheme
   158  	s.AddKnownTypes(v1alpha1.SchemeGroupVersion, &v1alpha1.IstioOperator{})
   159  
   160  	testClient = fake.NewClientBuilder().WithScheme(s).WithInterceptorFuncs(interceptorFunc).Build()
   161  }
   162  
   163  // runManifestCommands runs all testedManifestCmds commands with the given input IOP file, flags and chartSource.
   164  // It returns an ObjectSet for each cmd type.
   165  // nolint: unparam
   166  func runManifestCommands(inFile, flags string, chartSource chartSourceType, fileSelect []string) (map[cmdType]*ObjectSet, error) {
   167  	out := make(map[cmdType]*ObjectSet)
   168  	for _, cmd := range testedManifestCmds {
   169  		log.Infof("\nRunning test command using %s\n", cmd)
   170  		switch cmd {
   171  		case cmdApply, cmdController:
   172  			if err := cleanTestCluster(); err != nil {
   173  				return nil, err
   174  			}
   175  			if err := fakeApplyExtraResources(inFile); err != nil {
   176  				return nil, err
   177  			}
   178  		default:
   179  		}
   180  
   181  		var objs *ObjectSet
   182  		var err error
   183  		switch cmd {
   184  		case cmdGenerate:
   185  			m, _, err := generateManifest(inFile, flags, chartSource, fileSelect)
   186  			if err != nil {
   187  				return nil, err
   188  			}
   189  			objs, err = parseObjectSetFromManifest(m)
   190  			if err != nil {
   191  				return nil, err
   192  			}
   193  		case cmdApply:
   194  			objs, err = fakeApplyManifest(inFile, flags, chartSource)
   195  		case cmdController:
   196  			objs, err = fakeControllerReconcile(inFile, chartSource, nil)
   197  		default:
   198  		}
   199  		if err != nil {
   200  			return nil, err
   201  		}
   202  		out[cmd] = objs
   203  	}
   204  
   205  	return out, nil
   206  }
   207  
   208  // fakeApplyManifest runs istioctl install.
   209  func fakeApplyManifest(inFile, flags string, chartSource chartSourceType) (*ObjectSet, error) {
   210  	inPath := filepath.Join(testDataDir, "input", inFile+".yaml")
   211  	manifest, err := runManifestCommand("install", []string{inPath}, flags, chartSource, nil)
   212  	if err != nil {
   213  		return nil, fmt.Errorf("error %s: %s", err, manifest)
   214  	}
   215  	return NewObjectSet(getAllIstioObjects()), nil
   216  }
   217  
   218  // fakeApplyExtraResources applies any extra resources for the given test name.
   219  func fakeApplyExtraResources(inFile string) error {
   220  	reconciler, err := helmreconciler.NewHelmReconciler(testClient, nil, nil, nil)
   221  	if err != nil {
   222  		return err
   223  	}
   224  
   225  	if rs, err := readFile(filepath.Join(testDataDir, "input-extra-resources", inFile+".yaml")); err == nil {
   226  		if err := applyWithReconciler(reconciler, rs); err != nil {
   227  			return err
   228  		}
   229  	}
   230  	return nil
   231  }
   232  
   233  func fakeControllerReconcile(inFile string, chartSource chartSourceType, opts *helmreconciler.Options) (*ObjectSet, error) {
   234  	c := kube.NewFakeClientWithVersion("25")
   235  	l := clog.NewDefaultLogger()
   236  	_, iop, err := manifest.GenerateConfig(
   237  		[]string{inFileAbsolutePath(inFile)},
   238  		[]string{"installPackagePath=" + string(chartSource)},
   239  		false, c, l)
   240  	if err != nil {
   241  		return nil, err
   242  	}
   243  
   244  	iop.Spec.InstallPackagePath = string(chartSource)
   245  
   246  	reconciler, err := helmreconciler.NewHelmReconciler(testClient, c, iop, opts)
   247  	if err != nil {
   248  		return nil, err
   249  	}
   250  	if err := fakeInstallOperator(reconciler, chartSource); err != nil {
   251  		return nil, err
   252  	}
   253  
   254  	if _, err := reconciler.Reconcile(); err != nil {
   255  		return nil, err
   256  	}
   257  
   258  	return NewObjectSet(getAllIstioObjects()), nil
   259  }
   260  
   261  // fakeInstallOperator installs the operator manifest resources into a cluster using the given reconciler.
   262  // The installation is for testing with a kubebuilder fake cluster only, since no functional Deployment will be
   263  // created.
   264  func fakeInstallOperator(reconciler *helmreconciler.HelmReconciler, chartSource chartSourceType) error {
   265  	ocArgs := &operatorCommonArgs{
   266  		manifestsPath:     string(chartSource),
   267  		istioNamespace:    constants.IstioSystemNamespace,
   268  		watchedNamespaces: constants.IstioSystemNamespace,
   269  		operatorNamespace: operatorDefaultNamespace,
   270  		// placeholders, since the fake API server does not actually pull images and create pods.
   271  		hub: "fake hub",
   272  		tag: "fake tag",
   273  	}
   274  
   275  	_, mstr, err := renderOperatorManifest(nil, ocArgs)
   276  	if err != nil {
   277  		return err
   278  	}
   279  	if err := applyWithReconciler(reconciler, mstr); err != nil {
   280  		return err
   281  	}
   282  
   283  	return nil
   284  }
   285  
   286  // applyWithReconciler applies the given manifest string using the given reconciler.
   287  func applyWithReconciler(reconciler *helmreconciler.HelmReconciler, manifest string) error {
   288  	m := name.Manifest{
   289  		// Name is not important here, only Content will be applied.
   290  		Name:    name.IstioOperatorComponentName,
   291  		Content: manifest,
   292  	}
   293  	_, err := reconciler.ApplyManifest(m)
   294  	return err
   295  }
   296  
   297  // runManifestCommand runs the given manifest command. If filenames is set, passes the given filenames as -f flag,
   298  // flags is passed to the command verbatim. If you set both flags and path, make sure to not use -f in flags.
   299  func runManifestCommand(command string, filenames []string, flags string, chartSource chartSourceType, fileSelect []string) (string, error) {
   300  	var args string
   301  	if command == "install" {
   302  		args = "install"
   303  	} else {
   304  		args = "manifest " + command
   305  	}
   306  	for _, f := range filenames {
   307  		args += " -f " + f
   308  	}
   309  	if flags != "" {
   310  		args += " " + flags
   311  	}
   312  	if fileSelect != nil {
   313  		filters := []string{}
   314  		filters = append(filters, fileSelect...)
   315  		// Everything needs these
   316  		filters = append(filters, "templates/_affinity.tpl", "templates/_helpers.tpl", "templates/zzz_profile.yaml")
   317  		args += " --filter " + strings.Join(filters, ",")
   318  	}
   319  	args += " --set installPackagePath=" + string(chartSource)
   320  	return runCommand(args)
   321  }
   322  
   323  // runCommand runs the given command string.
   324  func runCommand(command string) (string, error) {
   325  	var out bytes.Buffer
   326  	rootCmd := GetRootCmd(cli.NewFakeContext(&cli.NewFakeContextOption{
   327  		Version: "25",
   328  	}), strings.Split(command, " "))
   329  	rootCmd.SetOut(&out)
   330  
   331  	err := rootCmd.Execute()
   332  	return out.String(), err
   333  }
   334  
   335  // cleanTestCluster resets the test cluster.
   336  func cleanTestCluster() error {
   337  	// Needed in case we are running a test through this path that doesn't start a new process.
   338  	cache.FlushObjectCaches()
   339  	if !kubeBuilderInstalled() {
   340  		return nil
   341  	}
   342  	return recreateTestEnv()
   343  }
   344  
   345  // getAllIstioObjects lists all Istio GVK resources from the testClient.
   346  func getAllIstioObjects() object.K8sObjects {
   347  	var out object.K8sObjects
   348  	for _, gvk := range append(allClusterGVKs, allNamespacedGVKs...) {
   349  		objects := &unstructured.UnstructuredList{}
   350  		objects.SetGroupVersionKind(gvk)
   351  		if err := testClient.List(context.TODO(), objects); err != nil {
   352  			log.Error(err.Error())
   353  			continue
   354  		}
   355  		for _, o := range objects.Items {
   356  			no := o.DeepCopy()
   357  			out = append(out, object.NewK8sObject(no, nil, nil))
   358  		}
   359  	}
   360  	return out
   361  }
   362  
   363  // readFile reads a file and returns the contents.
   364  func readFile(path string) (string, error) {
   365  	b, err := os.ReadFile(path)
   366  	return string(b), err
   367  }
   368  
   369  // writeFile writes a file and returns an error if operation is unsuccessful.
   370  func writeFile(path string, data []byte) error {
   371  	return os.WriteFile(path, data, 0o644)
   372  }
   373  
   374  // inFileAbsolutePath returns the absolute path for an input file like "gateways".
   375  func inFileAbsolutePath(inFile string) string {
   376  	return filepath.Join(testDataDir, "input", inFile+".yaml")
   377  }