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 }