diff --git a/api/v1alpha1/templatemanagement_types.go b/api/v1alpha1/templatemanagement_types.go index d779ade88..e279fd96c 100644 --- a/api/v1alpha1/templatemanagement_types.go +++ b/api/v1alpha1/templatemanagement_types.go @@ -64,19 +64,20 @@ type AccessRule struct { ServiceTemplateChains []string `json:"serviceTemplateChains,omitempty"` } -// +kubebuilder:validation:XValidation:rule="((has(self.stringSelector) ? 1 : 0) + (has(self.selector) ? 1 : 0) + (has(self.list) ? 1 : 0)) <= 1", message="only one of spec.targetNamespaces.selector or spec.targetNamespaces.stringSelector spec.targetNamespaces.list can be specified" +// +kubebuilder:validation:XValidation:rule="((has(self.stringSelector) ? 1 : 0) + (has(self.selector) ? 1 : 0) + (has(self.list) ? 1 : 0)) <= 1", message="only one of spec.targetNamespaces.selector or spec.targetNamespaces.stringSelector or spec.targetNamespaces.list can be specified" // TargetNamespaces defines the list of namespaces or the label selector to select namespaces type TargetNamespaces struct { // StringSelector is a label query to select namespaces. - // Mutually exclusive with Selector. + // Mutually exclusive with Selector and List. // +optional StringSelector string `json:"stringSelector,omitempty"` // Selector is a structured label query to select namespaces. - // Mutually exclusive with StringSelector. + // Mutually exclusive with StringSelector and List. // +optional - Selector metav1.LabelSelector `json:"selector,omitempty"` + Selector *metav1.LabelSelector `json:"selector,omitempty"` // List is the list of namespaces to select. + // Mutually exclusive with StringSelector and Selector. // +optional List []string `json:"list,omitempty"` } @@ -88,6 +89,8 @@ type TemplateManagementStatus struct { ObservedGeneration int64 `json:"observedGeneration,omitempty"` // Current reflects the applied access rules configuration. Current []AccessRule `json:"current,omitempty"` + // Error is the error message occurred during the reconciliation (if any) + Error string `json:"error,omitempty"` } func init() { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 73c144c4e..e947f8aa2 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -806,7 +806,11 @@ func (in *SupportedTemplate) DeepCopy() *SupportedTemplate { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TargetNamespaces) DeepCopyInto(out *TargetNamespaces) { *out = *in - in.Selector.DeepCopyInto(&out.Selector) + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(metav1.LabelSelector) + (*in).DeepCopyInto(*out) + } if in.List != nil { in, out := &in.List, &out.List *out = make([]string, len(*in)) diff --git a/cmd/main.go b/cmd/main.go index 0233a22fd..bb01e07fd 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -22,14 +22,13 @@ import ( // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. - _ "k8s.io/client-go/plugin/pkg/client/auth" - hcv2 "github.com/fluxcd/helm-controller/api/v2" sourcev1 "github.com/fluxcd/source-controller/api/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/client-go/dynamic" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + _ "k8s.io/client-go/plugin/pkg/client/auth" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" @@ -171,12 +170,18 @@ func main() { currentNamespace := utils.CurrentNamespace() + templateReconciler := controller.TemplateReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + SystemNamespace: currentNamespace, + DefaultRegistryURL: defaultRegistryURL, + DefaultRepoType: determinedRepositoryType, + RegistryCredentialsSecret: registryCredentialsSecret, + InsecureRegistry: insecureRegistry, + } + if err = (&controller.ClusterTemplateReconciler{ - TemplateReconciler: controller.TemplateReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - SystemNamespace: currentNamespace, - }, + TemplateReconciler: templateReconciler, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ClusterTemplate") os.Exit(1) @@ -189,21 +194,13 @@ func main() { } if err = (&controller.ServiceTemplateReconciler{ - TemplateReconciler: controller.TemplateReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - SystemNamespace: currentNamespace, - }, + TemplateReconciler: templateReconciler, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ServiceTemplate") os.Exit(1) } if err = (&controller.ProviderTemplateReconciler{ - TemplateReconciler: controller.TemplateReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - SystemNamespace: currentNamespace, - }, + TemplateReconciler: templateReconciler, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ProviderTemplate") os.Exit(1) @@ -237,16 +234,12 @@ func main() { os.Exit(1) } if err = mgr.Add(&controller.Poller{ - Client: mgr.GetClient(), - Config: mgr.GetConfig(), - CreateManagement: createManagement, - CreateTemplates: createTemplates, - DefaultRegistryURL: defaultRegistryURL, - DefaultRepoType: determinedRepositoryType, - RegistryCredentialsSecret: registryCredentialsSecret, - InsecureRegistry: insecureRegistry, - HMCTemplatesChartName: hmcTemplatesChartName, - SystemNamespace: currentNamespace, + Client: mgr.GetClient(), + Config: mgr.GetConfig(), + CreateManagement: createManagement, + CreateTemplates: createTemplates, + HMCTemplatesChartName: hmcTemplatesChartName, + SystemNamespace: currentNamespace, }); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ReleaseController") os.Exit(1) @@ -284,6 +277,12 @@ func main() { setupLog.Error(err, "unable to create webhook", "webhook", "Management") os.Exit(1) } + if err := (&hmcwebhook.TemplateManagementValidator{ + SystemNamespace: currentNamespace, + }).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "TemplateManagement") + os.Exit(1) + } if err := (&hmcwebhook.ClusterTemplateValidator{}).SetupWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "ClusterTemplate") os.Exit(1) diff --git a/internal/controller/release_controller.go b/internal/controller/release_controller.go index 622ab3632..6ab294c04 100644 --- a/internal/controller/release_controller.go +++ b/internal/controller/release_controller.go @@ -22,7 +22,6 @@ import ( "time" hcv2 "github.com/fluxcd/helm-controller/api/v2" - "github.com/fluxcd/pkg/apis/meta" sourcev1 "github.com/fluxcd/source-controller/api/v1" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chartutil" @@ -57,16 +56,8 @@ type Poller struct { CreateManagement bool CreateTemplates bool - // DefaultRepoType is the type specified by default in HelmRepository - // objects. Valid types are 'default' for http/https repositories, and - // 'oci' for OCI repositories. The RepositoryType is set in main based on - // the URI scheme of the DefaultRegistryURL. - DefaultRepoType string - DefaultRegistryURL string - RegistryCredentialsSecret string - InsecureRegistry bool - HMCTemplatesChartName string - SystemNamespace string + HMCTemplatesChartName string + SystemNamespace string } func (p *Poller) Start(ctx context.Context) error { @@ -92,12 +83,7 @@ func (p *Poller) Tick(ctx context.Context) error { l.Info("Poll is run") defer l.Info("Poll is finished") - err := p.reconcileDefaultHelmRepo(ctx) - if err != nil { - l.Error(err, "failed to reconcile default HelmRepository") - return err - } - err = p.reconcileHMCTemplates(ctx) + err := p.reconcileHMCTemplates(ctx) if err != nil { l.Error(err, "failed to reconcile HMC Templates") return err @@ -176,42 +162,6 @@ func (p *Poller) ensureManagement(ctx context.Context) error { return nil } -func (p *Poller) reconcileDefaultHelmRepo(ctx context.Context) error { - l := log.FromContext(ctx) - helmRepo := &sourcev1.HelmRepository{ - ObjectMeta: metav1.ObjectMeta{ - Name: defaultRepoName, - Namespace: p.SystemNamespace, - }, - } - operation, err := ctrl.CreateOrUpdate(ctx, p.Client, helmRepo, func() error { - if helmRepo.Labels == nil { - helmRepo.Labels = make(map[string]string) - } - - helmRepo.Labels[hmc.HMCManagedLabelKey] = hmc.HMCManagedLabelValue - helmRepo.Spec = sourcev1.HelmRepositorySpec{ - Type: p.DefaultRepoType, - URL: p.DefaultRegistryURL, - Interval: metav1.Duration{Duration: helm.DefaultReconcileInterval}, - Insecure: p.InsecureRegistry, - } - if p.RegistryCredentialsSecret != "" { - helmRepo.Spec.SecretRef = &meta.LocalObjectReference{ - Name: p.RegistryCredentialsSecret, - } - } - return nil - }) - if err != nil { - return err - } - if operation == controllerutil.OperationResultCreated || operation == controllerutil.OperationResultUpdated { - l.Info(fmt.Sprintf("Successfully %s %s/%s HelmRepository", operation, p.SystemNamespace, defaultRepoName)) - } - return nil -} - func (p *Poller) reconcileHMCTemplates(ctx context.Context) error { l := log.FromContext(ctx) if !p.CreateTemplates { diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 718452e84..a9f97b927 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -141,7 +141,7 @@ var _ = BeforeSuite(func() { err = (&hmcwebhook.ManagementValidator{}).SetupWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) - err = hmcwebhook.SetupTemplateIndex(ctx, mgr) + err = (&hmcwebhook.TemplateManagementValidator{}).SetupWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) err = (&hmcwebhook.ClusterTemplateValidator{}).SetupWebhookWithManager(mgr) @@ -153,6 +153,9 @@ var _ = BeforeSuite(func() { err = (&hmcwebhook.ProviderTemplateValidator{}).SetupWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) + err = hmcwebhook.SetupTemplateIndex(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(ctx) diff --git a/internal/controller/template_controller.go b/internal/controller/template_controller.go index 54596bf08..4d5b909f6 100644 --- a/internal/controller/template_controller.go +++ b/internal/controller/template_controller.go @@ -22,6 +22,7 @@ import ( helmcontrollerv2 "github.com/fluxcd/helm-controller/api/v2" v2 "github.com/fluxcd/helm-controller/api/v2" + "github.com/fluxcd/pkg/apis/meta" sourcev1 "github.com/fluxcd/source-controller/api/v1" "helm.sh/helm/v3/pkg/chart" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -31,6 +32,7 @@ import ( "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" hmc "github.com/Mirantis/hmc/api/v1alpha1" @@ -44,8 +46,18 @@ const ( // TemplateReconciler reconciles a Template object type TemplateReconciler struct { client.Client - Scheme *runtime.Scheme - SystemNamespace string + Scheme *runtime.Scheme + SystemNamespace string + + // DefaultRepoType is the type specified by default in HelmRepository + // objects. Valid types are 'default' for http/https repositories, and + // 'oci' for OCI repositories. The RepositoryType is set in main based on + // the URI scheme of the DefaultRegistryURL. + DefaultRepoType string + DefaultRegistryURL string + RegistryCredentialsSecret string + InsecureRegistry bool + downloadHelmChartFunc func(context.Context, *sourcev1.Artifact) (*chart.Chart, error) } @@ -138,6 +150,13 @@ func (r *TemplateReconciler) ReconcileTemplate(ctx context.Context, template Tem l.Error(err, "invalid helm chart reference") return ctrl.Result{}, err } + if template.GetNamespace() == r.SystemNamespace || !templateManagedByHMC(template) { + err := r.reconcileDefaultHelmRepository(ctx, template.GetNamespace()) + if err != nil { + l.Error(err, "Failed to reconcile default HelmRepository", "namespace", template.GetNamespace()) + return ctrl.Result{}, err + } + } l.Info("Reconciling helm-controller objects ") hcChart, err = r.reconcileHelmChart(ctx, template) if err != nil { @@ -204,6 +223,14 @@ func (r *TemplateReconciler) ReconcileTemplate(ctx context.Context, template Tem return ctrl.Result{}, r.updateStatus(ctx, template, "") } +func templateManagedByHMC(template Template) bool { + labels := template.GetLabels() + if labels == nil { + return false + } + return labels[hmc.HMCManagedLabelKey] == hmc.HMCManagedLabelValue +} + func (r *TemplateReconciler) parseChartMetadata(template Template, chart *chart.Chart) error { if chart.Metadata == nil { return fmt.Errorf("chart metadata is empty") @@ -251,13 +278,47 @@ func (r *TemplateReconciler) updateStatus(ctx context.Context, template Template return nil } -func (r *TemplateReconciler) reconcileHelmChart(ctx context.Context, template Template) (*sourcev1.HelmChart, error) { - spec := template.GetSpec() - if spec.Helm.ChartRef != nil { - // HelmChart is not managed by the controller - return nil, nil +func (r *TemplateReconciler) reconcileDefaultHelmRepository(ctx context.Context, namespace string) error { + l := log.FromContext(ctx) + if namespace == "" { + namespace = r.SystemNamespace + } + helmRepo := &sourcev1.HelmRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultRepoName, + Namespace: namespace, + }, } + operation, err := ctrl.CreateOrUpdate(ctx, r.Client, helmRepo, func() error { + if helmRepo.Labels == nil { + helmRepo.Labels = make(map[string]string) + } + + helmRepo.Labels[hmc.HMCManagedLabelKey] = hmc.HMCManagedLabelValue + helmRepo.Spec = sourcev1.HelmRepositorySpec{ + Type: r.DefaultRepoType, + URL: r.DefaultRegistryURL, + Interval: metav1.Duration{Duration: helm.DefaultReconcileInterval}, + Insecure: r.InsecureRegistry, + } + if r.RegistryCredentialsSecret != "" { + helmRepo.Spec.SecretRef = &meta.LocalObjectReference{ + Name: r.RegistryCredentialsSecret, + } + } + return nil + }) + if err != nil { + return err + } + if operation == controllerutil.OperationResultCreated || operation == controllerutil.OperationResultUpdated { + l.Info(fmt.Sprintf("Successfully %s %s/%s HelmRepository", operation, namespace, defaultRepoName)) + } + return nil +} +func (r *TemplateReconciler) reconcileHelmChart(ctx context.Context, template Template) (*sourcev1.HelmChart, error) { + spec := template.GetSpec() namespace := template.GetNamespace() if namespace == "" { namespace = r.SystemNamespace diff --git a/internal/controller/templatemanagement_controller.go b/internal/controller/templatemanagement_controller.go index 092b34f44..df70e6bea 100644 --- a/internal/controller/templatemanagement_controller.go +++ b/internal/controller/templatemanagement_controller.go @@ -16,8 +16,13 @@ package controller import ( "context" + "errors" + "fmt" + helmcontrollerv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" @@ -25,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" hmc "github.com/Mirantis/hmc/api/v1alpha1" + "github.com/Mirantis/hmc/internal/templateutil" ) // TemplateManagementReconciler reconciles a TemplateManagement object @@ -35,8 +41,8 @@ type TemplateManagementReconciler struct { SystemNamespace string } -func (r *TemplateManagementReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - l := log.FromContext(ctx).WithValues("TemplateManagementController", req.NamespacedName) +func (r *TemplateManagementReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { + l := log.FromContext(ctx).WithValues("TemplateManagementController", req.Name) log.IntoContext(ctx, l) l.Info("Reconciling TemplateManagement") templateMgmt := &hmc.TemplateManagement{} @@ -48,9 +54,156 @@ func (r *TemplateManagementReconciler) Reconcile(ctx context.Context, req ctrl.R l.Error(err, "Failed to get TemplateManagement") return ctrl.Result{}, err } + + defer func() { + statusErr := "" + if err != nil { + statusErr = err.Error() + } + templateMgmt.Status.Error = statusErr + templateMgmt.Status.ObservedGeneration = templateMgmt.Generation + err = errors.Join(err, r.updateStatus(ctx, templateMgmt)) + }() + + currentState, err := templateutil.GetCurrentTemplatesState(ctx, r.Client, r.SystemNamespace) + if err != nil { + return ctrl.Result{}, err + } + expectedState, err := templateutil.ParseAccessRules(ctx, r.Client, templateMgmt.Spec.AccessRules, currentState) + if err != nil { + return ctrl.Result{}, err + } + + var errs error + err = r.distributeTemplates(ctx, expectedState.ClusterTemplatesState, templateutil.ClusterTemplateKind) + if err != nil { + errs = errors.Join(errs, err) + } + err = r.distributeTemplates(ctx, expectedState.ServiceTemplatesState, templateutil.ServiceTemplateKind) + if err != nil { + errs = errors.Join(errs, err) + } + if errs != nil { + return ctrl.Result{}, errs + } + + templateMgmt.Status.Current = templateMgmt.Spec.AccessRules return ctrl.Result{}, nil } +func (r *TemplateManagementReconciler) updateStatus(ctx context.Context, templateMgmt *hmc.TemplateManagement) error { + if err := r.Status().Update(ctx, templateMgmt); err != nil { + return fmt.Errorf("failed to update status for TemplateManagement %s: %w", templateMgmt.Name, err) + } + return nil +} + +func (r *TemplateManagementReconciler) distributeTemplates(ctx context.Context, state map[string]map[string]bool, kind string) error { + var errs error + for name, namespaces := range state { + err := r.applyTemplates(ctx, kind, name, namespaces) + if err != nil { + errs = errors.Join(errs, err) + } + } + if errs != nil { + return errs + } + return nil +} + +func (r *TemplateManagementReconciler) applyTemplates(ctx context.Context, kind string, name string, namespaces map[string]bool) error { + l := log.FromContext(ctx) + meta := metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + hmc.HMCManagedLabelKey: hmc.HMCManagedLabelValue, + }, + } + + sourceFound := false + chartName := "" + + switch kind { + case templateutil.ClusterTemplateKind: + source := &hmc.ClusterTemplate{} + err := r.Get(ctx, client.ObjectKey{ + Namespace: r.SystemNamespace, + Name: name, + }, source) + if err == nil { + sourceFound = true + chartName = source.Spec.Helm.ChartName + } else if !apierrors.IsNotFound(err) { + return err + } + case templateutil.ServiceTemplateKind: + source := &hmc.ServiceTemplate{} + err := r.Get(ctx, client.ObjectKey{ + Namespace: r.SystemNamespace, + Name: name, + }, source) + if err == nil { + sourceFound = true + chartName = source.Spec.Helm.ChartName + } else if !apierrors.IsNotFound(err) { + return err + } + default: + return fmt.Errorf("invalid kind %s. Only %s or %s kinds are supported", kind, templateutil.ClusterTemplateKind, templateutil.ServiceTemplateKind) + } + + spec := hmc.TemplateSpecMixin{ + Helm: hmc.HelmSpec{ + ChartRef: &helmcontrollerv2.CrossNamespaceSourceReference{ + Kind: sourcev1.HelmChartKind, + Name: chartName, + Namespace: r.SystemNamespace, + }, + }, + } + var errs error + for ns, keep := range namespaces { + var target client.Object + meta.Namespace = ns + if kind == templateutil.ClusterTemplateKind { + target = &hmc.ClusterTemplate{ObjectMeta: meta, Spec: hmc.ClusterTemplateSpec{TemplateSpecMixin: spec}} + } + if kind == templateutil.ServiceTemplateKind { + target = &hmc.ServiceTemplate{ObjectMeta: meta, Spec: hmc.ServiceTemplateSpec{TemplateSpecMixin: spec}} + } + if keep { + if !sourceFound { + errs = errors.Join(errs, fmt.Errorf("source %s %s/%s is not found", kind, r.SystemNamespace, name)) + continue + } + l.Info(fmt.Sprintf("Creating %s", kind), "namespace", ns, "name", name) + err := r.Create(ctx, target) + if err == nil { + l.Info(fmt.Sprintf("%s was successfully created", kind), "namespace", ns, "name", name) + continue + } + if !apierrors.IsAlreadyExists(err) { + errs = errors.Join(errs, err) + } + } else { + l.Info(fmt.Sprintf("Deleting %s", kind), "namespace", ns, "name", name) + err := r.Delete(ctx, target) + if err == nil { + l.Info(fmt.Sprintf("%s was deleted", kind), "namespace", ns, "name", name) + continue + } + if !apierrors.IsNotFound(err) { + errs = errors.Join(errs, err) + } + } + } + if errs != nil { + return errs + } + return nil +} + // SetupWithManager sets up the controller with the Manager. func (r *TemplateManagementReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). diff --git a/internal/controller/templatemanagement_controller_test.go b/internal/controller/templatemanagement_controller_test.go index 73a5968de..4e4d5e8a0 100644 --- a/internal/controller/templatemanagement_controller_test.go +++ b/internal/controller/templatemanagement_controller_test.go @@ -19,62 +19,275 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + 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" + crclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - hmcmirantiscomv1alpha1 "github.com/Mirantis/hmc/api/v1alpha1" + "github.com/Mirantis/hmc/test/objects/template" + chain "github.com/Mirantis/hmc/test/objects/templatechain" + tm "github.com/Mirantis/hmc/test/objects/templatemanagement" ) var _ = Describe("Template Management Controller", func() { Context("When reconciling a resource", func() { - const resourceName = "test-resource" + const ( + tmName = "hmc-tm" + ctChainName = "hmc-ct-chain" + stChainName = "hmc-st-chain" + + namespace1Name = "namespace1" + namespace2Name = "namespace2" + namespace3Name = "namespace3" + + ct1Name = "tmpl" + ct2Name = "ct2" + ct3Name = "ct3" + ctUnmanagedName = "ct-unmanaged" + + st1Name = "tmpl" + st2Name = "st2" + st3Name = "st3" + stUnmanagedName = "st-unmanaged" + ) ctx := context.Background() - typeNamespacedName := types.NamespacedName{ - Name: resourceName, + systemNamespace := &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "hmc", + }, + } + + namespace1 := &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace1Name, + Labels: map[string]string{"environment": "dev", "test": "test"}, + }, + } + namespace2 := &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace2Name, + Labels: map[string]string{"environment": "prod"}, + }, + } + namespace3 := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace3Name}} + + accessRules := []hmcmirantiscomv1alpha1.AccessRule{ + { + // Target namespaces: namespace1, namespace2 + // ClusterTemplates: ct1, ct2 + TargetNamespaces: hmcmirantiscomv1alpha1.TargetNamespaces{ + Selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "environment", + Operator: metav1.LabelSelectorOpIn, + Values: []string{"prod", "dev"}, + }, + }, + }, + }, + ClusterTemplateChains: []string{ctChainName}, + }, + { + // Target namespace: namespace1 + // ClusterTemplates: ct1, ct2 + // ServiceTemplates: st1, st2 + TargetNamespaces: hmcmirantiscomv1alpha1.TargetNamespaces{ + StringSelector: "environment=dev", + }, + ClusterTemplateChains: []string{ctChainName}, + ServiceTemplateChains: []string{stChainName}, + }, + { + // Target namespace: namespace3 + // ServiceTemplates: st1, st2 + TargetNamespaces: hmcmirantiscomv1alpha1.TargetNamespaces{ + List: []string{namespace3Name}, + }, + ServiceTemplateChains: []string{stChainName}, + }, } - templateMgmt := &hmcmirantiscomv1alpha1.TemplateManagement{} + + tm := tm.NewTemplateManagement( + tm.WithName(tmName), + tm.WithAccessRules(accessRules), + ) + + supportedClusterTemplates := []hmcmirantiscomv1alpha1.SupportedTemplate{ + {Name: ct1Name}, + {Name: ct2Name}, + } + ctChain := chain.NewClusterTemplateChain( + chain.WithName(ctChainName), + chain.WithSupportedTemplates(supportedClusterTemplates), + ) + + supportedServiceTemplates := []hmcmirantiscomv1alpha1.SupportedTemplate{ + {Name: st1Name}, + {Name: st2Name}, + } + stChain := chain.NewServiceTemplateChain( + chain.WithName(stChainName), + chain.WithSupportedTemplates(supportedServiceTemplates), + ) + + templateHelmSpec := hmcmirantiscomv1alpha1.HelmSpec{ChartName: "test"} + ct1 := template.NewClusterTemplate(template.WithName(ct1Name), template.WithNamespace(systemNamespace.Name), template.WithHelmSpec(templateHelmSpec)) + ct2 := template.NewClusterTemplate(template.WithName(ct2Name), template.WithNamespace(systemNamespace.Name), template.WithHelmSpec(templateHelmSpec)) + ct3 := template.NewClusterTemplate( + template.WithName(ct3Name), + template.WithNamespace(namespace2Name), + template.WithHelmSpec(templateHelmSpec), + template.WithLabels(map[string]string{hmcmirantiscomv1alpha1.HMCManagedLabelKey: hmcmirantiscomv1alpha1.HMCManagedLabelValue}), + ) + ctUnmanaged := template.NewClusterTemplate(template.WithName(ctUnmanagedName), template.WithNamespace(namespace1Name), template.WithHelmSpec(templateHelmSpec)) + + st1 := template.NewServiceTemplate(template.WithName(st1Name), template.WithNamespace(systemNamespace.Name), template.WithHelmSpec(templateHelmSpec)) + st2 := template.NewServiceTemplate(template.WithName(st2Name), template.WithNamespace(systemNamespace.Name), template.WithHelmSpec(templateHelmSpec)) + st3 := template.NewServiceTemplate( + template.WithName(st3Name), + template.WithNamespace(namespace2Name), + template.WithHelmSpec(templateHelmSpec), + template.WithLabels(map[string]string{hmcmirantiscomv1alpha1.HMCManagedLabelKey: hmcmirantiscomv1alpha1.HMCManagedLabelValue}), + ) + stUnmanaged := template.NewServiceTemplate(template.WithName(stUnmanagedName), template.WithNamespace(namespace2Name), template.WithHelmSpec(templateHelmSpec)) BeforeEach(func() { + By("creating test namespaces") + var err error + for _, ns := range []*v1.Namespace{systemNamespace, namespace1, namespace2, namespace3} { + err = k8sClient.Get(ctx, types.NamespacedName{Name: ns.Name}, ns) + if err != nil && errors.IsNotFound(err) { + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + } + } By("creating the custom resource for the Kind TemplateManagement") - err := k8sClient.Get(ctx, typeNamespacedName, templateMgmt) + err = k8sClient.Get(ctx, types.NamespacedName{Name: tmName}, tm) if err != nil && errors.IsNotFound(err) { - resource := &hmcmirantiscomv1alpha1.TemplateManagement{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - }, - // TODO(user): Specify other spec details if needed. + Expect(k8sClient.Create(ctx, tm)).To(Succeed()) + } + By("creating the custom resource for the Kind ClusterTemplateChain") + err = k8sClient.Get(ctx, types.NamespacedName{Name: ctChainName}, ctChain) + if err != nil && errors.IsNotFound(err) { + Expect(k8sClient.Create(ctx, ctChain)).To(Succeed()) + } + By("creating the custom resource for the Kind ServiceTemplateChain") + err = k8sClient.Get(ctx, types.NamespacedName{Name: stChainName}, stChain) + if err != nil && errors.IsNotFound(err) { + Expect(k8sClient.Create(ctx, stChain)).To(Succeed()) + } + By("creating ClusterTemplates and ServiceTemplates") + for _, template := range []crclient.Object{ct1, ct2, ct3, ctUnmanaged, st1, st2, st3, stUnmanaged} { + err = k8sClient.Get(ctx, types.NamespacedName{Name: template.GetName(), Namespace: template.GetNamespace()}, template) + if err != nil && errors.IsNotFound(err) { + Expect(k8sClient.Create(ctx, template)).To(Succeed()) } - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) } }) AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. - resource := &hmcmirantiscomv1alpha1.TemplateManagement{} - err := k8sClient.Get(ctx, typeNamespacedName, resource) - Expect(err).NotTo(HaveOccurred()) + for _, ns := range []*v1.Namespace{namespace1, namespace2, namespace3} { + err := k8sClient.Get(ctx, types.NamespacedName{Name: ns.Name}, ns) + Expect(err).NotTo(HaveOccurred()) + By("Cleanup the namespace") + Expect(k8sClient.Delete(ctx, ns)).To(Succeed()) + } + tm := &hmcmirantiscomv1alpha1.TemplateManagement{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: tmName}, tm) + Expect(err).NotTo(HaveOccurred()) By("Cleanup the specific resource instance TemplateManagement") - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, tm)).To(Succeed()) + + ctChain := &hmcmirantiscomv1alpha1.ClusterTemplateChain{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: ctChainName}, ctChain) + Expect(err).NotTo(HaveOccurred()) + By("Cleanup the specific resource instance ClusterTemplateChain") + Expect(k8sClient.Delete(ctx, ctChain)).To(Succeed()) + + stChain := &hmcmirantiscomv1alpha1.ServiceTemplateChain{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: stChainName}, stChain) + Expect(err).NotTo(HaveOccurred()) + By("Cleanup the specific resource instance ServiceTemplateChain") + Expect(k8sClient.Delete(ctx, stChain)).To(Succeed()) }) It("should successfully reconcile the resource", func() { + By("Get unmanaged templates before the reconciliation to verify it wasn't changed") + ctUnmanagedBefore := &hmcmirantiscomv1alpha1.ClusterTemplate{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace1Name, Name: ctUnmanaged.Name}, ctUnmanagedBefore) + Expect(err).NotTo(HaveOccurred()) + stUnmanagedBefore := &hmcmirantiscomv1alpha1.ServiceTemplate{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace2Name, Name: stUnmanaged.Name}, stUnmanagedBefore) + Expect(err).NotTo(HaveOccurred()) + By("Reconciling the created resource") controllerReconciler := &TemplateManagementReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + Client: k8sClient, + Scheme: k8sClient.Scheme(), + SystemNamespace: systemNamespace.Name, } - - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: tmName}, }) Expect(err).NotTo(HaveOccurred()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. + /* + Expected state: + * namespace1/ct1 - should be created + * namespace1/ct2 - should be created + * namespace1/ctUnmanaged - should be unchanged (unmanaged by HMC) + * namespace1/st1 - should be created + * namespace1/st2 - should be created + + * namespace2/ct1 - should be created + * namespace2/ct2 - should be created + * namespace2/ct3 - should be deleted + * namespace2/st3 - should be deleted + * namespace2/stUnmanaged - should be unchanged (unmanaged by HMC) + + * namespace3/st1 - should be created + * namespace3/st2 - should be created + */ + verifyTemplateCreated(ctx, namespace1Name, ct1) + verifyTemplateCreated(ctx, namespace1Name, ct2) + verifyTemplateUnchanged(ctx, namespace1Name, ctUnmanagedBefore, ctUnmanaged) + verifyTemplateCreated(ctx, namespace1Name, st1) + verifyTemplateCreated(ctx, namespace1Name, st2) + + verifyTemplateCreated(ctx, namespace2Name, ct1) + verifyTemplateCreated(ctx, namespace2Name, ct2) + verifyTemplateDeleted(ctx, namespace2Name, ct3) + verifyTemplateDeleted(ctx, namespace2Name, st3) + verifyTemplateUnchanged(ctx, namespace2Name, stUnmanagedBefore, stUnmanaged) + + verifyTemplateCreated(ctx, namespace3Name, st1) + verifyTemplateCreated(ctx, namespace3Name, st2) }) }) }) + +func verifyTemplateCreated(ctx context.Context, namespace string, template crclient.Object) { + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: template.GetName()}, template) + Expect(err).NotTo(HaveOccurred()) + checkHMCManagedLabelExistence(template.GetLabels()) +} + +func verifyTemplateDeleted(ctx context.Context, namespace string, template crclient.Object) { + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: template.GetName()}, template) + Expect(err).To(HaveOccurred()) +} + +func verifyTemplateUnchanged(ctx context.Context, namespace string, oldTemplate, newTemplate crclient.Object) { + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: newTemplate.GetName()}, newTemplate) + Expect(err).NotTo(HaveOccurred()) + Expect(oldTemplate).To(Equal(newTemplate)) + Expect(newTemplate.GetLabels()).NotTo(HaveKeyWithValue(hmcmirantiscomv1alpha1.HMCManagedLabelKey, hmcmirantiscomv1alpha1.HMCManagedLabelValue)) +} + +func checkHMCManagedLabelExistence(labels map[string]string) { + Expect(labels).To(HaveKeyWithValue(hmcmirantiscomv1alpha1.HMCManagedLabelKey, hmcmirantiscomv1alpha1.HMCManagedLabelValue)) +} diff --git a/internal/templateutil/state.go b/internal/templateutil/state.go new file mode 100644 index 000000000..b46a07582 --- /dev/null +++ b/internal/templateutil/state.go @@ -0,0 +1,195 @@ +// Copyright 2024 +// +// 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 templateutil + +import ( + "context" + "errors" + "fmt" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + hmc "github.com/Mirantis/hmc/api/v1alpha1" +) + +const ( + ClusterTemplateKind = "ClusterTemplate" + ServiceTemplateKind = "ServiceTemplate" +) + +type State struct { + // ClusterTemplatesState is a map where keys are ClusterTemplate names and values is the map of namespaces + // where this ClusterTemplate should be distributed + ClusterTemplatesState map[string]map[string]bool + // ServiceTemplatesState is a map where keys are ServiceTemplate names and values is the map of namespaces + // where this ServiceTemplate should be distributed + ServiceTemplatesState map[string]map[string]bool +} + +func GetCurrentTemplatesState(ctx context.Context, cl client.Client, systemNamespace string) (State, error) { + listOpts := &client.ListOptions{ + LabelSelector: labels.SelectorFromSet(map[string]string{hmc.HMCManagedLabelKey: hmc.HMCManagedLabelValue}), + Namespace: "", + } + clusterTemplatesList, serviceTemplatesList := &metav1.PartialObjectMetadataList{}, &metav1.PartialObjectMetadataList{} + + for _, kind := range []string{ClusterTemplateKind, ServiceTemplateKind} { + partialList := &metav1.PartialObjectMetadataList{} + partialList.SetGroupVersionKind(schema.GroupVersionKind{ + Group: hmc.GroupVersion.Group, + Version: hmc.GroupVersion.Version, + Kind: kind, + }) + err := cl.List(ctx, partialList, listOpts) + if err != nil { + return State{}, err + } + if kind == ClusterTemplateKind { + clusterTemplatesList = partialList + } + if kind == ServiceTemplateKind { + serviceTemplatesList = partialList + } + } + + clusterTemplates, serviceTemplates := make(map[string]map[string]bool), make(map[string]map[string]bool) + for _, ct := range clusterTemplatesList.Items { + if ct.Namespace == systemNamespace { + continue + } + if clusterTemplates[ct.Name] == nil { + clusterTemplates[ct.Name] = make(map[string]bool) + } + clusterTemplates[ct.Name][ct.Namespace] = false + } + for _, st := range serviceTemplatesList.Items { + if st.Namespace == systemNamespace { + continue + } + if serviceTemplates[st.Name] == nil { + serviceTemplates[st.Name] = make(map[string]bool) + } + serviceTemplates[st.Name][st.Namespace] = false + } + return State{ + ClusterTemplatesState: clusterTemplates, + ServiceTemplatesState: serviceTemplates, + }, nil +} + +func ParseAccessRules(ctx context.Context, cl client.Client, rules []hmc.AccessRule, currentState State) (State, error) { + var errs error + + expectedState := currentState + if expectedState.ClusterTemplatesState == nil { + expectedState.ClusterTemplatesState = make(map[string]map[string]bool) + } + if expectedState.ServiceTemplatesState == nil { + expectedState.ServiceTemplatesState = make(map[string]map[string]bool) + } + for _, rule := range rules { + var clusterTemplates []string + var serviceTemplates []string + for _, ctChainName := range rule.ClusterTemplateChains { + ctChain := &hmc.ClusterTemplateChain{} + err := cl.Get(ctx, types.NamespacedName{ + Name: ctChainName, + }, ctChain) + if err != nil { + errs = errors.Join(errs, err) + continue + } + for _, supportedTemplate := range ctChain.Spec.SupportedTemplates { + clusterTemplates = append(clusterTemplates, supportedTemplate.Name) + } + } + for _, stChainName := range rule.ServiceTemplateChains { + stChain := &hmc.ServiceTemplateChain{} + err := cl.Get(ctx, types.NamespacedName{ + Name: stChainName, + }, stChain) + if err != nil { + errs = errors.Join(errs, err) + continue + } + for _, supportedTemplate := range stChain.Spec.SupportedTemplates { + serviceTemplates = append(serviceTemplates, supportedTemplate.Name) + } + } + namespaces, err := getTargetNamespaces(ctx, cl, rule.TargetNamespaces) + if err != nil { + return State{}, err + } + for _, ct := range clusterTemplates { + if expectedState.ClusterTemplatesState[ct] == nil { + expectedState.ClusterTemplatesState[ct] = make(map[string]bool) + } + for _, ns := range namespaces { + expectedState.ClusterTemplatesState[ct][ns] = true + } + } + for _, st := range serviceTemplates { + if expectedState.ServiceTemplatesState[st] == nil { + expectedState.ServiceTemplatesState[st] = make(map[string]bool) + } + for _, ns := range namespaces { + expectedState.ServiceTemplatesState[st][ns] = true + } + } + } + if errs != nil { + return State{}, errs + } + return expectedState, nil +} + +func getTargetNamespaces(ctx context.Context, cl client.Client, targetNamespaces hmc.TargetNamespaces) ([]string, error) { + if len(targetNamespaces.List) > 0 { + return targetNamespaces.List, nil + } + var selector labels.Selector + var err error + if targetNamespaces.StringSelector != "" { + selector, err = labels.Parse(targetNamespaces.StringSelector) + if err != nil { + return nil, err + } + } else { + selector, err = metav1.LabelSelectorAsSelector(targetNamespaces.Selector) + if err != nil { + return nil, fmt.Errorf("failed to construct selector from the namespaces selector %s: %w", targetNamespaces.Selector, err) + } + } + + namespaces := &v1.NamespaceList{} + listOpts := &client.ListOptions{} + if selector.String() != "" { + listOpts = &client.ListOptions{LabelSelector: selector} + } + err = cl.List(ctx, namespaces, listOpts) + if err != nil { + return []string{}, err + } + result := make([]string, len(namespaces.Items)) + for i, ns := range namespaces.Items { + result[i] = ns.Name + } + return result, nil +} diff --git a/internal/webhook/templatemanagement_webhook.go b/internal/webhook/templatemanagement_webhook.go new file mode 100644 index 000000000..96abea9db --- /dev/null +++ b/internal/webhook/templatemanagement_webhook.go @@ -0,0 +1,123 @@ +// Copyright 2024 +// +// 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 webhook // nolint:dupl + +import ( + "context" + "fmt" + "sort" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/Mirantis/hmc/api/v1alpha1" + "github.com/Mirantis/hmc/internal/templateutil" +) + +type TemplateManagementValidator struct { + client.Client + SystemNamespace string +} + +func (v *TemplateManagementValidator) SetupWebhookWithManager(mgr ctrl.Manager) error { + v.Client = mgr.GetClient() + return ctrl.NewWebhookManagedBy(mgr). + For(&v1alpha1.TemplateManagement{}). + WithValidator(v). + WithDefaulter(v). + Complete() +} + +var ( + _ webhook.CustomValidator = &TemplateManagementValidator{} + _ webhook.CustomDefaulter = &TemplateManagementValidator{} +) + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type. +func (*TemplateManagementValidator) ValidateCreate(_ context.Context, _ runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type. +func (v *TemplateManagementValidator) ValidateUpdate(ctx context.Context, _ runtime.Object, newObj runtime.Object) (admission.Warnings, error) { + newTm, ok := newObj.(*v1alpha1.TemplateManagement) + if !ok { + return nil, apierrors.NewBadRequest(fmt.Sprintf("expected TemplateManagement but got a %T", newObj)) + } + currentState, err := templateutil.GetCurrentTemplatesState(ctx, v.Client, v.SystemNamespace) + if err != nil { + return nil, fmt.Errorf("could not get current templates state: %v", err) + } + + expectedState, err := templateutil.ParseAccessRules(ctx, v.Client, newTm.Spec.AccessRules, currentState) + if err != nil { + return nil, fmt.Errorf("failed to parse access rules for TemplateManagement: %v", err) + } + + warnings := admission.Warnings{} + for templateName, namespaces := range expectedState.ClusterTemplatesState { + for namespace, keep := range namespaces { + if !keep { + managedClusters, err := getManagedClustersForTemplate(ctx, v.Client, namespace, templateName) + if err != nil { + return nil, err + } + + if len(managedClusters) > 0 { + errMsg := fmt.Sprintf("ClusterTemplate \"%s/%s\" can't be removed: found ManagedClusters that reference it: ", namespace, templateName) + sort.Slice(managedClusters, func(i, j int) bool { + return managedClusters[i].Name < managedClusters[j].Name + }) + for _, cluster := range managedClusters { + errMsg += fmt.Sprintf("\"%s/%s\", ", cluster.Namespace, cluster.Name) + } + warnings = append(warnings, strings.TrimRight(errMsg, ", ")) + } + } + } + } + if len(warnings) > 0 { + sort.Strings(warnings) + return warnings, fmt.Errorf("can not apply new access rules") + } + return nil, nil +} + +func getManagedClustersForTemplate(ctx context.Context, cl client.Client, namespace, templateName string) ([]v1alpha1.ManagedCluster, error) { + managedClusters := &v1alpha1.ManagedClusterList{} + err := cl.List(ctx, managedClusters, + client.InNamespace(namespace), + client.MatchingFields{TemplateKey: templateName}, + ) + if err != nil { + return nil, err + } + return managedClusters.Items, nil +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type. +func (v *TemplateManagementValidator) ValidateDelete(ctx context.Context, _ runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +// Default implements webhook.Defaulter so a webhook will be registered for the type. +func (*TemplateManagementValidator) Default(_ context.Context, obj runtime.Object) error { + return nil +} diff --git a/internal/webhook/templatemanagement_webhook_test.go b/internal/webhook/templatemanagement_webhook_test.go new file mode 100644 index 000000000..68e2ccecc --- /dev/null +++ b/internal/webhook/templatemanagement_webhook_test.go @@ -0,0 +1,207 @@ +// Copyright 2024 +// +// 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 webhook + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/Mirantis/hmc/api/v1alpha1" + "github.com/Mirantis/hmc/internal/utils" + "github.com/Mirantis/hmc/test/objects/managedcluster" + "github.com/Mirantis/hmc/test/objects/template" + chain "github.com/Mirantis/hmc/test/objects/templatechain" + tm "github.com/Mirantis/hmc/test/objects/templatemanagement" + "github.com/Mirantis/hmc/test/scheme" +) + +func TestTemplateManagementValidateUpdate(t *testing.T) { + g := NewWithT(t) + + ctx := context.Background() + + namespaceDevName := "dev" + namespaceProdName := "prod" + + namespaceDev := &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceDevName, + Labels: map[string]string{ + "environment": "dev", + }, + }, + } + namespaceProd := &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespaceProdName, + Labels: map[string]string{ + "environment": "prod", + }, + }, + } + + awsCtChainName := "aws-chain" + azureCtChainName := "azure-chain" + awsStandaloneCpTemplateName := "aws-standalone-cp" + awsHostedCpTemplateName := "aws-hosted-cp" + azureStandaloneCpTemplateName := "azure-standalone-cp" + azureHostedCpTemplateName := "azure-hosted-cp" + + awsAccessRule := v1alpha1.AccessRule{ + TargetNamespaces: v1alpha1.TargetNamespaces{ + StringSelector: "environment=dev", + }, + ClusterTemplateChains: []string{awsCtChainName}, + } + + azureProdAccessRule := v1alpha1.AccessRule{ + TargetNamespaces: v1alpha1.TargetNamespaces{ + Selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "environment", + Operator: metav1.LabelSelectorOpIn, + Values: []string{"prod"}, + }, + }, + }, + }, + ClusterTemplateChains: []string{azureCtChainName}, + } + + awsCtChain := chain.NewClusterTemplateChain(chain.WithName(awsCtChainName), chain.WithSupportedTemplates( + []v1alpha1.SupportedTemplate{ + {Name: awsStandaloneCpTemplateName}, + {Name: awsHostedCpTemplateName}, + }, + )) + azureCtChain := chain.NewClusterTemplateChain(chain.WithName(azureCtChainName), chain.WithSupportedTemplates( + []v1alpha1.SupportedTemplate{ + {Name: azureStandaloneCpTemplateName}, + {Name: azureHostedCpTemplateName}, + }, + )) + + hmcClusterTemplate := template.WithLabels(map[string]string{v1alpha1.HMCManagedLabelKey: v1alpha1.HMCManagedLabelValue}) + + tests := []struct { + name string + newTm *v1alpha1.TemplateManagement + existingObjects []runtime.Object + err string + warnings admission.Warnings + }{ + { + name: `should fail if new access rules require to remove the in-used ClusterTemplate (only aws in dev namespace is allowed)`, + newTm: tm.NewTemplateManagement(tm.WithAccessRules([]v1alpha1.AccessRule{awsAccessRule})), + existingObjects: []runtime.Object{ + awsCtChain, + azureCtChain, + template.NewClusterTemplate(hmcClusterTemplate, template.WithNamespace(namespaceDevName), template.WithName(awsStandaloneCpTemplateName)), + template.NewClusterTemplate(hmcClusterTemplate, template.WithNamespace(namespaceProdName), template.WithName(awsHostedCpTemplateName)), + template.NewClusterTemplate(hmcClusterTemplate, template.WithNamespace(namespaceDevName), template.WithName(azureStandaloneCpTemplateName)), + template.NewClusterTemplate(hmcClusterTemplate, template.WithNamespace(namespaceProdName), template.WithName(azureHostedCpTemplateName)), + template.NewClusterTemplate(template.WithName("unmanaged")), + managedcluster.NewManagedCluster( + managedcluster.WithName("aws-standalone"), + managedcluster.WithNamespace(namespaceDevName), + managedcluster.WithTemplate(awsStandaloneCpTemplateName), + ), + managedcluster.NewManagedCluster( + managedcluster.WithName("aws-hosted"), + managedcluster.WithNamespace(namespaceProdName), + managedcluster.WithTemplate(awsHostedCpTemplateName), + ), + managedcluster.NewManagedCluster( + managedcluster.WithName("azure-standalone"), + managedcluster.WithNamespace(namespaceDevName), + managedcluster.WithTemplate(azureStandaloneCpTemplateName), + ), + managedcluster.NewManagedCluster( + managedcluster.WithName("azure-hosted-1"), + managedcluster.WithNamespace(namespaceProdName), + managedcluster.WithTemplate(azureHostedCpTemplateName), + ), + managedcluster.NewManagedCluster( + managedcluster.WithName("azure-hosted-2"), + managedcluster.WithNamespace(namespaceProdName), + managedcluster.WithTemplate(azureHostedCpTemplateName), + ), + }, + warnings: admission.Warnings{ + "ClusterTemplate \"dev/azure-standalone-cp\" can't be removed: found ManagedClusters that reference it: \"dev/azure-standalone\"", + "ClusterTemplate \"prod/aws-hosted-cp\" can't be removed: found ManagedClusters that reference it: \"prod/aws-hosted\"", + "ClusterTemplate \"prod/azure-hosted-cp\" can't be removed: found ManagedClusters that reference it: \"prod/azure-hosted-1\", \"prod/azure-hosted-2\"", + }, + err: "can not apply new access rules", + }, + { + name: "should succeed if new access rules don't affect in-used ClusterTemplates (only azure hosted in prod namespace is allowed)", + newTm: tm.NewTemplateManagement(tm.WithAccessRules([]v1alpha1.AccessRule{azureProdAccessRule})), + existingObjects: []runtime.Object{ + awsCtChain, + azureCtChain, + template.NewClusterTemplate(hmcClusterTemplate, template.WithNamespace(namespaceDevName), template.WithName(awsStandaloneCpTemplateName)), + template.NewClusterTemplate(hmcClusterTemplate, template.WithNamespace(namespaceProdName), template.WithName(awsHostedCpTemplateName)), + template.NewClusterTemplate(hmcClusterTemplate, template.WithNamespace(namespaceDevName), template.WithName(azureStandaloneCpTemplateName)), + template.NewClusterTemplate(hmcClusterTemplate, template.WithNamespace(namespaceProdName), template.WithName(azureHostedCpTemplateName)), + template.NewClusterTemplate(template.WithName("unmanaged")), + managedcluster.NewManagedCluster( + managedcluster.WithName("azure-hosted-1"), + managedcluster.WithNamespace(namespaceProdName), + managedcluster.WithTemplate(azureHostedCpTemplateName), + ), + managedcluster.NewManagedCluster( + managedcluster.WithName("azure-hosted-2"), + managedcluster.WithNamespace(namespaceProdName), + managedcluster.WithTemplate(azureHostedCpTemplateName), + ), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj := []runtime.Object{namespaceDev, namespaceProd} + c := fake.NewClientBuilder(). + WithScheme(scheme.Scheme). + WithRuntimeObjects(append(obj, tt.existingObjects...)...). + WithIndex(&v1alpha1.ManagedCluster{}, TemplateKey, ExtractTemplateName). + Build() + validator := &TemplateManagementValidator{Client: c, SystemNamespace: utils.DefaultSystemNamespace} + warn, err := validator.ValidateUpdate(ctx, tm.NewTemplateManagement(), tt.newTm) + if tt.err != "" { + g.Expect(err).To(HaveOccurred()) + if err.Error() != tt.err { + t.Fatalf("expected error '%s', got error: %s", tt.err, err.Error()) + } + } else { + g.Expect(err).To(Succeed()) + } + if len(tt.warnings) > 0 { + g.Expect(warn).To(Equal(tt.warnings)) + } else { + g.Expect(warn).To(BeEmpty()) + } + }) + } +} diff --git a/templates/provider/hmc/templates/crds/hmc.mirantis.com_templatemanagements.yaml b/templates/provider/hmc/templates/crds/hmc.mirantis.com_templatemanagements.yaml index da5ef175f..586bd9823 100644 --- a/templates/provider/hmc/templates/crds/hmc.mirantis.com_templatemanagements.yaml +++ b/templates/provider/hmc/templates/crds/hmc.mirantis.com_templatemanagements.yaml @@ -71,14 +71,16 @@ spec: Templates will be distributed to all namespaces if unset. properties: list: - description: List is the list of namespaces to select. + description: |- + List is the list of namespaces to select. + Mutually exclusive with StringSelector and Selector. items: type: string type: array selector: description: |- Selector is a structured label query to select namespaces. - Mutually exclusive with StringSelector. + Mutually exclusive with StringSelector and List. properties: matchExpressions: description: matchExpressions is a list of label selector @@ -126,12 +128,12 @@ spec: stringSelector: description: |- StringSelector is a label query to select namespaces. - Mutually exclusive with Selector. + Mutually exclusive with Selector and List. type: string type: object x-kubernetes-validations: - message: only one of spec.targetNamespaces.selector or spec.targetNamespaces.stringSelector - spec.targetNamespaces.list can be specified + or spec.targetNamespaces.list can be specified rule: '((has(self.stringSelector) ? 1 : 0) + (has(self.selector) ? 1 : 0) + (has(self.list) ? 1 : 0)) <= 1' type: object @@ -167,14 +169,16 @@ spec: Templates will be distributed to all namespaces if unset. properties: list: - description: List is the list of namespaces to select. + description: |- + List is the list of namespaces to select. + Mutually exclusive with StringSelector and Selector. items: type: string type: array selector: description: |- Selector is a structured label query to select namespaces. - Mutually exclusive with StringSelector. + Mutually exclusive with StringSelector and List. properties: matchExpressions: description: matchExpressions is a list of label selector @@ -222,16 +226,20 @@ spec: stringSelector: description: |- StringSelector is a label query to select namespaces. - Mutually exclusive with Selector. + Mutually exclusive with Selector and List. type: string type: object x-kubernetes-validations: - message: only one of spec.targetNamespaces.selector or spec.targetNamespaces.stringSelector - spec.targetNamespaces.list can be specified + or spec.targetNamespaces.list can be specified rule: '((has(self.stringSelector) ? 1 : 0) + (has(self.selector) ? 1 : 0) + (has(self.list) ? 1 : 0)) <= 1' type: object type: array + error: + description: Error is the error message occurred during the reconciliation + (if any) + type: string observedGeneration: description: ObservedGeneration is the last observed generation. format: int64 diff --git a/templates/provider/hmc/templates/rbac/controller/roles.yaml b/templates/provider/hmc/templates/rbac/controller/roles.yaml index 69dc76918..17e71b303 100644 --- a/templates/provider/hmc/templates/rbac/controller/roles.yaml +++ b/templates/provider/hmc/templates/rbac/controller/roles.yaml @@ -49,6 +49,15 @@ rules: - patch - update - watch +- apiGroups: + - hmc.mirantis.com + resources: + - clustertemplatechains + - servicetemplatechains + verbs: + - get + - list + - watch - apiGroups: - hmc.mirantis.com resources: @@ -123,6 +132,11 @@ rules: resources: - machines verbs: {{ include "rbac.viewerVerbs" . | nindent 4 }} +- apiGroups: + - "" + resources: + - namespaces + verbs: {{ include "rbac.viewerVerbs" . | nindent 4 }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role diff --git a/templates/provider/hmc/templates/webhooks.yaml b/templates/provider/hmc/templates/webhooks.yaml index 7d372f9bd..2ff5f1740 100644 --- a/templates/provider/hmc/templates/webhooks.yaml +++ b/templates/provider/hmc/templates/webhooks.yaml @@ -127,4 +127,27 @@ webhooks: resources: - clustertemplates sideEffects: None + - admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + service: + name: {{ include "hmc.webhook.serviceName" . }} + namespace: {{ include "hmc.webhook.serviceNamespace" . }} + path: /validate-hmc-mirantis-com-v1alpha1-templatemanagement + failurePolicy: Fail + matchPolicy: Equivalent + name: validation.templatemanagement.hmc.mirantis.com + rules: + - apiGroups: + - hmc.mirantis.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + - DELETE + resources: + - templatemanagements + sideEffects: None {{- end }} diff --git a/test/objects/template/template.go b/test/objects/template/template.go index 3cda23d3f..b97f8e818 100644 --- a/test/objects/template/template.go +++ b/test/objects/template/template.go @@ -86,6 +86,12 @@ func WithNamespace(namespace string) Opt { } } +func WithLabels(labels map[string]string) Opt { + return func(t *Template) { + t.Labels = labels + } +} + func WithHelmSpec(helmSpec v1alpha1.HelmSpec) Opt { return func(t *Template) { t.Spec.Helm = helmSpec diff --git a/test/objects/templatechain/templatechain.go b/test/objects/templatechain/templatechain.go new file mode 100644 index 000000000..ec0c60dee --- /dev/null +++ b/test/objects/templatechain/templatechain.go @@ -0,0 +1,72 @@ +// Copyright 2024 +// +// 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 templatemanagement + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/Mirantis/hmc/api/v1alpha1" +) + +const ( + DefaultName = "hmc-tc" +) + +type TemplateChain struct { + metav1.ObjectMeta `json:",inline"` + Spec v1alpha1.TemplateChainSpec `json:"spec"` +} + +type Opt func(tc *TemplateChain) + +func NewClusterTemplateChain(opts ...Opt) *v1alpha1.ClusterTemplateChain { + tc := NewTemplateChain(opts...) + return &v1alpha1.ClusterTemplateChain{ + ObjectMeta: tc.ObjectMeta, + Spec: tc.Spec, + } +} + +func NewServiceTemplateChain(opts ...Opt) *v1alpha1.ServiceTemplateChain { + tc := NewTemplateChain(opts...) + return &v1alpha1.ServiceTemplateChain{ + ObjectMeta: tc.ObjectMeta, + Spec: tc.Spec, + } +} + +func NewTemplateChain(opts ...Opt) *TemplateChain { + tc := &TemplateChain{ + ObjectMeta: metav1.ObjectMeta{ + Name: DefaultName, + }, + } + for _, opt := range opts { + opt(tc) + } + return tc +} + +func WithName(name string) Opt { + return func(tc *TemplateChain) { + tc.Name = name + } +} + +func WithSupportedTemplates(supportedTemplates []v1alpha1.SupportedTemplate) Opt { + return func(tc *TemplateChain) { + tc.Spec.SupportedTemplates = supportedTemplates + } +} diff --git a/test/objects/templatemanagement/templatemanagement.go b/test/objects/templatemanagement/templatemanagement.go new file mode 100644 index 000000000..e7f41f40b --- /dev/null +++ b/test/objects/templatemanagement/templatemanagement.go @@ -0,0 +1,52 @@ +// Copyright 2024 +// +// 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 templatemanagement + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/Mirantis/hmc/api/v1alpha1" +) + +const ( + DefaultName = "hmc-tm" +) + +type Opt func(tm *v1alpha1.TemplateManagement) + +func NewTemplateManagement(opts ...Opt) *v1alpha1.TemplateManagement { + tm := &v1alpha1.TemplateManagement{ + ObjectMeta: metav1.ObjectMeta{ + Name: DefaultName, + }, + } + + for _, opt := range opts { + opt(tm) + } + return tm +} + +func WithName(name string) Opt { + return func(tm *v1alpha1.TemplateManagement) { + tm.Name = name + } +} + +func WithAccessRules(accessRules []v1alpha1.AccessRule) Opt { + return func(tm *v1alpha1.TemplateManagement) { + tm.Spec.AccessRules = accessRules + } +}