diff --git a/cmd/appsubsummary/exec/manager.go b/cmd/appsubsummary/exec/manager.go index 93fae56b..ddf20929 100644 --- a/cmd/appsubsummary/exec/manager.go +++ b/cmd/appsubsummary/exec/manager.go @@ -21,6 +21,7 @@ import ( "k8s.io/client-go/rest" "k8s.io/klog/v2" appsubapi "open-cluster-management.io/multicloud-operators-subscription/pkg/apis" + managedClusterView "open-cluster-management.io/multicloud-operators-subscription/pkg/apis/view/v1beta1" "open-cluster-management.io/multicloud-operators-subscription/pkg/controller" "open-cluster-management.io/multicloud-operators-subscription/pkg/utils" ctrl "sigs.k8s.io/controller-runtime" @@ -82,6 +83,12 @@ func RunManager() { os.Exit(1) } + // Setup manageClusterView Scheme for manager + if err := managedClusterView.AddToScheme(mgr.GetScheme()); err != nil { + klog.Error(err, "") + os.Exit(1) + } + // Setup all Controllers. if err := controller.AddAppSubSummaryToManager(mgr, options.SyncInterval); err != nil { klog.Error(err, "") diff --git a/hack/test/view.open-cluster-management.io_managedclusterviews.yaml b/hack/test/view.open-cluster-management.io_managedclusterviews.yaml new file mode 100644 index 00000000..29445cd3 --- /dev/null +++ b/hack/test/view.open-cluster-management.io_managedclusterviews.yaml @@ -0,0 +1,126 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.5.0 + creationTimestamp: null + name: managedclusterviews.view.open-cluster-management.io +spec: + group: view.open-cluster-management.io + names: + kind: ManagedClusterView + listKind: ManagedClusterViewList + plural: managedclusterviews + singular: managedclusterview + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: ManagedClusterView is the view of resources on a managed cluster + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: Spec defines the desired configuration of a view + properties: + scope: + description: Scope is the scope of the view on a cluster + properties: + apiGroup: + description: Group is the api group of the resources + type: string + kind: + description: Kind is the kind of the subject + type: string + name: + description: Name is the name of the subject + type: string + namespace: + description: Name is the name of the subject + type: string + resource: + description: Resource is the resource type of the subject + type: string + updateIntervalSeconds: + description: UpdateIntervalSeconds is the interval to update view + format: int32 + type: integer + version: + description: Version is the version of the subject + type: string + type: object + type: object + status: + description: Status describes current status of a view + properties: + conditions: + description: Conditions represents the conditions of this resource on managed cluster + items: + description: "Condition contains details for one aspect of the current state of this API Resource. --- This struct is intended for direct use as an array at the field path .status.conditions. For example, type FooStatus struct{ // Represents the observations of a foo's current state. // Known .status.conditions.type are: \"Available\", \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. --- Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be useful (see .node.status.conditions), the ability to deconflict is important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + result: + description: Result references the related result of the view + nullable: true + type: object + x-kubernetes-embedded-resource: true + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/pkg/apis/view/v1beta1/groupversion_info.go b/pkg/apis/view/v1beta1/groupversion_info.go new file mode 100644 index 00000000..193f776b --- /dev/null +++ b/pkg/apis/view/v1beta1/groupversion_info.go @@ -0,0 +1,38 @@ +/* + + +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 v1beta1 contains API Schema definitions for the view v1beta1 API group +// +kubebuilder:object:generate=true +// +groupName=view.open-cluster-management.io +package v1beta1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + GroupName = "view.open-cluster-management.io" + + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1beta1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/pkg/apis/view/v1beta1/view_types.go b/pkg/apis/view/v1beta1/view_types.go new file mode 100644 index 00000000..3f9d99a9 --- /dev/null +++ b/pkg/apis/view/v1beta1/view_types.go @@ -0,0 +1,108 @@ +package v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// ManagedClusterView is the view of resources on a managed cluster +type ManagedClusterView struct { + metav1.TypeMeta `json:",inline"` + // Standard object's metadata. + // More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec defines the desired configuration of a view + // +optional + Spec ViewSpec `json:"spec,omitempty"` + + // Status describes current status of a view + // +optional + Status ViewStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ManagedClusterViewList is a list of all the ManagedClusterView +type ManagedClusterViewList struct { + metav1.TypeMeta `json:",inline"` + // Standard list metadata. + // More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#types-kinds + // +optional + metav1.ListMeta `json:"metadata,omitempty"` + + // List of ManagedClusterView objects. + Items []ManagedClusterView `json:"items"` +} + +// ViewSpec defines the desired configuration of a view +type ViewSpec struct { + // Scope is the scope of the view on a cluster + Scope ViewScope `json:"scope,omitempty"` +} + +// ViewStatus returns the status of the view +type ViewStatus struct { + // Conditions represents the conditions of this resource on managed cluster + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // Result references the related result of the view + // +nullable + // +optional + // +kubebuilder:validation:EmbeddedResource + // +kubebuilder:pruning:PreserveUnknownFields + Result runtime.RawExtension `json:"result,omitempty"` +} + +// ViewScope represents the scope of resources to be viewed +type ViewScope struct { + // Group is the api group of the resources + Group string `json:"apiGroup,omitempty"` + + // Version is the version of the subject + // +optional + Version string `json:"version,omitempty"` + + // Kind is the kind of the subject + // +optional + Kind string `json:"kind,omitempty"` + + // Resource is the resource type of the subject + // +optional + Resource string `json:"resource,omitempty"` + + // Name is the name of the subject + // +optional + Name string `json:"name,omitempty"` + + // Name is the name of the subject + // +optional + Namespace string `json:"namespace,omitempty"` + + // UpdateIntervalSeconds is the interval to update view + // +optional + UpdateIntervalSeconds int32 `json:"updateIntervalSeconds,omitempty"` +} + +// These are valid conditions of a cluster. +const ( + // ConditionViewProcessing means the view is processing. + ConditionViewProcessing string = "Processing" +) + +const ( + ReasonResourceNameInvalid string = "ResourceNameInvalid" + ReasonResourceTypeInvalid string = "ResourceTypeInvalid" + ReasonResourceGVKInvalid string = "ResourceGVKInvalid" + ReasonGetResourceFailed string = "GetResourceFailed" + ReasonGetResource string = "GetResourceProcessing" +) + +func init() { + SchemeBuilder.Register(&ManagedClusterView{}, &ManagedClusterViewList{}) +} diff --git a/pkg/apis/view/v1beta1/zz_generated.deepcopy.go b/pkg/apis/view/v1beta1/zz_generated.deepcopy.go new file mode 100644 index 00000000..3323b542 --- /dev/null +++ b/pkg/apis/view/v1beta1/zz_generated.deepcopy.go @@ -0,0 +1,126 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// Copyright (c) 2020 Red Hat, Inc. + +// Code generated by controller-gen. DO NOT EDIT. + +package v1beta1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ManagedClusterView) DeepCopyInto(out *ManagedClusterView) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagedClusterView. +func (in *ManagedClusterView) DeepCopy() *ManagedClusterView { + if in == nil { + return nil + } + out := new(ManagedClusterView) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ManagedClusterView) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ManagedClusterViewList) DeepCopyInto(out *ManagedClusterViewList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ManagedClusterView, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagedClusterViewList. +func (in *ManagedClusterViewList) DeepCopy() *ManagedClusterViewList { + if in == nil { + return nil + } + out := new(ManagedClusterViewList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ManagedClusterViewList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ViewScope) DeepCopyInto(out *ViewScope) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ViewScope. +func (in *ViewScope) DeepCopy() *ViewScope { + if in == nil { + return nil + } + out := new(ViewScope) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ViewSpec) DeepCopyInto(out *ViewSpec) { + *out = *in + out.Scope = in.Scope +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ViewSpec. +func (in *ViewSpec) DeepCopy() *ViewSpec { + if in == nil { + return nil + } + out := new(ViewSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ViewStatus) DeepCopyInto(out *ViewStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.Result.DeepCopyInto(&out.Result) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ViewStatus. +func (in *ViewStatus) DeepCopy() *ViewStatus { + if in == nil { + return nil + } + out := new(ViewStatus) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/controller/appsubsummary/appsubsummary_controller.go b/pkg/controller/appsubsummary/appsubsummary_controller.go index 6218faaa..084d80a9 100644 --- a/pkg/controller/appsubsummary/appsubsummary_controller.go +++ b/pkg/controller/appsubsummary/appsubsummary_controller.go @@ -31,7 +31,7 @@ import ( "k8s.io/klog/v2" appsubv1 "open-cluster-management.io/multicloud-operators-subscription/pkg/apis/apps/v1" appsubReportV1alpha1 "open-cluster-management.io/multicloud-operators-subscription/pkg/apis/apps/v1alpha1" - "open-cluster-management.io/multicloud-operators-subscription/pkg/utils" + managedClusterView "open-cluster-management.io/multicloud-operators-subscription/pkg/apis/view/v1beta1" subutils "open-cluster-management.io/multicloud-operators-subscription/pkg/utils" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -63,6 +63,13 @@ func (a ClusterSorter) Len() int { return len(a) } func (a ClusterSorter) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ClusterSorter) Less(i, j int) bool { return a[i].Source < a[j].Source } +// AppSubClusterStatus sorts AppSubClusterStatus by Cluster name. +type AppSubClusterStatusSorter []AppSubClusterStatus + +func (a AppSubClusterStatusSorter) Len() int { return len(a) } +func (a AppSubClusterStatusSorter) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a AppSubClusterStatusSorter) Less(i, j int) bool { return a[i].Cluster < a[j].Cluster } + func Add(mgr manager.Manager, interval int) error { dsRS := &ReconcileAppSubSummary{ Client: mgr.GetClient(), @@ -147,8 +154,14 @@ func (r *ReconcileAppSubSummary) generateAppSubSummary() error { r.createOrUpdateAppSubReport(appSubClusterStatusMap) + if subutils.IsReadyManagedClusterView(r.Client) { + r.RefreshManagedClusterViews(appSubClusterStatusMap) + } + runtime.GC() + PrintMemUsage("AppSub Report refreshed.") + return nil } @@ -157,7 +170,7 @@ func (r *ReconcileAppSubSummary) UpdateAppSubMapsPerCluster(appsubReportPerClust cluster := appsubReportPerCluster.Namespace for _, result := range appsubReportPerCluster.Results { - appsubName, appsubNs := utils.ParseNamespacedName(result.Source) + appsubName, appsubNs := subutils.ParseNamespacedName(result.Source) if appsubName == "" && appsubNs == "" { continue @@ -196,13 +209,148 @@ func (r *ReconcileAppSubSummary) UpdateAppSubMapsPerCluster(appsubReportPerClust } } +func (r *ReconcileAppSubSummary) RefreshManagedClusterViews( + appSubClusterStatusMap map[string]AppSubClustersStatus) { + klog.Infof("Start refreshing managedClusterView per app on the first failing cluster, total apps: %v", len(appSubClusterStatusMap)) + + TotalFailingClusterCount := 0 + + for appsub, clustersStatus := range appSubClusterStatusMap { + appsubNs, appsubName := subutils.ParseNamespacedName(appsub) + if appsubName == "" && appsubNs == "" { + continue + } + + newFailingCluster := "" + + sort.Sort(AppSubClusterStatusSorter(clustersStatus.Clusters)) + + // Add the managedClusterView for the app on the first failing cluster + for _, ClusterStatus := range clustersStatus.Clusters { + if ClusterStatus.Phase == "failed" { + newFailingCluster = ClusterStatus.Cluster + + TotalFailingClusterCount++ + if TotalFailingClusterCount > 50 { + break + } + + r.createManagedClusterViewPerApp(appsubName, appsubNs, newFailingCluster) + + break + } + } + + // delete all other existing managedClusterView for the app + + if TotalFailingClusterCount > 50 { + newFailingCluster = "" + + klog.Infof("Since 50 managedClusterViews have been created, delete managedClusterViews for app: %v/%v", appsubNs, appsubName) + } + + r.cleanManagedClusterViewPerApp(appsubName, appsubNs, newFailingCluster) + } +} + +func (r *ReconcileAppSubSummary) cleanManagedClusterViewPerApp(appsubName, appsubNs, newFailingCluster string) { + viewList := &managedClusterView.ManagedClusterViewList{} + listopts := &client.ListOptions{} + + viewSelector := &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "apps.open-cluster-management.io/hosting-subscription": fmt.Sprintf("%.63s", appsubNs+"."+appsubName), + }, + } + + viewSelectionLabel, err := subutils.ConvertLabels(viewSelector) + if err != nil { + klog.Error("Failed to convert managed cluster view selector, err:", err) + + return + } + + listopts.LabelSelector = viewSelectionLabel + err = r.List(context.TODO(), viewList, listopts) + + if err != nil { + klog.Error("Failed to list managed cluster views, err:", err) + + return + } + + for _, managedClusterView := range viewList.Items { + if managedClusterView.Namespace == newFailingCluster { + klog.Infof("Keep the managedClusterview for app: %v/%v failing cluster: %v", appsubNs, appsubName, newFailingCluster) + + continue + } + + if err = r.Delete(context.TODO(), &managedClusterView); err != nil { + klog.Errorf("Error deleting managedClusterView :%v/%v, err:%v", managedClusterView.Namespace, managedClusterView.Name, err) + } + + klog.Infof("managedClusterview deleted for app: %v/%v failing cluster: %v", appsubNs, appsubName, managedClusterView.Namespace) + } +} + +func (r *ReconcileAppSubSummary) createManagedClusterViewPerApp(appsubName, appsubNs, cluster string) { + appManagedClusterView := &managedClusterView.ManagedClusterView{ + TypeMeta: metav1.TypeMeta{ + Kind: "SubscriptionReport", + APIVersion: "apps.open-cluster-management.io/v1alpha1", + }, + } + + appManagedClusterViewKey := types.NamespacedName{ + Name: appsubNs + "-" + appsubName, + Namespace: cluster, + } + + // Create new managedClusterView for the app on the first failing cluster if it doesn't exist + // If it exists, no need to update + if err := r.Get(context.TODO(), appManagedClusterViewKey, appManagedClusterView); err != nil { + if errors.IsNotFound(err) { + klog.Infof("Creating new managed cluster view for app: %v", appManagedClusterViewKey) + + newAppManagedClusterView := &managedClusterView.ManagedClusterView{ + TypeMeta: metav1.TypeMeta{ + Kind: "ManagedClusterView", + APIVersion: "view.open-cluster-management.io/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: appManagedClusterViewKey.Name, + Namespace: appManagedClusterViewKey.Namespace, + Labels: map[string]string{ + "apps.open-cluster-management.io/hosting-subscription": fmt.Sprintf("%.63s", appsubNs+"."+appsubName), + }, + }, + Spec: managedClusterView.ViewSpec{ + Scope: managedClusterView.ViewScope{ + Group: "apps.open-cluster-management.io", + Kind: "SubscriptionStatus", + Version: "v1alpha1", + Resource: "subscriptionstatuses", + Name: appsubName, + Namespace: appsubNs, + }, + }, + } + + if err := r.Create(context.TODO(), newAppManagedClusterView); err != nil { + klog.Errorf("Error in creating ManagedClusterView:%v, err:%v", appManagedClusterViewKey.String(), err) + } + } + } +} + func (r *ReconcileAppSubSummary) createOrUpdateAppSubReport( appSubClusterStatusMap map[string]AppSubClustersStatus) { // Find existing appSubReport for app - can assume it exists for now klog.Infof("appSub Cluster FailStatus Map Count: %v", len(appSubClusterStatusMap)) for appsub, clustersStatus := range appSubClusterStatusMap { - appsubNs, appsubName := utils.ParseNamespacedName(appsub) + appsubNs, appsubName := subutils.ParseNamespacedName(appsub) if appsubName == "" && appsubNs == "" { continue } diff --git a/pkg/controller/appsubsummary/appsubsummary_controller_suite_test.go b/pkg/controller/appsubsummary/appsubsummary_controller_suite_test.go new file mode 100644 index 00000000..76434361 --- /dev/null +++ b/pkg/controller/appsubsummary/appsubsummary_controller_suite_test.go @@ -0,0 +1,112 @@ +// Copyright 2021 The Kubernetes 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 appsubsummary + +import ( + "context" + "log" + stdlog "log" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + spokeClusterV1 "open-cluster-management.io/api/cluster/v1" + "open-cluster-management.io/multicloud-operators-subscription/pkg/apis" + managedClusterView "open-cluster-management.io/multicloud-operators-subscription/pkg/apis/view/v1beta1" +) + +var cfg *rest.Config +var c client.Client + +func TestMain(m *testing.M) { + t := &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "..", "deploy", "crds"), + filepath.Join("..", "..", "..", "hack", "test"), + }, + } + + apis.AddToScheme(scheme.Scheme) + spokeClusterV1.AddToScheme(scheme.Scheme) + managedClusterView.AddToScheme(scheme.Scheme) + + var err error + if cfg, err = t.Start(); err != nil { + stdlog.Fatal(err) + } + + var c client.Client + + if c, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}); err != nil { + log.Fatal(err) + } + + err = c.Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster1"}, + }) + if err != nil { + log.Fatal(err) + } + + err = c.Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster2"}, + }) + if err != nil { + log.Fatal(err) + } + + code := m.Run() + + t.Stop() + os.Exit(code) +} + +// SetupTestReconcile returns a reconcile.Reconcile implementation that delegates to inner and +// writes the request to requests after Reconcile is finished. +func SetupTestReconcile(inner reconcile.Reconciler) (reconcile.Reconciler, chan reconcile.Request) { + requests := make(chan reconcile.Request) + fn := reconcile.Func(func(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + result, err := inner.Reconcile(ctx, req) + requests <- req + + return result, err + }) + + return fn, requests +} + +// StartTestManager adds recFn +func StartTestManager(ctx context.Context, mgr manager.Manager, g *gomega.GomegaWithT) *sync.WaitGroup { + wg := &sync.WaitGroup{} + wg.Add(1) + + go func() { + wg.Done() + mgr.Start(ctx) + }() + + return wg +} diff --git a/pkg/controller/appsubsummary/appsubsummary_controller_test.go b/pkg/controller/appsubsummary/appsubsummary_controller_test.go new file mode 100644 index 00000000..4f65d7ba --- /dev/null +++ b/pkg/controller/appsubsummary/appsubsummary_controller_test.go @@ -0,0 +1,161 @@ +// Copyright 2021 The Kubernetes 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 appsubsummary + +import ( + "testing" + "time" + + "github.com/onsi/gomega" + "golang.org/x/net/context" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + appsubReportV1alpha1 "open-cluster-management.io/multicloud-operators-subscription/pkg/apis/apps/v1alpha1" + managedClusterView "open-cluster-management.io/multicloud-operators-subscription/pkg/apis/view/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +var ( + appsubReport1Failed = appsubReportV1alpha1.SubscriptionReport{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apps.open-cluster-management.io/v1alpha1", + Kind: "SubscriptionReport", + }, + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "apps.open-cluster-management.io/cluster": "true", + }, + Name: "cluster1", + Namespace: "cluster1", + }, + ReportType: "Cluster", + Results: []*appsubReportV1alpha1.SubscriptionReportResult{ + { + Source: "app1-ns/app1", + Result: appsubReportV1alpha1.SubscriptionResult("failed"), + }, + }, + } + + appsubReport1Deployed = appsubReportV1alpha1.SubscriptionReport{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apps.open-cluster-management.io/v1alpha1", + Kind: "SubscriptionReport", + }, + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "apps.open-cluster-management.io/cluster": "true", + }, + Name: "cluster1", + Namespace: "cluster1", + }, + ReportType: "Cluster", + Results: []*appsubReportV1alpha1.SubscriptionReportResult{ + { + Source: "app1-ns/app1", + Result: appsubReportV1alpha1.SubscriptionResult("deployed"), + }, + }, + } + + appsubReport2 = appsubReportV1alpha1.SubscriptionReport{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apps.open-cluster-management.io/v1alpha1", + Kind: "SubscriptionReport", + }, + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "apps.open-cluster-management.io/cluster": "true", + }, + Name: "cluster2", + Namespace: "cluster2", + }, + ReportType: "Cluster", + Results: []*appsubReportV1alpha1.SubscriptionReportResult{ + { + Source: "app1-ns/app1", + Result: appsubReportV1alpha1.SubscriptionResult("failed"), + }, + }, + } + + view1Key = types.NamespacedName{ + Name: "app1-ns-app1", + Namespace: "cluster1", + } + + view2Key = types.NamespacedName{ + Name: "app1-ns-app1", + Namespace: "cluster2", + } +) + +func TestRefreshManagedClusterViews(t *testing.T) { + g := gomega.NewGomegaWithT(t) + + // Setup the Manager and Controller + mgr, err := manager.New(cfg, manager.Options{MetricsBindAddress: "0"}) + g.Expect(err).NotTo(gomega.HaveOccurred()) + + c = mgr.GetClient() + + Add(mgr, 5) + + ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Minute) + mgrStopped := StartTestManager(ctx, mgr, g) + + defer func() { + cancel() + mgrStopped.Wait() + }() + + // Test 1: create appsubReport1Failed, expect app1 managedClusterView is created in the cluster1 NS + g.Expect(c.Create(context.TODO(), &appsubReport1Failed)).NotTo(gomega.HaveOccurred()) + + time.Sleep(time.Second * 10) + + view1 := &managedClusterView.ManagedClusterView{} + err = c.Get(context.TODO(), view1Key, view1) + g.Expect(err).NotTo(gomega.HaveOccurred()) + + // Test 1.1: delete appsubReport1Failed, create appsubReport1Deployed, expect app1 managedClusterView is removed from the cluster1 NS + g.Expect(c.Delete(context.TODO(), &appsubReport1Failed)).NotTo(gomega.HaveOccurred()) + + g.Expect(c.Create(context.TODO(), &appsubReport1Deployed)).NotTo(gomega.HaveOccurred()) + + defer c.Delete(context.TODO(), &appsubReport1Deployed) + + time.Sleep(time.Second * 10) + + view11 := &managedClusterView.ManagedClusterView{} + err = c.Get(context.TODO(), view1Key, view11) + g.Expect(errors.IsNotFound(err)).To(gomega.BeTrue()) + + // Test 2: create appsubReport2, expect app1 managedClusterView is created in the cluster2 NS + g.Expect(c.Create(context.TODO(), &appsubReport2)).NotTo(gomega.HaveOccurred()) + + defer c.Delete(context.TODO(), &appsubReport2) + + time.Sleep(time.Second * 10) + + view2 := &managedClusterView.ManagedClusterView{} + err = c.Get(context.TODO(), view2Key, view2) + g.Expect(err).NotTo(gomega.HaveOccurred()) + + view1 = &managedClusterView.ManagedClusterView{} + err = c.Get(context.TODO(), view1Key, view1) + g.Expect(errors.IsNotFound(err)).To(gomega.BeTrue()) +} diff --git a/pkg/utils/subscription.go b/pkg/utils/subscription.go index 1a78f5a5..97ff46a2 100644 --- a/pkg/utils/subscription.go +++ b/pkg/utils/subscription.go @@ -34,6 +34,7 @@ import ( manifestWorkV1 "open-cluster-management.io/api/work/v1" appv1 "open-cluster-management.io/multicloud-operators-subscription/pkg/apis/apps/v1" appsubReportV1alpha1 "open-cluster-management.io/multicloud-operators-subscription/pkg/apis/apps/v1alpha1" + managedClusterView "open-cluster-management.io/multicloud-operators-subscription/pkg/apis/view/v1beta1" corev1 "k8s.io/api/core/v1" clientsetx "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" @@ -1286,6 +1287,24 @@ func FetchChannelReferences(clt client.Client, chn chnv1.Channel) (sec *corev1.S return sec, cm } +// IsReadyManagedClusterView check if managed cluster view API is ready or not. +func IsReadyManagedClusterView(clReader client.Reader) bool { + viewList := &managedClusterView.ManagedClusterViewList{} + + listopts := &client.ListOptions{} + + err := clReader.List(context.TODO(), viewList, listopts) + if err != nil { + klog.Error("Managed Cluster View API NOT ready: ", err) + + return false + } + + klog.Info("Managed Cluster View API API is ready") + + return true +} + // IsReadyPlacementDecision check if Placement Decision API is ready or not. func IsReadyPlacementDecision(clReader client.Reader) bool { pdlist := &clusterapi.PlacementDecisionList{}