diff --git a/Dockerfiles/bundle.Dockerfile b/Dockerfiles/bundle.Dockerfile index 133f0557f2c..a22025fe38a 100644 --- a/Dockerfiles/bundle.Dockerfile +++ b/Dockerfiles/bundle.Dockerfile @@ -7,7 +7,7 @@ LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/ LABEL operators.operatorframework.io.bundle.package.v1=opendatahub-operator LABEL operators.operatorframework.io.bundle.channels.v1=fast LABEL operators.operatorframework.io.bundle.channel.default.v1=fast -LABEL operators.operatorframework.io.metrics.builder=operator-sdk-v1.24.1 +LABEL operators.operatorframework.io.metrics.builder=operator-sdk-v1.32.0 LABEL operators.operatorframework.io.metrics.mediatype.v1=metrics+v1 LABEL operators.operatorframework.io.metrics.project_layout=go.kubebuilder.io/v3 diff --git a/Makefile b/Makefile index bb9b21af9f2..c8184d47ffb 100644 --- a/Makefile +++ b/Makefile @@ -126,7 +126,7 @@ endef manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. # TODO: enable below when we do webhook # $(CONTROLLER_GEN) rbac:roleName=controller-manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases - $(CONTROLLER_GEN) rbac:roleName=controller-manager-role crd paths="./..." output:crd:artifacts:config=config/crd/bases + $(CONTROLLER_GEN) rbac:roleName=controller-manager-role crd:ignoreUnexportedFields=true paths="./..." output:crd:artifacts:config=config/crd/bases $(call fetch-external-crds,github.com/openshift/api,route/v1) $(call fetch-external-crds,github.com/openshift/api,user/v1) @@ -308,6 +308,8 @@ toolbox: ## Create a toolbox instance with the proper Golang and Operator SDK ve toolbox create opendatahub-toolbox --image localhost/opendatahub-toolbox:latest # Run tests. +TEST_SRC=./controllers/... ./tests/integration/features/... + .PHONY: envtest envtest: $(ENVTEST) ## Download envtest-setup locally if necessary. $(ENVTEST): $(LOCALBIN) @@ -318,7 +320,7 @@ test: unit-test e2e-test .PHONY: unit-test unit-test: envtest - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./controllers/... -v -coverprofile cover.out + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $(TEST_SRC) -v -coverprofile cover.out .PHONY: e2e-test e2e-test: ## Run e2e tests for the controller diff --git a/apis/dscinitialization/v1/dscinitialization_types.go b/apis/dscinitialization/v1/dscinitialization_types.go index 4deb280bd65..d9fd87d2d85 100644 --- a/apis/dscinitialization/v1/dscinitialization_types.go +++ b/apis/dscinitialization/v1/dscinitialization_types.go @@ -107,6 +107,51 @@ type DSCInitializationList struct { Items []DSCInitialization `json:"items"` } +// FeatureTracker is a cluster-scoped resource for tracking objects +// created through Features API for Data Science Platform. +// It's primarily used as owner reference for resources created across namespaces so that they can be +// garbage collected by Kubernetes when they're not needed anymore. +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +type FeatureTracker struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec FeatureTrackerSpec `json:"spec,omitempty"` + Status FeatureTrackerStatus `json:"status,omitempty"` +} + +func (s *FeatureTracker) ToOwnerReference() metav1.OwnerReference { + return metav1.OwnerReference{ + APIVersion: s.APIVersion, + Kind: s.Kind, + Name: s.Name, + UID: s.UID, + } +} + +// FeatureTrackerSpec defines the desired state of FeatureTracker. +type FeatureTrackerSpec struct { +} + +// FeatureTrackerStatus defines the observed state of FeatureTracker. +type FeatureTrackerStatus struct { +} + +// +kubebuilder:object:root=true + +// FeatureTrackerList contains a list of FeatureTracker. +type FeatureTrackerList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []FeatureTracker `json:"items"` +} + func init() { - SchemeBuilder.Register(&DSCInitialization{}, &DSCInitializationList{}) + SchemeBuilder.Register( + &DSCInitialization{}, + &DSCInitializationList{}, + &FeatureTracker{}, + &FeatureTrackerList{}, + ) } diff --git a/apis/dscinitialization/v1/zz_generated.deepcopy.go b/apis/dscinitialization/v1/zz_generated.deepcopy.go index c885191f04b..ea929384365 100644 --- a/apis/dscinitialization/v1/zz_generated.deepcopy.go +++ b/apis/dscinitialization/v1/zz_generated.deepcopy.go @@ -145,6 +145,95 @@ func (in *DevFlags) DeepCopy() *DevFlags { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FeatureTracker) DeepCopyInto(out *FeatureTracker) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureTracker. +func (in *FeatureTracker) DeepCopy() *FeatureTracker { + if in == nil { + return nil + } + out := new(FeatureTracker) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FeatureTracker) 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 *FeatureTrackerList) DeepCopyInto(out *FeatureTrackerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]FeatureTracker, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureTrackerList. +func (in *FeatureTrackerList) DeepCopy() *FeatureTrackerList { + if in == nil { + return nil + } + out := new(FeatureTrackerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FeatureTrackerList) 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 *FeatureTrackerSpec) DeepCopyInto(out *FeatureTrackerSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureTrackerSpec. +func (in *FeatureTrackerSpec) DeepCopy() *FeatureTrackerSpec { + if in == nil { + return nil + } + out := new(FeatureTrackerSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FeatureTrackerStatus) DeepCopyInto(out *FeatureTrackerStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FeatureTrackerStatus. +func (in *FeatureTrackerStatus) DeepCopy() *FeatureTrackerStatus { + if in == nil { + return nil + } + out := new(FeatureTrackerStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Monitoring) DeepCopyInto(out *Monitoring) { *out = *in diff --git a/bundle/manifests/dscinitialization.opendatahub.io_featuretrackers.yaml b/bundle/manifests/dscinitialization.opendatahub.io_featuretrackers.yaml new file mode 100644 index 00000000000..2414d0d0fa0 --- /dev/null +++ b/bundle/manifests/dscinitialization.opendatahub.io_featuretrackers.yaml @@ -0,0 +1,51 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.9.2 + creationTimestamp: null + name: featuretrackers.dscinitialization.opendatahub.io +spec: + group: dscinitialization.opendatahub.io + names: + kind: FeatureTracker + listKind: FeatureTrackerList + plural: featuretrackers + singular: featuretracker + scope: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + description: FeatureTracker is a cluster-scoped resource for tracking objects + created through Features API for Data Science Platform. It's primarily used + as owner reference for resources created across namespaces so that they + can be garbage collected by Kubernetes when they're not needed anymore. + 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: FeatureTrackerSpec defines the desired state of FeatureTracker. + type: object + status: + description: FeatureTrackerStatus defines the observed state of FeatureTracker. + type: object + type: object + served: true + storage: true +status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: null diff --git a/bundle/manifests/opendatahub-operator.clusterserviceversion.yaml b/bundle/manifests/opendatahub-operator.clusterserviceversion.yaml index 7a64d70eef0..18f062065f8 100644 --- a/bundle/manifests/opendatahub-operator.clusterserviceversion.yaml +++ b/bundle/manifests/opendatahub-operator.clusterserviceversion.yaml @@ -72,7 +72,7 @@ metadata: categories: AI/Machine Learning, Big Data certified: "False" containerImage: quay.io/opendatahub/opendatahub-operator:v2.1.0 - createdAt: "2023-8-23T00:00:00Z" + createdAt: "2023-10-30T14:38:57Z" olm.skipRange: '>=1.0.0 <2.0.0' operatorframework.io/initialization-resource: |- { @@ -117,7 +117,7 @@ metadata: } } } - operators.operatorframework.io/builder: operator-sdk-v1.24.1 + operators.operatorframework.io/builder: operator-sdk-v1.32.0 operators.operatorframework.io/internal-objects: '[dscinitialization.opendatahub.io]' operators.operatorframework.io/project_layout: go.kubebuilder.io/v3 repository: https://github.com/opendatahub-io/opendatahub-operator @@ -158,6 +158,9 @@ spec: displayName: Conditions path: conditions version: v1 + - kind: FeatureTracker + name: featuretrackers.dscinitialization.opendatahub.io + version: v1 description: "The Open Data Hub is a machine-learning-as-a-service platform built on Red Hat's Kubernetes-based OpenShift® Container Platform. Open Data Hub integrates multiple AI/ML open source components into one operator that can easily be downloaded @@ -807,6 +810,18 @@ spec: - get - patch - update + - apiGroups: + - dscinitialization.opendatahub.io + resources: + - featuretrackers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - events.k8s.io resources: diff --git a/bundle/metadata/annotations.yaml b/bundle/metadata/annotations.yaml index ec98f7c4f55..2135259c91b 100644 --- a/bundle/metadata/annotations.yaml +++ b/bundle/metadata/annotations.yaml @@ -6,7 +6,7 @@ annotations: operators.operatorframework.io.bundle.package.v1: opendatahub-operator operators.operatorframework.io.bundle.channels.v1: fast operators.operatorframework.io.bundle.channel.default.v1: fast - operators.operatorframework.io.metrics.builder: operator-sdk-v1.24.1 + operators.operatorframework.io.metrics.builder: operator-sdk-v1.32.0 operators.operatorframework.io.metrics.mediatype.v1: metrics+v1 operators.operatorframework.io.metrics.project_layout: go.kubebuilder.io/v3 diff --git a/components/dashboard/dashboard.go b/components/dashboard/dashboard.go index fa2e86f6804..cbaa0d340fd 100644 --- a/components/dashboard/dashboard.go +++ b/components/dashboard/dashboard.go @@ -9,15 +9,15 @@ import ( "strings" operatorv1 "github.com/openshift/api/operator/v1" + routev1 "github.com/openshift/api/route/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" dsci "github.com/opendatahub-io/opendatahub-operator/v2/apis/dscinitialization/v1" "github.com/opendatahub-io/opendatahub-operator/v2/components" + "github.com/opendatahub-io/opendatahub-operator/v2/pkg/cluster" "github.com/opendatahub-io/opendatahub-operator/v2/pkg/common" "github.com/opendatahub-io/opendatahub-operator/v2/pkg/deploy" - - routev1 "github.com/openshift/api/route/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" ) var ( @@ -88,7 +88,7 @@ func (d *Dashboard) ReconcileComponent(cli client.Client, owner metav1.Object, d } if platform == deploy.OpenDataHub || platform == "" { - err := common.UpdatePodSecurityRolebinding(cli, []string{"odh-dashboard"}, dscispec.ApplicationsNamespace) + err := cluster.UpdatePodSecurityRolebinding(cli, dscispec.ApplicationsNamespace, "odh-dashboard") if err != nil { return err } @@ -99,7 +99,7 @@ func (d *Dashboard) ReconcileComponent(cli client.Client, owner metav1.Object, d } if platform == deploy.SelfManagedRhods || platform == deploy.ManagedRhods { - err := common.UpdatePodSecurityRolebinding(cli, []string{"rhods-dashboard"}, dscispec.ApplicationsNamespace) + err := cluster.UpdatePodSecurityRolebinding(cli, dscispec.ApplicationsNamespace, "rhods-dashboard") if err != nil { return err } @@ -187,7 +187,7 @@ func (d *Dashboard) applyRhodsSpecificConfigs(cli client.Client, owner metav1.Ob return fmt.Errorf("failed to set dashboard OVMS from %s: %w", PathOVMS, err) } - if err := common.CreateSecret(cli, "anaconda-ce-access", namespace); err != nil { + if err := cluster.CreateSecret(cli, "anaconda-ce-access", namespace); err != nil { return fmt.Errorf("failed to create access-secret for anaconda: %w", err) } diff --git a/components/kserve/kserve.go b/components/kserve/kserve.go index 3180c1ef242..89cbe86c94f 100644 --- a/components/kserve/kserve.go +++ b/components/kserve/kserve.go @@ -3,13 +3,12 @@ package kserve import ( "fmt" - "path/filepath" "strings" dsci "github.com/opendatahub-io/opendatahub-operator/v2/apis/dscinitialization/v1" "github.com/opendatahub-io/opendatahub-operator/v2/components" - "github.com/opendatahub-io/opendatahub-operator/v2/pkg/common" + "github.com/opendatahub-io/opendatahub-operator/v2/pkg/cluster" "github.com/opendatahub-io/opendatahub-operator/v2/pkg/deploy" operatorv1 "github.com/openshift/api/operator/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -122,8 +121,7 @@ func (k *Kserve) ReconcileComponent(cli client.Client, owner metav1.Object, dsci // For odh-model-controller if enabled { - err := common.UpdatePodSecurityRolebinding(cli, []string{"odh-model-controller"}, dscispec.ApplicationsNamespace) - if err != nil { + if err := cluster.UpdatePodSecurityRolebinding(cli, dscispec.ApplicationsNamespace, "odh-model-controller"); err != nil { return err } // Update image parameters for odh-maodel-controller diff --git a/components/modelmeshserving/modelmeshserving.go b/components/modelmeshserving/modelmeshserving.go index 1efd6155d84..0b2b8616e20 100644 --- a/components/modelmeshserving/modelmeshserving.go +++ b/components/modelmeshserving/modelmeshserving.go @@ -8,7 +8,7 @@ import ( dsci "github.com/opendatahub-io/opendatahub-operator/v2/apis/dscinitialization/v1" "github.com/opendatahub-io/opendatahub-operator/v2/components" - "github.com/opendatahub-io/opendatahub-operator/v2/pkg/common" + "github.com/opendatahub-io/opendatahub-operator/v2/pkg/cluster" "github.com/opendatahub-io/opendatahub-operator/v2/pkg/deploy" operatorv1 "github.com/openshift/api/operator/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -93,8 +93,11 @@ func (m *ModelMeshServing) ReconcileComponent(cli client.Client, owner metav1.Ob return err } - err := common.UpdatePodSecurityRolebinding(cli, []string{"modelmesh", "modelmesh-controller", "odh-prometheus-operator", "prometheus-custom"}, dscispec.ApplicationsNamespace) - if err != nil { + if err := cluster.UpdatePodSecurityRolebinding(cli, dscispec.ApplicationsNamespace, + "modelmesh", + "modelmesh-controller", + "odh-prometheus-operator", + "prometheus-custom"); err != nil { return err } // Update image parameters @@ -112,7 +115,7 @@ func (m *ModelMeshServing) ReconcileComponent(cli client.Client, owner metav1.Ob // For odh-model-controller if enabled { - err := common.UpdatePodSecurityRolebinding(cli, []string{"odh-model-controller"}, dscispec.ApplicationsNamespace) + err := cluster.UpdatePodSecurityRolebinding(cli, dscispec.ApplicationsNamespace, "odh-model-controller") if err != nil { return err } diff --git a/components/workbenches/workbenches.go b/components/workbenches/workbenches.go index 86475ddfb1d..45ceab7dd34 100644 --- a/components/workbenches/workbenches.go +++ b/components/workbenches/workbenches.go @@ -5,13 +5,14 @@ import ( "path/filepath" "strings" - dsci "github.com/opendatahub-io/opendatahub-operator/v2/apis/dscinitialization/v1" - "github.com/opendatahub-io/opendatahub-operator/v2/components" - "github.com/opendatahub-io/opendatahub-operator/v2/pkg/common" - "github.com/opendatahub-io/opendatahub-operator/v2/pkg/deploy" operatorv1 "github.com/openshift/api/operator/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" + + dsci "github.com/opendatahub-io/opendatahub-operator/v2/apis/dscinitialization/v1" + "github.com/opendatahub-io/opendatahub-operator/v2/components" + "github.com/opendatahub-io/opendatahub-operator/v2/pkg/cluster" + "github.com/opendatahub-io/opendatahub-operator/v2/pkg/deploy" ) var ( @@ -111,14 +112,14 @@ func (w *Workbenches) ReconcileComponent(cli client.Client, owner metav1.Object, } if platform == deploy.SelfManagedRhods || platform == deploy.ManagedRhods { - err := common.CreateNamespace(cli, "rhods-notebooks") + _, err := cluster.CreateNamespace(cli, "rhods-notebooks") if err != nil { // no need to log error as it was already logged in createOdhNamespace return err } } // Update Default rolebinding - err = common.UpdatePodSecurityRolebinding(cli, []string{"notebook-controller-service-account"}, dscispec.ApplicationsNamespace) + err = cluster.UpdatePodSecurityRolebinding(cli, dscispec.ApplicationsNamespace, "notebook-controller-service-account") if err != nil { return err } diff --git a/config/crd/bases/dscinitialization.opendatahub.io_featuretrackers.yaml b/config/crd/bases/dscinitialization.opendatahub.io_featuretrackers.yaml new file mode 100644 index 00000000000..3ad5b72560f --- /dev/null +++ b/config/crd/bases/dscinitialization.opendatahub.io_featuretrackers.yaml @@ -0,0 +1,46 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.9.2 + creationTimestamp: null + name: featuretrackers.dscinitialization.opendatahub.io +spec: + group: dscinitialization.opendatahub.io + names: + kind: FeatureTracker + listKind: FeatureTrackerList + plural: featuretrackers + singular: featuretracker + scope: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + description: FeatureTracker is a cluster-scoped resource for tracking objects + created through Features API for Data Science Platform. It's primarily used + as owner reference for resources created across namespaces so that they + can be garbage collected by Kubernetes when they're not needed anymore. + 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: FeatureTrackerSpec defines the desired state of FeatureTracker. + type: object + status: + description: FeatureTrackerStatus defines the observed state of FeatureTracker. + type: object + type: object + served: true + storage: true diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 110438d509c..6adcedb9a88 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,6 +3,7 @@ # It should be run by config/default resources: - bases/dscinitialization.opendatahub.io_dscinitializations.yaml +- bases/dscinitialization.opendatahub.io_featuretrackers.yaml - bases/datasciencecluster.opendatahub.io_datascienceclusters.yaml #+kubebuilder:scaffold:crdkustomizeresource diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index cbf5d2b0b5f..06d68384317 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -625,6 +625,18 @@ rules: - get - patch - update +- apiGroups: + - dscinitialization.opendatahub.io + resources: + - featuretrackers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - events.k8s.io resources: diff --git a/config/samples/dscinitialization_v1_dscinitialization.yaml b/config/samples/dscinitialization_v1_dscinitialization.yaml index 745876813a1..cb0d24da046 100644 --- a/config/samples/dscinitialization_v1_dscinitialization.yaml +++ b/config/samples/dscinitialization_v1_dscinitialization.yaml @@ -12,4 +12,4 @@ spec: monitoring: managementState: "Managed" namespace: 'opendatahub' - applicationsNamespace: 'opendatahub' \ No newline at end of file + applicationsNamespace: 'opendatahub' diff --git a/controllers/dscinitialization/dscinitialization_controller.go b/controllers/dscinitialization/dscinitialization_controller.go index 72ba7e3dd74..6d38647d5c8 100644 --- a/controllers/dscinitialization/dscinitialization_controller.go +++ b/controllers/dscinitialization/dscinitialization_controller.go @@ -59,6 +59,7 @@ type DSCInitializationReconciler struct { // +kubebuilder:rbac:groups="dscinitialization.opendatahub.io",resources=dscinitializations/status,verbs=get;update;patch;delete // +kubebuilder:rbac:groups="dscinitialization.opendatahub.io",resources=dscinitializations/finalizers,verbs=get;update;patch;delete // +kubebuilder:rbac:groups="dscinitialization.opendatahub.io",resources=dscinitializations,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="dscinitialization.opendatahub.io",resources=featuretrackers,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="kfdef.apps.kubeflow.org",resources=kfdefs,verbs=get;list;watch;create;update;patch;delete // Reconcile contains controller logic specific to DSCInitialization instance updates. diff --git a/controllers/dscinitialization/dscinitialization_test.go b/controllers/dscinitialization/dscinitialization_test.go index 089067c1aae..f86d8745b55 100644 --- a/controllers/dscinitialization/dscinitialization_test.go +++ b/controllers/dscinitialization/dscinitialization_test.go @@ -2,10 +2,10 @@ package dscinitialization_test import ( "context" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" dsci "github.com/opendatahub-io/opendatahub-operator/v2/apis/dscinitialization/v1" + "github.com/opendatahub-io/opendatahub-operator/v2/tests/envtestutil" operatorv1 "github.com/openshift/api/operator/v1" corev1 "k8s.io/api/core/v1" netv1 "k8s.io/api/networking/v1" @@ -25,9 +25,10 @@ const ( var _ = Describe("DataScienceCluster initialization", func() { Context("Creation of related resources", func() { - applicationName := "default-test" + var applicationName string BeforeEach(func() { // when + applicationName = envtestutil.AppendRandomNameTo("default-test") desiredDsci := createDSCI(applicationName) Expect(k8sClient.Create(context.Background(), desiredDsci)).Should(Succeed()) foundDsci := &dsci.DSCInitialization{} @@ -97,7 +98,7 @@ var _ = Describe("DataScienceCluster initialization", func() { AfterEach(cleanupResources) It("Should not update rolebinding if it exists", func() { - applicationName := "rolebinding-test" + applicationName := envtestutil.AppendRandomNameTo("rolebinding-test") // given desiredRoleBinding := &authv1.RoleBinding{ @@ -134,7 +135,7 @@ var _ = Describe("DataScienceCluster initialization", func() { }) It("Should not update configmap if it exists", func() { - applicationName := "configmap-test" + applicationName := envtestutil.AppendRandomNameTo("configmap-test") // given desiredConfigMap := &corev1.ConfigMap{ @@ -167,7 +168,7 @@ var _ = Describe("DataScienceCluster initialization", func() { }) It("Should not update namespace if it exists", func() { - applicationName := "configmap-test" + applicationName := envtestutil.AppendRandomNameTo("configmap-test") anotherNamespace := "test-another-ns" // given diff --git a/controllers/dscinitialization/suite_test.go b/controllers/dscinitialization/suite_test.go index 4c15492a4f4..a94a7dc3291 100644 --- a/controllers/dscinitialization/suite_test.go +++ b/controllers/dscinitialization/suite_test.go @@ -18,8 +18,7 @@ package dscinitialization_test import ( "context" - kfdefv1 "github.com/opendatahub-io/opendatahub-operator/apis/kfdef.apps.kubeflow.org/v1" - "k8s.io/apimachinery/pkg/runtime" + "path/filepath" "testing" "time" @@ -27,18 +26,21 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - util "github.com/opendatahub-io/opendatahub-operator/v2/controllers/test" - + kfdefv1 "github.com/opendatahub-io/opendatahub-operator/apis/kfdef.apps.kubeflow.org/v1" dscinitializationv1 "github.com/opendatahub-io/opendatahub-operator/v2/apis/dscinitialization/v1" dsci "github.com/opendatahub-io/opendatahub-operator/v2/controllers/dscinitialization" + "github.com/opendatahub-io/opendatahub-operator/v2/tests/envtestutil" + routev1 "github.com/openshift/api/route/v1" userv1 "github.com/openshift/api/user/v1" ofapi "github.com/operator-framework/api/pkg/operators/v1alpha1" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" netv1 "k8s.io/api/networking/v1" authv1 "k8s.io/api/rbac/v1" apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" @@ -78,7 +80,7 @@ var _ = BeforeSuite(func() { ctx, cancel = context.WithCancel(context.TODO()) logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) By("bootstrapping test environment") - rootPath, pathErr := util.FindProjectRoot() + rootPath, pathErr := envtestutil.FindProjectRoot() Expect(pathErr).ToNot(HaveOccurred(), pathErr) testEnv = &envtest.Environment{ diff --git a/controllers/secretgenerator/secret.go b/controllers/secretgenerator/secret.go index 521fb7fef28..fe91f42ea37 100644 --- a/controllers/secretgenerator/secret.go +++ b/controllers/secretgenerator/secret.go @@ -31,7 +31,7 @@ type Secret struct { OAuthClientRoute string } -func newSecret(annotations map[string]string) (*Secret, error) { +func NewSecretFrom(annotations map[string]string) (*Secret, error) { // Check if annotations is not empty if len(annotations) == 0 { return nil, errors.New(errEmptyAnnotation) @@ -64,14 +64,37 @@ func newSecret(annotations map[string]string) (*Secret, error) { secret.Complexity = SECRET_DEFAULT_COMPLEXITY } - // Generate a random value based on the secret type + if secretOAuthClientRoute, found := annotations[SECRET_OAUTH_CLIENT_ANNOTATION]; found { + secret.OAuthClientRoute = secretOAuthClientRoute + } + + if err := generateSecretValue(&secret); err != nil { + return nil, err + } + + return &secret, nil +} + +func NewSecret(name, secretType string, complexity int) (*Secret, error) { + secret := &Secret{ + Name: name, + Type: secretType, + Complexity: complexity, + } + + err := generateSecretValue(secret) + + return secret, err +} + +func generateSecretValue(secret *Secret) error { switch secret.Type { case "random": randomValue := make([]byte, secret.Complexity) for i := 0; i < secret.Complexity; i++ { num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letterRunes)))) if err != nil { - return nil, err + return err } randomValue[i] = letterRunes[num.Int64()] } @@ -79,16 +102,13 @@ func newSecret(annotations map[string]string) (*Secret, error) { case "oauth": randomValue := make([]byte, secret.Complexity) if _, err := rand.Read(randomValue); err != nil { - return nil, err + return err } secret.Value = base64.StdEncoding.EncodeToString( []byte(base64.StdEncoding.EncodeToString(randomValue))) default: - return nil, errors.New(errUnsupportedType) + return errors.New(errUnsupportedType) } - // Get OAuthClient route name from annotation - if secretOAuthClientRoute, found := annotations[SECRET_OAUTH_CLIENT_ANNOTATION]; found { - secret.OAuthClientRoute = secretOAuthClientRoute - } - return &secret, nil + + return nil } diff --git a/controllers/secretgenerator/secret_test.go b/controllers/secretgenerator/secret_test.go index b3c90ee9c81..240986bb974 100644 --- a/controllers/secretgenerator/secret_test.go +++ b/controllers/secretgenerator/secret_test.go @@ -84,7 +84,7 @@ func TestNewSecret(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - secret, err := newSecret(tc.annotations) + secret, err := NewSecretFrom(tc.annotations) if err != nil { if err.Error() != tc.err.Error() { t.Errorf("Expected error: %v, got: %v\n", diff --git a/controllers/secretgenerator/secretgenerator_controller.go b/controllers/secretgenerator/secretgenerator_controller.go index b650011e0d7..2561e6caeb3 100644 --- a/controllers/secretgenerator/secretgenerator_controller.go +++ b/controllers/secretgenerator/secretgenerator_controller.go @@ -25,7 +25,7 @@ import ( ocv1 "github.com/openshift/api/oauth/v1" routev1 "github.com/openshift/api/route/v1" v1 "k8s.io/api/core/v1" - k8serrors "k8s.io/apimachinery/pkg/api/errors" + apierrs "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -104,7 +104,7 @@ func (r *SecretGeneratorReconciler) Reconcile(ctx context.Context, request ctrl. foundSecret := &v1.Secret{} err := r.Client.Get(ctx, request.NamespacedName, foundSecret) if err != nil { - if k8serrors.IsNotFound(err) { + if apierrs.IsNotFound(err) { // If Secret is deleted, delete OAuthClient if exists err = r.deleteOAuthClient(ctx, request.Name) } @@ -129,12 +129,12 @@ func (r *SecretGeneratorReconciler) Reconcile(ctx context.Context, request ctrl. } err = r.Client.Get(ctx, generatedSecretKey, generatedSecret) if err != nil { - if k8serrors.IsNotFound(err) { + if apierrs.IsNotFound(err) { // Generate secret random value secGenLog.Info("Generating a random value for a secret in a namespace", "secret", generatedSecret.Name, "namespace", generatedSecret.Namespace) - secret, err := newSecret(foundSecret.GetAnnotations()) + secret, err := NewSecretFrom(foundSecret.GetAnnotations()) if err != nil { secGenLog.Error(err, "error creating secret") return ctrl.Result{}, err @@ -183,7 +183,7 @@ func (r *SecretGeneratorReconciler) getRoute(ctx context.Context, name string, n Namespace: namespace, }, route) if err != nil { - if k8serrors.IsNotFound(err) { + if apierrs.IsNotFound(err) { return false, nil } return false, err @@ -217,7 +217,7 @@ func (r *SecretGeneratorReconciler) createOAuthClient(ctx context.Context, name err := r.Client.Create(ctx, oauthClient) if err != nil { - if k8serrors.IsAlreadyExists(err) { + if apierrs.IsAlreadyExists(err) { secGenLog.Info("OAuth client resource already exists", "name", oauthClient.Name) return nil } @@ -232,7 +232,7 @@ func (r *SecretGeneratorReconciler) deleteOAuthClient(ctx context.Context, secre Name: secretName, }, oauthClient) if err != nil { - if k8serrors.IsNotFound(err) { + if apierrs.IsNotFound(err) { return nil } return err diff --git a/go.mod b/go.mod index 02cf3354918..75ea79e214c 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,13 @@ module github.com/opendatahub-io/opendatahub-operator/v2 go 1.19 require ( + github.com/ghodss/yaml v1.0.0 github.com/go-logr/logr v1.2.4 github.com/hashicorp/go-multierror v1.1.1 - github.com/onsi/ginkgo/v2 v2.11.0 + github.com/onsi/ginkgo/v2 v2.12.1 github.com/onsi/gomega v1.27.10 github.com/opendatahub-io/opendatahub-operator v1.7.0 - github.com/openshift/addon-operator/apis v0.0.0-20230616140313-b6e2f736fdcd + github.com/openshift/addon-operator/apis v0.0.0-20230919043633-820afed15881 github.com/openshift/api v0.0.0-20230823114715-5fdd7511b790 github.com/openshift/custom-resource-status v1.1.2 github.com/operator-framework/api v0.17.6 @@ -34,7 +35,6 @@ require ( github.com/evanphx/json-patch v4.12.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.6.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect - github.com/ghodss/yaml v1.0.0 // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-logr/zapr v1.2.4 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect diff --git a/go.sum b/go.sum index 4b34c925a5e..daac5ea4129 100644 --- a/go.sum +++ b/go.sum @@ -684,8 +684,8 @@ github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47 github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0= github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= -github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU= -github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM= +github.com/onsi/ginkgo/v2 v2.12.1 h1:uHNEO1RP2SpuZApSkel9nEh1/Mu+hmQe7Q+Pepg5OYA= +github.com/onsi/ginkgo/v2 v2.12.1/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= @@ -718,8 +718,8 @@ github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.m github.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs= github.com/opendatahub-io/opendatahub-operator v1.7.0 h1:Pn76VWCUHeqD5E0L94t5FtZ2OU6ZaWk/IwbEqXGQ4Gs= github.com/opendatahub-io/opendatahub-operator v1.7.0/go.mod h1:XsgkXbGjJoVeZOkmRztemhY5ppX7puzuqDw9oUdzNZk= -github.com/openshift/addon-operator/apis v0.0.0-20230616140313-b6e2f736fdcd h1:6elrLdOa+BRHJVaHnZAHltufWk0pzPZYF67fX9aFCjU= -github.com/openshift/addon-operator/apis v0.0.0-20230616140313-b6e2f736fdcd/go.mod h1:cDMtOZx741HfmmUMmT09PWM8cOBxEJp3ipUHeHPr8F4= +github.com/openshift/addon-operator/apis v0.0.0-20230919043633-820afed15881 h1:d0hmj9Is2sCLkNYWtBicZV0Ft8+Os+w+RUkFRjie0VI= +github.com/openshift/addon-operator/apis v0.0.0-20230919043633-820afed15881/go.mod h1:2hsK4sYLKcjVJ8SziFrzr/c/Tmp5zBDy8aYvrFaRm2o= github.com/openshift/api v0.0.0-20200326152221-912866ddb162/go.mod h1:RKMJ5CBnljLfnej+BJ/xnOWc3kZDvJUaIAEq2oKSPtE= github.com/openshift/api v0.0.0-20200331152225-585af27e34fd/go.mod h1:RKMJ5CBnljLfnej+BJ/xnOWc3kZDvJUaIAEq2oKSPtE= github.com/openshift/api v0.0.0-20230823114715-5fdd7511b790 h1:e3zIxk67/kiABxGFfFVECqJ4FcQRG5DPF8lgDV9f+MM= diff --git a/pkg/cluster/operations.go b/pkg/cluster/operations.go new file mode 100644 index 00000000000..20935bebe9e --- /dev/null +++ b/pkg/cluster/operations.go @@ -0,0 +1,100 @@ +package cluster + +import ( + "context" + corev1 "k8s.io/api/core/v1" + authv1 "k8s.io/api/rbac/v1" + apierrs "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // odhGeneratedNamespaceLabel is the label added to all the namespaces generated by odh-deployer + odhGeneratedNamespaceLabel = "opendatahub.io/generated-namespace" +) + +// UpdatePodSecurityRolebinding update default rolebinding which is created in applications namespace by manifests +// being used by different components. +func UpdatePodSecurityRolebinding(cli client.Client, namespace string, serviceAccountsList ...string) error { + foundRoleBinding := &authv1.RoleBinding{} + err := cli.Get(context.TODO(), client.ObjectKey{Name: namespace, Namespace: namespace}, foundRoleBinding) + if err != nil { + return err + } + + for _, sa := range serviceAccountsList { + // Append serviceAccount if not added already + if !subjectExistInRoleBinding(foundRoleBinding.Subjects, sa, namespace) { + foundRoleBinding.Subjects = append(foundRoleBinding.Subjects, authv1.Subject{ + Kind: authv1.ServiceAccountKind, + Name: sa, + Namespace: namespace, + }) + } + } + + return cli.Update(context.TODO(), foundRoleBinding) +} + +// Internal function used by UpdatePodSecurityRolebinding() +// Return whether Rolebinding matching service account and namespace exists or not. +func subjectExistInRoleBinding(subjectList []authv1.Subject, serviceAccountName, namespace string) bool { + for _, subject := range subjectList { + if subject.Name == serviceAccountName && subject.Namespace == namespace { + return true + } + } + return false +} + +// CreateSecret creates secrets required by dashboard component in downstream. +func CreateSecret(cli client.Client, name, namespace string) error { + desiredSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Type: corev1.SecretTypeOpaque, + } + + foundSecret := &corev1.Secret{} + err := cli.Get(context.TODO(), client.ObjectKey{Name: name, Namespace: namespace}, foundSecret) + if err != nil { + if apierrs.IsNotFound(err) { + err = cli.Create(context.TODO(), desiredSecret) + if err != nil && !apierrs.IsAlreadyExists(err) { + return err + } + } else { + return err + } + } + return nil +} + +// CreateNamespace creates namespace required by workbenches component in downstream. +func CreateNamespace(cli client.Client, namespace string) (*corev1.Namespace, error) { + desiredNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + Labels: map[string]string{ + odhGeneratedNamespaceLabel: "true", + }, + }, + } + + foundNamespace := &corev1.Namespace{} + err := cli.Get(context.TODO(), client.ObjectKey{Name: namespace}, foundNamespace) + if err != nil { + if apierrs.IsNotFound(err) { + err = cli.Create(context.TODO(), desiredNamespace) + if err != nil && !apierrs.IsAlreadyExists(err) { + return nil, err + } + } else { + return nil, err + } + } + return desiredNamespace, nil +} diff --git a/pkg/common/common.go b/pkg/common/common.go index 600bc56f3b6..e76a5a0404f 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -18,56 +18,12 @@ limitations under the License. package common import ( - "context" "fmt" - corev1 "k8s.io/api/core/v1" - authv1 "k8s.io/api/rbac/v1" - apierrs "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "os" - "sigs.k8s.io/controller-runtime/pkg/client" + "regexp" "strings" ) -const ( - // odhGeneratedNamespaceLabel is the label added to all the namespaces genereated by odh-deployer - odhGeneratedNamespaceLabel = "opendatahub.io/generated-namespace" -) - -// UpdatePodSecurityRolebinding update default rolebinding which is created in applications namespace by manifests -// being used by different components. -func UpdatePodSecurityRolebinding(cli client.Client, serviceAccountsList []string, namespace string) error { - foundRoleBinding := &authv1.RoleBinding{} - err := cli.Get(context.TODO(), client.ObjectKey{Name: namespace, Namespace: namespace}, foundRoleBinding) - if err != nil { - return err - } - - for _, sa := range serviceAccountsList { - // Append serviceAccount if not added already - if !subjectExistInRoleBinding(foundRoleBinding.Subjects, sa, namespace) { - foundRoleBinding.Subjects = append(foundRoleBinding.Subjects, authv1.Subject{ - Kind: authv1.ServiceAccountKind, - Name: sa, - Namespace: namespace, - }) - } - } - - return cli.Update(context.TODO(), foundRoleBinding) -} - -// Internal function used by UpdatePodSecurityRolebinding() -// Return whether Rolebinding matching service account and namespace exists or not. -func subjectExistInRoleBinding(subjectList []authv1.Subject, serviceAccountName, namespace string) bool { - for _, subject := range subjectList { - if subject.Name == serviceAccountName && subject.Namespace == namespace { - return true - } - } - return false -} - // ReplaceStringsInFile replaces variable with value in manifests during runtime. func ReplaceStringsInFile(fileName string, replacements map[string]string) error { // Read the contents of the file @@ -91,53 +47,29 @@ func ReplaceStringsInFile(fileName string, replacements map[string]string) error return nil } -// CreateSecret creates secrets required by dashboard component in downstream. -func CreateSecret(cli client.Client, name, namespace string) error { - desiredSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - Type: corev1.SecretTypeOpaque, +func TrimToRFC1123Name(input string) string { + if len(input) == 0 { + return input } - - foundSecret := &corev1.Secret{} - err := cli.Get(context.TODO(), client.ObjectKey{Name: name, Namespace: namespace}, foundSecret) - if err != nil { - if apierrs.IsNotFound(err) { - err = cli.Create(context.TODO(), desiredSecret) - if err != nil && !apierrs.IsAlreadyExists(err) { - return err - } - } else { - return err - } + if len(input) > 63 { + input = input[:63] } - return nil -} -// CreateNamespace creates namespace required by workbenches component in downstream. -func CreateNamespace(cli client.Client, namespace string) error { - desiredNamespace := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: namespace, - Labels: map[string]string{ - odhGeneratedNamespaceLabel: "true", - }, - }, + regex := regexp.MustCompile(`[^A-Za-z0-9\-]+`) + replaced := regex.ReplaceAllString(input, "-") + + if !isAlphanumeric(replaced[0]) { + replaced = "a" + replaced[1:] } - foundNamespace := &corev1.Namespace{} - err := cli.Get(context.TODO(), client.ObjectKey{Name: namespace}, foundNamespace) - if err != nil { - if apierrs.IsNotFound(err) { - err = cli.Create(context.TODO(), desiredNamespace) - if err != nil && !apierrs.IsAlreadyExists(err) { - return err - } - } else { - return err - } + if !isAlphanumeric(replaced[len(replaced)-1]) { + replaced = replaced[:len(replaced)-1] + "z" } - return nil + + return strings.ToLower(replaced) +} + +func isAlphanumeric(char byte) bool { + regex := regexp.MustCompile(`^[A-Za-z0-9]$`) + return regex.Match([]byte{char}) } diff --git a/pkg/common/common_suite_test.go b/pkg/common/common_suite_test.go new file mode 100644 index 00000000000..353da190026 --- /dev/null +++ b/pkg/common/common_suite_test.go @@ -0,0 +1,13 @@ +package common_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestK8sNamingHelpers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Common k8s naming func unit tests") +} diff --git a/pkg/common/k8s_naming_test.go b/pkg/common/k8s_naming_test.go new file mode 100644 index 00000000000..4be336f80a6 --- /dev/null +++ b/pkg/common/k8s_naming_test.go @@ -0,0 +1,61 @@ +package common_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/opendatahub-io/opendatahub-operator/v2/pkg/common" +) + +var _ = Describe("Ensuring name (e.g. meta.Name) fulfills RFC1123 naming spec", func() { + + type nameConversionCase struct { + actual string + expected string + } + + DescribeTable("trimming to correct RFC1123 names", + func(testCase nameConversionCase) { + Expect(common.TrimToRFC1123Name(testCase.actual)).To(Equal(testCase.expected)) + }, + Entry("empty string should be left unchanged", nameConversionCase{ + actual: "", + expected: "", + }), + Entry("string longer than 63 characters should be trimmed to 63", nameConversionCase{ + actual: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmno", + expected: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijk", + }), + Entry("string with non-alphanumeric characters should have them replaced to hyphens", nameConversionCase{ + actual: "abc!@#def", + expected: "abc-def", + }), + Entry("string starting with non-alphanumeric character should have it replaced", nameConversionCase{ + actual: "!abcdef", + expected: "aabcdef", + }), + Entry("string ending with non-alphanumeric character should have it replaced", nameConversionCase{ + actual: "abcdef!", + expected: "abcdefz", + }), + Entry("string with uppercase characters should be all lowercase", nameConversionCase{ + actual: "AbCdEf", + expected: "abcdef", + }), + Entry("string with multiple consecutive non-alphanumeric characters should have it folded to one hyphen", nameConversionCase{ + actual: "abc!!!def", + expected: "abc-def", + }), + Entry("string that has both start and end non-alphanumeric should have them replaced", nameConversionCase{ + actual: "!abcdef!", + expected: "aabcdefz", + }), + Entry("string that starts and ends with hyphens should have them replaced by alphanumeric characters", nameConversionCase{ + actual: "-abcdef-", + expected: "aabcdefz", + }), + Entry("string entirely of non-alphanumeric characters should be converted to one letter", nameConversionCase{ + actual: "!@#$%^&*()", + expected: "a", + }), + ) +}) diff --git a/pkg/deploy/setup.go b/pkg/deploy/setup.go index 131a6d29527..109f428e267 100644 --- a/pkg/deploy/setup.go +++ b/pkg/deploy/setup.go @@ -17,6 +17,8 @@ const ( SelfManagedRhods Platform = "Red Hat OpenShift Data Science" // OpenDataHub defines display name in csv. OpenDataHub Platform = "Open Data Hub Operator" + // Unknown indicates that operator is not deployed using OLM + Unknown Platform = "" ) type Platform string @@ -41,7 +43,7 @@ func isSelfManaged(cli client.Client) (Platform, error) { } } } - return "", nil + return Unknown, nil } // isManagedRHODS checks if CRD add-on exists and contains string ManagedRhods. @@ -59,9 +61,9 @@ func isManagedRHODS(cli client.Client) (Platform, error) { err := cli.List(context.TODO(), expectedCatlogSource) if err != nil { if apierrs.IsNotFound(err) { - return "", nil + return Unknown, nil } else { - return "", err + return Unknown, err } } if len(expectedCatlogSource.Items) > 0 { @@ -78,7 +80,7 @@ func isManagedRHODS(cli client.Client) (Platform, error) { func GetPlatform(cli client.Client) (Platform, error) { // First check if its addon installation to return 'ManagedRhods, nil' if platform, err := isManagedRHODS(cli); err != nil { - return "", err + return Unknown, err } else if platform == ManagedRhods { return ManagedRhods, nil } diff --git a/pkg/feature/builder.go b/pkg/feature/builder.go new file mode 100644 index 00000000000..67621ddde44 --- /dev/null +++ b/pkg/feature/builder.go @@ -0,0 +1,185 @@ +package feature + +import ( + v1 "github.com/opendatahub-io/opendatahub-operator/v2/apis/dscinitialization/v1" + "github.com/pkg/errors" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type partialBuilder func(f *Feature) error + +type featureBuilder struct { + name string + builders []partialBuilder +} + +func CreateFeature(name string) *featureBuilder { + return &featureBuilder{name: name} +} + +func (fb *featureBuilder) For(spec *v1.DSCInitializationSpec) *featureBuilder { + createSpec := func(f *Feature) error { + f.Spec = &Spec{ + AppNamespace: spec.ApplicationsNamespace, + } + + return nil + } + + // Ensures creation of .Spec object is always invoked first + fb.builders = append([]partialBuilder{createSpec}, fb.builders...) + + return fb +} + +func (fb *featureBuilder) UsingConfig(config *rest.Config) *featureBuilder { + fb.builders = append(fb.builders, createClients(config)) + + return fb +} + +func createClients(config *rest.Config) partialBuilder { + return func(f *Feature) error { + var err error + f.Clientset, err = kubernetes.NewForConfig(config) + if err != nil { + return err + } + + f.DynamicClient, err = dynamic.NewForConfig(config) + if err != nil { + return err + } + + f.Client, err = client.New(config, client.Options{}) + if err != nil { + return errors.WithStack(err) + } + + if err := apiextv1.AddToScheme(f.Client.Scheme()); err != nil { + return err + } + + return nil + } +} + +func (fb *featureBuilder) Manifests(paths ...string) *featureBuilder { + fb.builders = append(fb.builders, func(f *Feature) error { + var err error + var manifests []manifest + + for _, path := range paths { + manifests, err = loadManifestsFrom(path) + if err != nil { + return errors.WithStack(err) + } + + f.manifests = append(f.manifests, manifests...) + } + + return nil + }) + + return fb +} + +func (fb *featureBuilder) WithData(loader ...Action) *featureBuilder { + fb.builders = append(fb.builders, func(f *Feature) error { + f.loaders = append(f.loaders, loader...) + + return nil + }) + + return fb +} + +func (fb *featureBuilder) PreConditions(preconditions ...Action) *featureBuilder { + fb.builders = append(fb.builders, func(f *Feature) error { + f.preconditions = append(f.preconditions, preconditions...) + + return nil + }) + + return fb +} + +func (fb *featureBuilder) PostConditions(postconditions ...Action) *featureBuilder { + fb.builders = append(fb.builders, func(f *Feature) error { + f.postconditions = append(f.postconditions, postconditions...) + + return nil + }) + + return fb +} + +func (fb *featureBuilder) OnDelete(cleanups ...Action) *featureBuilder { + fb.builders = append(fb.builders, func(f *Feature) error { + f.addCleanup(cleanups...) + + return nil + }) + + return fb +} + +func (fb *featureBuilder) WithResources(resources ...Action) *featureBuilder { + fb.builders = append(fb.builders, func(f *Feature) error { + f.resources = resources + + return nil + }) + + return fb +} + +func (fb *featureBuilder) Load() (*Feature, error) { + feature := &Feature{ + Name: fb.name, + Enabled: true, + } + + for i := range fb.builders { + if err := fb.builders[i](feature); err != nil { + return nil, err + } + } + + // UsingConfig builder wasn't called while constructing this feature. + // Get default settings and create needed clients. + if feature.Client == nil { + config, err := rest.InClusterConfig() + if errors.Is(err, rest.ErrNotInCluster) { + // rollback to local kubeconfig - this can be helpful when running the process locally i.e. while debugging + kubeconfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + &clientcmd.ClientConfigLoadingRules{ExplicitPath: clientcmd.RecommendedHomeFile}, + &clientcmd.ConfigOverrides{}, + ) + + config, err = kubeconfig.ClientConfig() + if err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + + if err := createClients(config)(feature); err != nil { + return nil, err + } + } + + if feature.Enabled { + if err := feature.createResourceTracker(); err != nil { + return feature, err + } + } + + return feature, nil +} diff --git a/pkg/feature/cert.go b/pkg/feature/cert.go new file mode 100644 index 00000000000..d0eb0797c9e --- /dev/null +++ b/pkg/feature/cert.go @@ -0,0 +1,95 @@ +package feature + +import ( + "bytes" + cryptorand "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "math/big" + "math/rand" + "net" + "strings" + "time" +) + +var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) + +func GenerateSelfSignedCertificateAsSecret(addr string, objectMeta metav1.ObjectMeta) (*corev1.Secret, error) { + cert, key, err := generateCertificate(addr) + if err != nil { + return nil, errors.WithStack(err) + } + + return &corev1.Secret{ + ObjectMeta: objectMeta, + Data: map[string][]byte{ + corev1.TLSCertKey: cert, + corev1.TLSPrivateKeyKey: key, + }, + }, nil +} + +func generateCertificate(addr string) ([]byte, []byte, error) { + key, err := rsa.GenerateKey(cryptorand.Reader, 2048) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + now := time.Now() + tmpl := x509.Certificate{ + SerialNumber: new(big.Int).SetInt64(seededRand.Int63()), + Subject: pkix.Name{ + CommonName: addr, + Organization: []string{"opendatahub-self-signed"}, + }, + NotBefore: now.UTC(), + NotAfter: now.Add(time.Second * 60 * 60 * 24 * 365).UTC(), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + + if ip := net.ParseIP(addr); ip != nil { + tmpl.IPAddresses = append(tmpl.IPAddresses, ip) + } else { + if strings.HasPrefix(addr, "*.") { + tmpl.DNSNames = append(tmpl.DNSNames, addr[2:]) + } + tmpl.DNSNames = append(tmpl.DNSNames, addr) + } + + tmpl.DNSNames = append(tmpl.DNSNames, "localhost") + + certDERBytes, err := x509.CreateCertificate(cryptorand.Reader, &tmpl, &tmpl, key.Public(), key) + if err != nil { + return nil, nil, errors.WithStack(err) + } + certificate, err := x509.ParseCertificate(certDERBytes) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + certBuffer := bytes.Buffer{} + if err := pem.Encode(&certBuffer, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certificate.Raw, + }); err != nil { + return nil, nil, errors.WithStack(err) + } + + keyBuffer := bytes.Buffer{} + if err := pem.Encode(&keyBuffer, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }); err != nil { + return nil, nil, errors.WithStack(err) + } + + return certBuffer.Bytes(), keyBuffer.Bytes(), nil +} diff --git a/pkg/feature/conditions.go b/pkg/feature/conditions.go new file mode 100644 index 00000000000..c58d5c6959f --- /dev/null +++ b/pkg/feature/conditions.go @@ -0,0 +1,83 @@ +package feature + +import ( + "context" + corev1 "k8s.io/api/core/v1" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + "time" +) + +const ( + interval = 2 * time.Second + duration = 5 * time.Minute +) + +func EnsureCRDIsInstalled(name string) Action { + return func(f *Feature) error { + return f.Client.Get(context.TODO(), client.ObjectKey{Name: name}, &apiextv1.CustomResourceDefinition{}) + } +} + +func WaitForPodsToBeReady(namespace string) Action { + return func(feature *Feature) error { + return wait.PollUntilContextTimeout(context.TODO(), interval, duration, false, func(ctx context.Context) (bool, error) { + log.Info("waiting for pods to become ready", "feature", feature.Name, "namespace", namespace, "duration (s)", duration.Seconds()) + podList, err := feature.Clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return false, err + } + + readyPods := 0 + totalPods := len(podList.Items) + + for _, pod := range podList.Items { + podReady := true + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady { + if condition.Status != corev1.ConditionTrue { + podReady = false + break + } + } + } + if podReady { + readyPods++ + } + } + + done := readyPods == totalPods + + if done { + log.Info("done waiting for pods to become ready", "feature", feature.Name, "namespace", namespace) + } + + return done, nil + }) + } +} + +func WaitForResourceToBeCreated(namespace string, gvr schema.GroupVersionResource) Action { + return func(feature *Feature) error { + return wait.PollUntilContextTimeout(context.TODO(), interval, duration, false, func(ctx context.Context) (bool, error) { + log.Info("waiting for resource to be created", "namespace", namespace, "resource", gvr) + + resources, err := feature.DynamicClient.Resource(gvr).Namespace(namespace).List(context.TODO(), metav1.ListOptions{Limit: 1}) + if err != nil { + log.Error(err, "failed waiting for resource", "namespace", namespace, "resource", gvr) + return false, err + } + + if len(resources.Items) > 0 { + log.Info("resource created", "namespace", namespace, "resource", gvr) + return true, nil + } + + log.Info("still waiting for resource", "namespace", namespace, "resource", gvr) + return false, nil + }) + } +} diff --git a/pkg/feature/feature.go b/pkg/feature/feature.go new file mode 100644 index 00000000000..339813ddd9c --- /dev/null +++ b/pkg/feature/feature.go @@ -0,0 +1,239 @@ +package feature + +import ( + "context" + "github.com/hashicorp/go-multierror" + v1 "github.com/opendatahub-io/opendatahub-operator/v2/apis/dscinitialization/v1" + "github.com/opendatahub-io/opendatahub-operator/v2/pkg/common" + "github.com/opendatahub-io/opendatahub-operator/v2/pkg/gvr" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrlLog "sigs.k8s.io/controller-runtime/pkg/log" +) + +var log = ctrlLog.Log.WithName("features") + +type Feature struct { + Name string + Spec *Spec + Enabled bool + + Clientset *kubernetes.Clientset + DynamicClient dynamic.Interface + Client client.Client + + manifests []manifest + cleanups []Action + resources []Action + preconditions []Action + postconditions []Action + loaders []Action +} + +// Action is a func type which can be used for different purposes while having access to Feature struct +type Action func(feature *Feature) error + +func (f *Feature) Apply() error { + if !f.Enabled { + log.Info("feature is disabled, skipping.", "feature", f.Name) + + return nil + } + + // Verify all precondition and collect errors + var multiErr *multierror.Error + for _, precondition := range f.preconditions { + multiErr = multierror.Append(multiErr, precondition(f)) + } + + if multiErr.ErrorOrNil() != nil { + return multiErr.ErrorOrNil() + } + + // Load necessary data + for _, loader := range f.loaders { + multiErr = multierror.Append(multiErr, loader(f)) + } + if multiErr.ErrorOrNil() != nil { + return multiErr.ErrorOrNil() + } + + // create or update resources + for _, resource := range f.resources { + if err := resource(f); err != nil { + return err + } + } + + // Process and apply manifests + for _, m := range f.manifests { + if err := m.processTemplate(f.Spec); err != nil { + return errors.WithStack(err) + } + + log.Info("applying manifest", "feature", f.Name, "path", m.targetPath()) + } + + if err := f.applyManifests(); err != nil { + return err + } + + for _, postcondition := range f.postconditions { + multiErr = multierror.Append(multiErr, postcondition(f)) + } + + if multiErr.ErrorOrNil() != nil { + return multiErr.ErrorOrNil() + } + + return nil +} + +func (f *Feature) Cleanup() error { + if !f.Enabled { + log.Info("feature is disabled, skipping.", "feature", f.Name) + + return nil + } + + var cleanupErrors *multierror.Error + for _, cleanupFunc := range f.cleanups { + cleanupErrors = multierror.Append(cleanupErrors, cleanupFunc(f)) + } + + return cleanupErrors.ErrorOrNil() +} + +func (f *Feature) applyManifests() error { + var applyErrors *multierror.Error + for _, m := range f.manifests { + err := f.apply(m) + applyErrors = multierror.Append(applyErrors, err) + } + + return applyErrors.ErrorOrNil() +} + +func (f *Feature) CreateConfigMap(cfgMapName string, data map[string]string) error { + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: cfgMapName, + Namespace: f.Spec.AppNamespace, + OwnerReferences: []metav1.OwnerReference{ + f.OwnerReference(), + }, + }, + Data: data, + } + + configMaps := f.Clientset.CoreV1().ConfigMaps(configMap.Namespace) + _, err := configMaps.Get(context.TODO(), configMap.Name, metav1.GetOptions{}) + if k8serrors.IsNotFound(err) { //nolint:gocritic + _, err = configMaps.Create(context.TODO(), configMap, metav1.CreateOptions{}) + if err != nil { + return err + } + } else if k8serrors.IsAlreadyExists(err) { + _, err = configMaps.Update(context.TODO(), configMap, metav1.UpdateOptions{}) + if err != nil { + return err + } + } else { + return err + } + + return nil +} + +func (f *Feature) addCleanup(cleanupFuncs ...Action) { + f.cleanups = append(f.cleanups, cleanupFuncs...) +} + +type apply func(filename string) error + +func (f *Feature) apply(m manifest) error { + var applier apply + targetPath := m.targetPath() + + if m.patch { + applier = func(filename string) error { + log.Info("patching using manifest", "feature", f.Name, "name", m.name, "path", targetPath) + + return f.patchResourceFromFile(filename) + } + } else { + applier = func(filename string) error { + log.Info("applying manifest", "feature", f.Name, "name", m.name, "path", targetPath) + + return f.createResourceFromFile(filename) + } + } + + if err := applier(targetPath); err != nil { + log.Error(err, "failed to create resource", "feature", f.Name, "name", m.name, "path", targetPath) + + return err + } + + return nil +} + +func (f *Feature) OwnerReference() metav1.OwnerReference { + return f.Spec.Tracker.ToOwnerReference() +} + +// createResourceTracker instantiates FeatureTracker for a given Feature. All resources created when applying +// it will have this object attached as an OwnerReference. +// It's a cluster-scoped resource. Once created, there's a cleanup hook added which will be invoked on deletion, resulting +// in removal of all owned resources which belong to this Feature. +func (f *Feature) createResourceTracker() error { + tracker := &v1.FeatureTracker{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "dscinitialization.opendatahub.io/v1", + Kind: "FeatureTracker", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: f.Spec.AppNamespace + "-" + common.TrimToRFC1123Name(f.Name), + }, + } + + foundTracker, err := f.DynamicClient.Resource(gvr.ResourceTracker).Get(context.TODO(), tracker.Name, metav1.GetOptions{}) + if k8serrors.IsNotFound(err) { + unstructuredTracker, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tracker) + if err != nil { + return err + } + + u := unstructured.Unstructured{Object: unstructuredTracker} + + foundTracker, err = f.DynamicClient.Resource(gvr.ResourceTracker).Create(context.TODO(), &u, metav1.CreateOptions{}) + if err != nil { + return err + } + } else if err != nil { + return err + } + + f.Spec.Tracker = &v1.FeatureTracker{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(foundTracker.Object, f.Spec.Tracker); err != nil { + return err + } + + // Register its own cleanup + f.addCleanup(func(feature *Feature) error { + if err := f.DynamicClient.Resource(gvr.ResourceTracker).Delete(context.TODO(), f.Spec.Tracker.Name, metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) { + return err + } + + return nil + }) + + return nil +} diff --git a/pkg/feature/feature_suite_test.go b/pkg/feature/feature_suite_test.go new file mode 100644 index 00000000000..014ccee2b35 --- /dev/null +++ b/pkg/feature/feature_suite_test.go @@ -0,0 +1,14 @@ +package feature_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestFeatures(t *testing.T) { + RegisterFailHandler(Fail) + // for integration tests see tests/integration directory + RunSpecs(t, "Features unit tests") +} diff --git a/pkg/feature/initializer.go b/pkg/feature/initializer.go new file mode 100644 index 00000000000..540707c93d6 --- /dev/null +++ b/pkg/feature/initializer.go @@ -0,0 +1,55 @@ +package feature + +import ( + "github.com/hashicorp/go-multierror" + v1 "github.com/opendatahub-io/opendatahub-operator/v2/apis/dscinitialization/v1" +) + +type FeaturesInitializer struct { + *v1.DSCInitializationSpec + definedFeatures DefinedFeatures + Features []*Feature +} + +type DefinedFeatures func(s *FeaturesInitializer) error + +func NewFeaturesInitializer(spec *v1.DSCInitializationSpec, def DefinedFeatures) *FeaturesInitializer { + return &FeaturesInitializer{ + DSCInitializationSpec: spec, + definedFeatures: def, + } +} + +// Prepare performs validation of the spec and ensures all resources, +// such as Features and their templates, are processed and initialized +// before proceeding with the actual cluster set-up. +func (s *FeaturesInitializer) Prepare() error { + log.Info("Initializing features") + + return s.definedFeatures(s) +} + +func (s *FeaturesInitializer) Apply() error { + var applyErrors *multierror.Error + + for _, f := range s.Features { + err := f.Apply() + applyErrors = multierror.Append(applyErrors, err) + } + + return applyErrors.ErrorOrNil() +} + +// Delete executes registered clean-up tasks in the opposite order they were initiated (following a stack structure). +// For instance, this allows for the undoing patches before its deletion. +// This approach assumes that Features are either instantiated in the correct sequence +// or are self-contained. +func (s *FeaturesInitializer) Delete() error { + var cleanupErrors *multierror.Error + for i := len(s.Features) - 1; i >= 0; i-- { + log.Info("cleanup", "name", s.Features[i].Name) + cleanupErrors = multierror.Append(cleanupErrors, s.Features[i].Cleanup()) + } + + return cleanupErrors.ErrorOrNil() +} diff --git a/pkg/feature/manifest.go b/pkg/feature/manifest.go new file mode 100644 index 00000000000..ac803c32486 --- /dev/null +++ b/pkg/feature/manifest.go @@ -0,0 +1,72 @@ +package feature + +import ( + "fmt" + "github.com/pkg/errors" + "html/template" + "os" + "path/filepath" + "strings" +) + +type manifest struct { + name, + path string + template, + patch, + processed bool +} + +func loadManifestsFrom(path string) ([]manifest, error) { + var manifests []manifest + if err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + basePath := filepath.Base(path) + manifests = append(manifests, manifest{ + name: basePath, + path: path, + patch: strings.Contains(basePath, ".patch"), + template: filepath.Ext(path) == ".tmpl", + }) + return nil + }); err != nil { + return nil, errors.WithStack(err) + } + + return manifests, nil +} + +func (m *manifest) targetPath() string { + return fmt.Sprintf("%s%s", m.path[:len(m.path)-len(filepath.Ext(m.path))], ".yaml") +} + +func (m *manifest) processTemplate(data interface{}) error { + if !m.template { + return nil + } + path := m.targetPath() + + f, err := os.Create(path) + if err != nil { + log.Error(err, "Failed to create file") + + return err + } + + tmpl := template.New(m.name).Funcs(template.FuncMap{"ReplaceChar": ReplaceChar}) + + tmpl, err = tmpl.ParseFiles(m.path) + if err != nil { + return err + } + + err = tmpl.Execute(f, data) + m.processed = err == nil + + return err +} diff --git a/pkg/feature/raw_resources.go b/pkg/feature/raw_resources.go new file mode 100644 index 00000000000..675553a74c9 --- /dev/null +++ b/pkg/feature/raw_resources.go @@ -0,0 +1,137 @@ +/* +Copyright (c) 2016-2017 Bitnami +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 feature + +import ( + "context" + "fmt" + "github.com/ghodss/yaml" + "github.com/pkg/errors" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "os" + "regexp" + "strings" +) + +const ( + YamlSeparator = "(?m)^---[ \t]*$" +) + +func (f *Feature) createResourceFromFile(filename string) error { + data, err := os.ReadFile(filename) + if err != nil { + return errors.WithStack(err) + } + splitter := regexp.MustCompile(YamlSeparator) + objectStrings := splitter.Split(string(data), -1) + for _, str := range objectStrings { + if strings.TrimSpace(str) == "" { + continue + } + u := &unstructured.Unstructured{} + if err := yaml.Unmarshal([]byte(str), u); err != nil { + return errors.WithStack(err) + } + + ensureNamespaceIsSet(f, u) + + name := u.GetName() + namespace := u.GetNamespace() + + u.SetOwnerReferences([]metav1.OwnerReference{ + f.OwnerReference(), + }) + + log.Info("Creating resource", "name", name) + + err := f.Client.Get(context.TODO(), k8stypes.NamespacedName{Name: name, Namespace: namespace}, u.DeepCopy()) + if err == nil { + log.Info("Object already exists...") + continue + } + if !k8serrors.IsNotFound(err) { + return errors.WithStack(err) + } + + err = f.Client.Create(context.TODO(), u) + if err != nil { + return errors.WithStack(err) + } + } + return nil +} + +func (f *Feature) patchResourceFromFile(filename string) error { + data, err := os.ReadFile(filename) + if err != nil { + return errors.WithStack(err) + } + splitter := regexp.MustCompile(YamlSeparator) + objectStrings := splitter.Split(string(data), -1) + for _, str := range objectStrings { + if strings.TrimSpace(str) == "" { + continue + } + u := &unstructured.Unstructured{} + if err := yaml.Unmarshal([]byte(str), u); err != nil { + log.Error(err, "error unmarshalling yaml") + return errors.WithStack(err) + } + + ensureNamespaceIsSet(f, u) + + gvr := schema.GroupVersionResource{ + Group: strings.ToLower(u.GroupVersionKind().Group), + Version: u.GroupVersionKind().Version, + Resource: strings.ToLower(u.GroupVersionKind().Kind) + "s", + } + + // Convert the patch from YAML to JSON + patchAsJSON, err := yaml.YAMLToJSON(data) + if err != nil { + log.Error(err, "error converting yaml to json") + return errors.WithStack(err) + } + + _, err = f.DynamicClient.Resource(gvr). + Namespace(u.GetNamespace()). + Patch(context.TODO(), u.GetName(), k8stypes.MergePatchType, patchAsJSON, metav1.PatchOptions{}) + if err != nil { + log.Error(err, "error patching resource", + "gvr", fmt.Sprintf("%+v\n", gvr), + "patch", fmt.Sprintf("%+v\n", u), + "json", fmt.Sprintf("%+v\n", patchAsJSON)) + return errors.WithStack(err) + } + + if err != nil { + return errors.WithStack(err) + } + } + + return nil +} + +// For any other than Namespace kind we set namespace to AppNamespace if it is not defined +// yet for the object +func ensureNamespaceIsSet(f *Feature, u *unstructured.Unstructured) { + namespace := u.GetNamespace() + if u.GetKind() != "Namespace" && namespace == "" { + u.SetNamespace(f.Spec.AppNamespace) + } +} diff --git a/pkg/feature/resources.go b/pkg/feature/resources.go new file mode 100644 index 00000000000..1470b41945d --- /dev/null +++ b/pkg/feature/resources.go @@ -0,0 +1,26 @@ +package feature + +import ( + "context" + + "github.com/opendatahub-io/opendatahub-operator/v2/pkg/cluster" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// CreateNamespace will create namespace with the given name if it does not exist yet and sets feature as an owner of it. +// This way we ensure that when the feature is cleaned up, the namespace will be deleted as well. +func CreateNamespace(namespace string) Action { + return func(f *Feature) error { + createdNs, err := cluster.CreateNamespace(f.Client, namespace) + if err != nil { + return err + } + + createdNs.SetOwnerReferences([]metav1.OwnerReference{f.OwnerReference()}) + + nsClient := f.Clientset.CoreV1().Namespaces() + _, err = nsClient.Update(context.TODO(), createdNs, metav1.UpdateOptions{}) + + return err + } +} diff --git a/pkg/feature/template_loader.go b/pkg/feature/template_loader.go new file mode 100644 index 00000000000..9d2fd53cf1e --- /dev/null +++ b/pkg/feature/template_loader.go @@ -0,0 +1,39 @@ +package feature + +import ( + "embed" + "io/fs" + "os" + "path/filepath" +) + +//go:embed templates +var embeddedFiles embed.FS + +// CopyEmbeddedFiles ensures that files embedded using go:embed are populated +// to dest directory. In order to process the templates, we need to create a tmp directory +// to store the files. This is because embedded files are read only. +func CopyEmbeddedFiles(src, dest string) error { + return fs.WalkDir(embeddedFiles, src, func(path string, dir fs.DirEntry, err error) error { + if err != nil { + return err + } + + destPath := filepath.Join(dest, path) + if dir.IsDir() { + if err := os.MkdirAll(destPath, 0755); err != nil { + return err + } + } else { + data, err := fs.ReadFile(embeddedFiles, path) + if err != nil { + return err + } + if err := os.WriteFile(destPath, data, 0644); err != nil { + return err + } + } + + return nil + }) +} diff --git a/pkg/feature/templates/namespace.patch.tmpl b/pkg/feature/templates/namespace.patch.tmpl new file mode 100644 index 00000000000..98b12916ebd --- /dev/null +++ b/pkg/feature/templates/namespace.patch.tmpl @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .AppNamespace }} + annotations: + opendatahub.io/service-mesh: "true" + \ No newline at end of file diff --git a/pkg/feature/types.go b/pkg/feature/types.go new file mode 100644 index 00000000000..af9c7223996 --- /dev/null +++ b/pkg/feature/types.go @@ -0,0 +1,26 @@ +package feature + +import ( + v1 "github.com/opendatahub-io/opendatahub-operator/v2/apis/dscinitialization/v1" + "strings" +) + +type Spec struct { + OAuth OAuth + AppNamespace string + Domain string + Tracker *v1.FeatureTracker +} + +type OAuth struct { + AuthzEndpoint, + TokenEndpoint, + Route, + Port, + ClientSecret, + Hmac string +} + +func ReplaceChar(s string, oldChar, newChar string) string { + return strings.ReplaceAll(s, oldChar, newChar) +} diff --git a/pkg/gvr/gvr.go b/pkg/gvr/gvr.go new file mode 100644 index 00000000000..a10b0fdae50 --- /dev/null +++ b/pkg/gvr/gvr.go @@ -0,0 +1,11 @@ +package gvr + +import "k8s.io/apimachinery/pkg/runtime/schema" + +var ( + ResourceTracker = schema.GroupVersionResource{ + Group: "dscinitialization.opendatahub.io", + Version: "v1", + Resource: "featuretrackers", + } +) diff --git a/tests/envtestutil/cleaner.go b/tests/envtestutil/cleaner.go new file mode 100644 index 00000000000..8c7a1a82152 --- /dev/null +++ b/tests/envtestutil/cleaner.go @@ -0,0 +1,131 @@ +package envtestutil + +import ( + "context" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + . "github.com/onsi/gomega" //nolint +) + +// Cleaner is a struct to perform deletion of resources, +// enforcing removal of finalizers. Otherwise deletion of namespaces wouldn't be possible. +// See: https://book.kubebuilder.io/reference/envtest.html#namespace-usage-limitation +// Based on https://github.com/kubernetes-sigs/controller-runtime/issues/880#issuecomment-749742403 +type Cleaner struct { + clientset *kubernetes.Clientset + client client.Client + timeout, interval time.Duration +} + +func CreateCleaner(c client.Client, config *rest.Config, timeout, interval time.Duration) *Cleaner { + k8sClient, err := kubernetes.NewForConfig(config) + if err != nil { + panic(err) + } + return &Cleaner{ + clientset: k8sClient, + client: c, + timeout: timeout, + interval: interval, + } +} + +func (c *Cleaner) DeleteAll(objects ...client.Object) { + for _, obj := range objects { + Expect(client.IgnoreNotFound(c.client.Delete(context.Background(), obj))).Should(Succeed()) + + if ns, ok := obj.(*corev1.Namespace); ok { + // Normally the kube-controller-manager would handle finalization + // and garbage collection of namespaces, but with envtest, we aren't + // running a kube-controller-manager. Instead, we're going to approximate + // (poorly) the kube-controller-manager by explicitly deleting some + // resources within the namespace and then removing the `kubernetes` + // finalizer from the namespace resource, so it can finish deleting. + // Note that any resources within the namespace that we don't + // successfully delete could reappear if the namespace is ever + // recreated with the same name. + + // Look up all namespaced resources under the discovery API + _, apiResources, err := c.clientset.DiscoveryClient.ServerGroupsAndResources() + Expect(err).ShouldNot(HaveOccurred()) + namespacedGVKs := make(map[string]schema.GroupVersionKind) + for _, apiResourceList := range apiResources { + defaultGV, err := schema.ParseGroupVersion(apiResourceList.GroupVersion) + Expect(err).ShouldNot(HaveOccurred()) + for _, r := range apiResourceList.APIResources { + if !r.Namespaced || strings.Contains(r.Name, "/") { + // skip non-namespaced and subresources + continue + } + gvk := schema.GroupVersionKind{ + Group: defaultGV.Group, + Version: defaultGV.Version, + Kind: r.Kind, + } + if r.Group != "" { + gvk.Group = r.Group + } + if r.Version != "" { + gvk.Version = r.Version + } + namespacedGVKs[gvk.String()] = gvk + } + } + + // Delete all namespaced resources in this namespace + for _, gvk := range namespacedGVKs { + var u unstructured.Unstructured + u.SetGroupVersionKind(gvk) + err := c.client.DeleteAllOf(context.Background(), &u, client.InNamespace(ns.Name)) + Expect(client.IgnoreNotFound(ignoreMethodNotAllowed(err))).ShouldNot(HaveOccurred()) + } + + Eventually(func() error { + key := client.ObjectKeyFromObject(ns) + + if err := c.client.Get(context.Background(), key, ns); err != nil { + return client.IgnoreNotFound(err) + } + // remove `kubernetes` finalizer + const k8s = "kubernetes" + finalizers := []corev1.FinalizerName{} + for _, f := range ns.Spec.Finalizers { + if f != k8s { + finalizers = append(finalizers, f) + } + } + ns.Spec.Finalizers = finalizers + + // We have to use the k8s.io/client-go library here to expose + // ability to patch the /finalize subresource on the namespace + _, err = c.clientset.CoreV1().Namespaces().Finalize(context.Background(), ns, metav1.UpdateOptions{}) + return err + }, c.timeout, c.interval).Should(Succeed()) + } + + Eventually(func() metav1.StatusReason { + key := client.ObjectKeyFromObject(obj) + if err := c.client.Get(context.Background(), key, obj); err != nil { + return apierrors.ReasonForError(err) + } + return "" + }, c.timeout, c.interval).Should(Equal(metav1.StatusReasonNotFound)) + } +} + +func ignoreMethodNotAllowed(err error) error { + if apierrors.ReasonForError(err) == metav1.StatusReasonMethodNotAllowed { + return nil + } + return err +} diff --git a/tests/envtestutil/name_gen.go b/tests/envtestutil/name_gen.go new file mode 100644 index 00000000000..68ccdc00bdb --- /dev/null +++ b/tests/envtestutil/name_gen.go @@ -0,0 +1,59 @@ +package envtestutil + +import ( + "crypto/rand" + "encoding/hex" + "math" + "math/big" +) + +var letters = []rune("abcdefghijklmnopqrstuvwxyz") + +func RandomUUIDName(len int) string { + uuidBytes := make([]byte, len) + _, _ = rand.Read(uuidBytes) + return hex.EncodeToString(uuidBytes)[:len] +} + +func AppendRandomNameTo(prefix string) string { + return ConcatToMax(63, prefix, GenerateString(16)) +} + +// GenerateString generates random alphabetical name which can be used for example as application or namespace name. +// Maximum length is capped at 63 characters. +// +// Don't forget to seed before using this function, e.g. rand.Seed(time.Now().UTC().UnixNano()) +// otherwise you will always get the same value. +func GenerateString(length int) string { + if length == 0 { + return "" + } + + if length > 63 { + length = 63 + } + + b := make([]rune, length) + for i := range b { + ri, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + b[i] = letters[ri.Int64()] + } + + return string(b) +} + +// ConcatToMax will cut each section to length based on number of sections to not go beyond max and separate the sections with -. +func ConcatToMax(max int, sections ...string) string { + sectionLength := (max - len(sections) - 1) / len(sections) + name := "" + + for i, section := range sections { + s := section[:int32(math.Min(float64(len(section)), float64(sectionLength)))] + name = name + "-" + s + if i+1 != len(sections) { + sectionLength = (max - len(name) - 1) / (len(sections) - (i + 1)) + } + } + + return name[1:] +} diff --git a/controllers/test/envtest_setup.go b/tests/envtestutil/utils.go similarity index 94% rename from controllers/test/envtest_setup.go rename to tests/envtestutil/utils.go index f786952c03e..3a6d2396479 100644 --- a/controllers/test/envtest_setup.go +++ b/tests/envtestutil/utils.go @@ -1,4 +1,4 @@ -package controllers_test +package envtestutil import ( "fmt" @@ -11,15 +11,19 @@ func FindProjectRoot() (string, error) { if err != nil { return "", err } + for { if _, err := os.Stat(filepath.Join(currentDir, "go.mod")); err == nil { return filepath.FromSlash(currentDir), nil } + parentDir := filepath.Dir(currentDir) if parentDir == currentDir { break } + currentDir = parentDir } + return "", fmt.Errorf("project root not found") } diff --git a/tests/integration/features/crd/test-resource.yaml b/tests/integration/features/crd/test-resource.yaml new file mode 100644 index 00000000000..02464948226 --- /dev/null +++ b/tests/integration/features/crd/test-resource.yaml @@ -0,0 +1,17 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: test-resources.openshift.io +spec: + group: openshift.io + versions: + - name: test-version + served: true + storage: true + schema: + openAPIV3Schema: + type: "object" + names: + plural: test-resources + kind: "testCRD" + scope: Namespaced diff --git a/tests/integration/features/features_int_test.go b/tests/integration/features/features_int_test.go new file mode 100644 index 00000000000..2d6741c7388 --- /dev/null +++ b/tests/integration/features/features_int_test.go @@ -0,0 +1,146 @@ +package features_test + +import ( + "context" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + dscv1 "github.com/opendatahub-io/opendatahub-operator/v2/apis/dscinitialization/v1" + "github.com/opendatahub-io/opendatahub-operator/v2/pkg/feature" + "github.com/opendatahub-io/opendatahub-operator/v2/tests/envtestutil" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "time" +) + +const ( + timeout = 5 * time.Second + interval = 250 * time.Millisecond +) + +var _ = Describe("preconditions", func() { + + Context("namespace existence", func() { + + var ( + objectCleaner *envtestutil.Cleaner + testFeature *feature.Feature + namespace string + ) + + BeforeEach(func() { + objectCleaner = envtestutil.CreateCleaner(envTestClient, envTest.Config, timeout, interval) + + testFeatureName := "test-ns-creation" + namespace = envtestutil.AppendRandomNameTo(testFeatureName) + + dsciSpec := newDSCInitializationSpec(namespace) + var err error + testFeature, err = feature.CreateFeature(testFeatureName). + For(dsciSpec). + UsingConfig(envTest.Config). + Load() + Expect(err).ToNot(HaveOccurred()) + }) + + It("should create namespace if it does not exist", func() { + // given + _, err := getNamespace(namespace) + Expect(errors.IsNotFound(err)).To(BeTrue()) + defer objectCleaner.DeleteAll(&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}) + + // when + err = feature.CreateNamespace(namespace)(testFeature) + + // then + Expect(err).ToNot(HaveOccurred()) + }) + + It("should not try to create namespace if it does already exist", func() { + // given + ns := createNamespace(namespace) + Expect(envTestClient.Create(context.Background(), ns)).To(Succeed()) + defer objectCleaner.DeleteAll(ns) + + // when + err := feature.CreateNamespace(namespace)(testFeature) + + // then + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("ensuring custom resource definitions are installed", func() { + + var ( + dsciSpec *dscv1.DSCInitializationSpec + verificationFeature *feature.Feature + ) + + BeforeEach(func() { + dsciSpec = newDSCInitializationSpec("default") + }) + + It("should successfully check for existing CRD", func() { + // given example CRD installed into env + name := "test-resources.openshift.io" + + var err error + verificationFeature, err = feature.CreateFeature("CRD verification"). + For(dsciSpec). + UsingConfig(envTest.Config). + PreConditions(feature.EnsureCRDIsInstalled(name)). + Load() + Expect(err).ToNot(HaveOccurred()) + + // when + err = verificationFeature.Apply() + + // then + Expect(err).ToNot(HaveOccurred()) + }) + + It("should fail to check non-existing CRD", func() { + // given + name := "non-existing-resource.non-existing-group.io" + + var err error + verificationFeature, err = feature.CreateFeature("CRD verification"). + For(dsciSpec). + UsingConfig(envTest.Config). + PreConditions(feature.EnsureCRDIsInstalled(name)). + Load() + Expect(err).ToNot(HaveOccurred()) + + // when + err = verificationFeature.Apply() + + // then + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("\"non-existing-resource.non-existing-group.io\" not found")) + }) + }) + +}) + +func createNamespace(name string) *v1.Namespace { + return &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } +} + +func newDSCInitializationSpec(ns string) *dscv1.DSCInitializationSpec { + spec := dscv1.DSCInitializationSpec{} + spec.ApplicationsNamespace = ns + return &spec +} + +func getNamespace(namespace string) (*v1.Namespace, error) { + ns := createNamespace(namespace) + err := envTestClient.Get(context.Background(), types.NamespacedName{Name: namespace}, ns) + + return ns, err +} diff --git a/tests/integration/features/features_suite_int_test.go b/tests/integration/features/features_suite_int_test.go new file mode 100644 index 00000000000..ee05ead0134 --- /dev/null +++ b/tests/integration/features/features_suite_int_test.go @@ -0,0 +1,81 @@ +package features_test + +import ( + "context" + "fmt" + "github.com/opendatahub-io/opendatahub-operator/v2/tests/envtestutil" + v1 "k8s.io/api/core/v1" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "math/rand" + "path/filepath" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/runtime" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +var ( + envTestClient client.Client + envTest *envtest.Environment + ctx context.Context + cancel context.CancelFunc +) + +var testScheme = runtime.NewScheme() + +func TestFeaturesIntegration(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Basic Features DSL integration tests") +} + +var _ = BeforeSuite(func() { + rand.Seed(time.Now().UTC().UnixNano()) + + ctx, cancel = context.WithCancel(context.TODO()) + + opts := zap.Options{Development: true} + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseFlagOptions(&opts))) + + By("Bootstrapping k8s test environment") + projectDir, err := envtestutil.FindProjectRoot() + if err != nil { + fmt.Printf("Error finding project root: %v\n", err) + return + } + + utilruntime.Must(v1.AddToScheme(testScheme)) + + envTest = &envtest.Environment{ + CRDInstallOptions: envtest.CRDInstallOptions{ + Scheme: testScheme, + Paths: []string{ + filepath.Join(projectDir, "config", "crd", "bases"), + filepath.Join(projectDir, "config", "crd", "dashboard-crds"), + filepath.Join(projectDir, "tests", "integration", "features", "crd"), + }, + ErrorIfPathMissing: true, + CleanUpAfterUse: false, + }, + } + + config, err := envTest.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(config).NotTo(BeNil()) + + envTestClient, err = client.New(config, client.Options{Scheme: testScheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(envTestClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("Tearing down the test environment") + cancel() + Expect(envTest.Stop()).To(Succeed()) +})