diff --git a/controllers/component/component_adapter.go b/controllers/component/component_adapter.go new file mode 100644 index 000000000..9c65ddbc5 --- /dev/null +++ b/controllers/component/component_adapter.go @@ -0,0 +1,138 @@ +/* +Copyright 2023. + +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 component + +import ( + "context" + "fmt" + "reflect" + ctrl "sigs.k8s.io/controller-runtime" + + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + "github.com/redhat-appstudio/integration-service/gitops" + h "github.com/redhat-appstudio/integration-service/helpers" + "github.com/redhat-appstudio/integration-service/loader" + "github.com/redhat-appstudio/integration-service/metrics" + "github.com/redhat-appstudio/integration-service/status" + "github.com/redhat-appstudio/operator-toolkit/controller" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Adapter holds the objects needed to reconcile a integration PipelineRun. +type Adapter struct { + component *applicationapiv1alpha1.Component + application *applicationapiv1alpha1.Application + loader loader.ObjectLoader + logger h.IntegrationLogger + client client.Client + context context.Context + status status.Status +} + +// NewAdapter creates and returns an Adapter instance. +func NewAdapter(component *applicationapiv1alpha1.Component, application *applicationapiv1alpha1.Application, logger h.IntegrationLogger, loader loader.ObjectLoader, client client.Client, + context context.Context) *Adapter { + return &Adapter{ + component: component, + application: application, + logger: logger, + loader: loader, + client: client, + context: context, + status: status.NewAdapter(logger.Logger, client), + } +} + +// EnsureSnapshotIsFresh is an operation that will ensure that any snapshots associated with components +// marked for deletion are recreated without said component +func (a *Adapter) EnsureSnapshotIsFresh() (controller.OperationResult, error) { + if !HasComponentBeenDeleted(a.component) { + return controller.ContinueProcessing() + } + applicationSnapshots, err := a.loader.GetAllSnapshots(a.client, a.context, a.application) + if err != nil { + return controller.RequeueWithError(err) + } + for _, snapshot := range *applicationSnapshots { + for _, snapshotComponent := range snapshot.Spec.Components { + if reflect.DeepEqual(a.component, snapshotComponent) { + // create new snapshot + // prepare new component list for snapshot + var updatedComponents []applicationapiv1alpha1.SnapshotComponent + for i := 0; i < len(snapshot.Spec.Components); i++ { + if reflect.DeepEqual(a.component, snapshot.Spec.Components[i]) { + updatedComponents = append(snapshot.Spec.Components[:i], snapshot.Spec.Components[i+1:]...) + } + } + + var refreshedSnapshot, err = RefreshSnapshot(a.client, a.application, &updatedComponents) + if err != nil { + a.logger.Error(err, "Failed to refresh snapshot after component deletion detected") + return controller.RequeueWithError(err) + } + + // TODO create new seb + a.loader.GetAllEnvironments(a.client, a.context, a.application) + + // mark snapshot as failed + existingSnapshot, err := gitops.MarkSnapshotAsFailed(a.client, a.context, &snapshot, "Associated snapshot components deleted.") + if err != nil { + a.logger.Error(err, "Failed to Update Snapshot AppStudioTestSucceeded status") + return controller.RequeueWithError(err) + } + a.logger.LogAuditEvent("Snapshot integration status condition marked as failed, an associated snapshot components deleted.", + existingSnapshot, h.LogActionUpdate) + + // looks like potential things to do after we create the snapshot need some clarity on + // TODO understand controller reference + // TODO understand RegisterNewSnapshot + // TODO better understand labels and annotations + // copy old label and annotation? + h.CopyLabelsByPrefix(&snapshot.ObjectMeta, &refreshedSnapshot.ObjectMeta, gitops.PipelinesAsCodePrefix, gitops.PipelinesAsCodePrefix) + h.CopyAnnotationsByPrefix(&snapshot.ObjectMeta, &refreshedSnapshot.ObjectMeta, gitops.PipelinesAsCodePrefix, gitops.PipelinesAsCodePrefix) + go metrics.RegisterNewSnapshot() + + //break + } + } + } + + return controller.ContinueProcessing() +} + +// RefreshSnapshot prepares the Snapshot for a given application, components and the updated component (if any). +// In case the Snapshot can't be created, an error will be returned. +func RefreshSnapshot(adapterClient client.Client, application *applicationapiv1alpha1.Application, remainingComponents *[]applicationapiv1alpha1.SnapshotComponent) (*applicationapiv1alpha1.Snapshot, error) { + if len(*remainingComponents) == 0 { + return nil, fmt.Errorf("failed to prepare snapshot due to missing valid digest in containerImage for all components of application") + } + snapshot := gitops.NewSnapshot(application, remainingComponents) + + err := ctrl.SetControllerReference(application, snapshot, adapterClient.Scheme()) + if err != nil { + return nil, err + } + + return snapshot, nil +} + +func HasComponentBeenDeleted(object client.Object) bool { + if comp, ok := object.(*applicationapiv1alpha1.Component); ok { + return !comp.ObjectMeta.DeletionTimestamp.IsZero() + } + return false +} diff --git a/controllers/component/component_adapter_test.go b/controllers/component/component_adapter_test.go new file mode 100644 index 000000000..cc264c96b --- /dev/null +++ b/controllers/component/component_adapter_test.go @@ -0,0 +1 @@ +package component diff --git a/controllers/component/component_controller.go b/controllers/component/component_controller.go new file mode 100644 index 000000000..ad9156885 --- /dev/null +++ b/controllers/component/component_controller.go @@ -0,0 +1,116 @@ +/* +Copyright 2023. + +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 andF +limitations under the License. +*/ + +package component + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + "github.com/redhat-appstudio/integration-service/helpers" + "github.com/redhat-appstudio/integration-service/loader" + "github.com/redhat-appstudio/operator-toolkit/controller" + tektonv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + "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/predicate" +) + +// Reconciler reconciles a component object +type Reconciler struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme +} + +// NewComponentReconciler creates and returns a Reconciler. +func NewComponentReconciler(client client.Client, logger *logr.Logger, scheme *runtime.Scheme) *Reconciler { + return &Reconciler{ + Client: client, + Log: logger.WithName("integration pipeline"), + Scheme: scheme, + } +} + +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=components,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=components/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=components/finalizers,verbs=update +//+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch +//+kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := helpers.IntegrationLogger{Logger: r.Log.WithValues("component", req.NamespacedName)} + loader := loader.NewLoader() + + component := &applicationapiv1alpha1.Component{} + err := r.Get(ctx, req.NamespacedName, component) + if err != nil { + logger.Error(err, "Failed to get component for", "req", req.NamespacedName) + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + + return ctrl.Result{}, err + } + + application := &applicationapiv1alpha1.Application{} + application, err = loader.GetApplicationFromComponent(r.Client, ctx, component) + if err != nil { + logger.Error(err, "Failed to get Application from the component", + "Component.Name", component.Name) + return ctrl.Result{}, err + } + + if application == nil { + err := fmt.Errorf("failed to get Application") + logger.Error(err, "reconcile cannot resolve application") + return ctrl.Result{}, err + } + logger = logger.WithApp(*application) + + adapter := NewAdapter(component, application, logger, loader, r.Client, ctx) + + return controller.ReconcileHandler([]controller.Operation{ + adapter.EnsureSnapshotIsFresh, + }) +} + +// AdapterInterface is an interface defining all the operations that should be defined in an Integration adapter. +type AdapterInterface interface { + EnsureSnapshotIsFresh() (controller.OperationResult, error) +} + +// SetupController creates a new Component controller and adds it to the Manager. +func SetupController(manager ctrl.Manager, log *logr.Logger) error { + return setupControllerWithManager(manager, NewComponentReconciler(manager.GetClient(), log, manager.GetScheme())) + +} + +// setupControllerWithManager sets up the controller with the Manager which monitors new PipelineRuns and filters +// out status updates. +func setupControllerWithManager(manager ctrl.Manager, controller *Reconciler) error { + return ctrl.NewControllerManagedBy(manager). + For(&tektonv1beta1.PipelineRun{}). + WithEventFilter(predicate.Or( + ComponentDeletedPredicate())). + Complete(controller) +} diff --git a/controllers/component/component_controller_test.go b/controllers/component/component_controller_test.go new file mode 100644 index 000000000..0b0898055 --- /dev/null +++ b/controllers/component/component_controller_test.go @@ -0,0 +1,153 @@ +package component + +import ( + "k8s.io/apimachinery/pkg/api/errors" + "reflect" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientsetscheme "k8s.io/client-go/kubernetes/scheme" + klog "k8s.io/klog/v2" +) + +var _ = Describe("ComponentController", func() { + var ( + manager ctrl.Manager + componentReconciler *Reconciler + scheme runtime.Scheme + req ctrl.Request + hasApp *applicationapiv1alpha1.Application + hasComp *applicationapiv1alpha1.Component + ) + const ( + SampleRepoLink = "https://github.com/devfile-samples/devfile-sample-java-springboot-basic" + ) + + BeforeEach(func() { + + applicationName := "application-sample" + + hasApp = &applicationapiv1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: applicationName, + Namespace: "default", + }, + Spec: applicationapiv1alpha1.ApplicationSpec{ + DisplayName: "application-sample", + Description: "This is an example application", + }, + } + + Expect(k8sClient.Create(ctx, hasApp)).Should(Succeed()) + + hasComp = &applicationapiv1alpha1.Component{ + ObjectMeta: metav1.ObjectMeta{ + Name: "component-sample", + Namespace: "default", + }, + Spec: applicationapiv1alpha1.ComponentSpec{ + ComponentName: "component-sample", + Application: applicationName, + Source: applicationapiv1alpha1.ComponentSource{ + ComponentSourceUnion: applicationapiv1alpha1.ComponentSourceUnion{ + GitSource: &applicationapiv1alpha1.GitSource{ + URL: SampleRepoLink, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, hasComp)).Should(Succeed()) + + req = ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: hasComp.Name, + }, + } + + webhookInstallOptions := &testEnv.WebhookInstallOptions + + klog.Info(webhookInstallOptions.LocalServingHost) + klog.Info(webhookInstallOptions.LocalServingPort) + klog.Info(webhookInstallOptions.LocalServingCertDir) + + var err error + manager, err = ctrl.NewManager(cfg, ctrl.Options{ + Scheme: clientsetscheme.Scheme, + Host: webhookInstallOptions.LocalServingHost, + Port: webhookInstallOptions.LocalServingPort, + CertDir: webhookInstallOptions.LocalServingCertDir, + MetricsBindAddress: "0", // this disables metrics + LeaderElection: false, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(err).To(BeNil()) + + componentReconciler = NewComponentReconciler(k8sClient, &logf.Log, &scheme) + }) + AfterEach(func() { + err := k8sClient.Delete(ctx, hasApp) + Expect(err == nil || errors.IsNotFound(err)).To(BeTrue()) + err = k8sClient.Delete(ctx, hasComp) + Expect(err == nil || errors.IsNotFound(err)).To(BeTrue()) + }) + + It("can create and return a new Reconciler object", func() { + Expect(reflect.TypeOf(componentReconciler)).To(Equal(reflect.TypeOf(&Reconciler{}))) + }) + + It("can fail when Reconcile fails to prepare the adapter when Component is not found", func() { + Expect(k8sClient.Delete(ctx, hasComp)).Should(Succeed()) + Eventually(func() error { + _, err := componentReconciler.Reconcile(ctx, req) + return err + }).Should(BeNil()) + }) + + It("can fail when Reconcile fails to prepare the adapter when Application is not found", func() { + Expect(k8sClient.Delete(ctx, hasApp)).Should(Succeed()) + Eventually(func() error { + _, err := componentReconciler.Reconcile(ctx, req) + return err + }).ShouldNot(BeNil()) + }) + + It("can Reconcile function prepare the adapter and return the result of the reconcile handling operation", func() { + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: "non-existent", + Namespace: "default", + }, + } + result, err := componentReconciler.Reconcile(ctx, req) + Expect(reflect.TypeOf(result)).To(Equal(reflect.TypeOf(reconcile.Result{}))) + Expect(err).To(BeNil()) + }) + + It("can setup a new controller manager with the given componentReconciler", func() { + err := setupControllerWithManager(manager, componentReconciler) + Expect(err).NotTo(HaveOccurred()) + }) + + It("can setup a new Controller manager and start it", func() { + err := SetupController(manager, &ctrl.Log) + Expect(err).To(BeNil()) + go func() { + defer GinkgoRecover() + err = manager.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() + }) + +}) diff --git a/controllers/component/component_suite_test.go b/controllers/component/component_suite_test.go new file mode 100644 index 000000000..d75049a9f --- /dev/null +++ b/controllers/component/component_suite_test.go @@ -0,0 +1,111 @@ +/* +Copyright 2022. + +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 component + +import ( + "context" + "go/build" + "path/filepath" + "testing" + + toolkit "github.com/redhat-appstudio/operator-toolkit/test" + + "k8s.io/client-go/rest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + ctrl "sigs.k8s.io/controller-runtime" + + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + "github.com/redhat-appstudio/integration-service/api/v1beta1" + releasev1alpha1 "github.com/redhat-appstudio/release-service/api/v1alpha1" + tektonv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + clientsetscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment + ctx context.Context + cancel context.CancelFunc +) + +func TestControllerComponent(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Component Controller Test Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + ctx, cancel = context.WithCancel(context.TODO()) + + //adding required CRDs, including tekton for PipelineRun Kind + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "config", "crd", "bases"), + filepath.Join( + build.Default.GOPATH, + "pkg", "mod", toolkit.GetRelativeDependencyPath("tektoncd/pipeline"), "config", + ), + filepath.Join( + build.Default.GOPATH, + "pkg", "mod", toolkit.GetRelativeDependencyPath("application-api"), + "config", "crd", "bases", + ), + filepath.Join( + build.Default.GOPATH, + "pkg", "mod", toolkit.GetRelativeDependencyPath("release-service"), "config", "crd", "bases", + ), + }, + ErrorIfCRDPathMissing: true, + } + + var err error + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + Expect(applicationapiv1alpha1.AddToScheme(clientsetscheme.Scheme)).To(Succeed()) + Expect(tektonv1beta1.AddToScheme(clientsetscheme.Scheme)).To(Succeed()) + Expect(releasev1alpha1.AddToScheme(clientsetscheme.Scheme)).To(Succeed()) + Expect(v1beta1.AddToScheme(clientsetscheme.Scheme)).To(Succeed()) + + k8sManager, _ := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: clientsetscheme.Scheme, + MetricsBindAddress: "0", // this disables metrics + LeaderElection: false, + }) + + k8sClient = k8sManager.GetClient() + go func() { + defer GinkgoRecover() + Expect(k8sManager.Start(ctx)).To(Succeed()) + }() +}) + +var _ = AfterSuite(func() { + cancel() + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/controllers/component/predicates.go b/controllers/component/predicates.go new file mode 100644 index 000000000..fbf064c9a --- /dev/null +++ b/controllers/component/predicates.go @@ -0,0 +1,25 @@ +package component + +import ( + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// ComponentDeletedPredicate returns a predicate which filters out +// only deleted component scenarios +func ComponentDeletedPredicate() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(createEvent event.CreateEvent) bool { + return false + }, + DeleteFunc: func(deleteEvent event.DeleteEvent) bool { + return true + }, + GenericFunc: func(genericEvent event.GenericEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return false + }, + } +} diff --git a/gitops/snapshot.go b/gitops/snapshot.go index c5aaaa45c..50139880c 100644 --- a/gitops/snapshot.go +++ b/gitops/snapshot.go @@ -396,7 +396,7 @@ func PrepareSnapshot(adapterClient client.Client, ctx context.Context, applicati log.Error(nil, "component cannot be added to snapshot for application due to missing containerImage", "component.Name", applicationComponent.Name) continue } - // if the containerImage donesn't have a valid digest, the component + // if the containerImage doesn't have a valid digest, the component // will not be added to snapshot err := ValidateImageDigest(containerImage) if err != nil {