From 26d5739c4c2a30a37c20561e882bbb29f4c2047e Mon Sep 17 00:00:00 2001 From: Hongwei Liu Date: Fri, 1 Sep 2023 14:02:00 +0800 Subject: [PATCH] feat(STONEINTG-524): report result of handling ITS to github * Create function to get credential from snapshot * Create function to report for result of handle ITSes for snapshot Signed-off-by: Hongwei Liu --- controllers/controllers.go | 2 + .../integrationpipeline_adapter_test.go | 7 +- .../statusreport/statusreport_adapter.go | 100 ++++++++ .../statusreport/statusreport_adapter_test.go | 150 ++++++++++++ .../statusreport/statusreport_controller.go | 125 ++++++++++ .../statusreport_controller_test.go | 152 ++++++++++++ .../statusreport/statusreport_suite_test.go | 111 +++++++++ git/github/github.go | 94 ++++++- git/github/github_test.go | 20 ++ gitops/snapshot.go | 56 +++++ gitops/snapshot_predicate.go | 19 ++ gitops/snapshot_predicate_test.go | 73 +++++- status/reporters.go | 225 ++++++++++++++++- status/reporters_test.go | 230 +++++++++++++++++- status/status.go | 8 +- status/status_test.go | 6 + 16 files changed, 1358 insertions(+), 20 deletions(-) create mode 100644 controllers/statusreport/statusreport_adapter.go create mode 100644 controllers/statusreport/statusreport_adapter_test.go create mode 100644 controllers/statusreport/statusreport_controller.go create mode 100644 controllers/statusreport/statusreport_controller_test.go create mode 100644 controllers/statusreport/statusreport_suite_test.go diff --git a/controllers/controllers.go b/controllers/controllers.go index 8e571bba0..39e15041f 100644 --- a/controllers/controllers.go +++ b/controllers/controllers.go @@ -23,6 +23,7 @@ import ( "github.com/redhat-appstudio/integration-service/controllers/integrationpipeline" "github.com/redhat-appstudio/integration-service/controllers/scenario" "github.com/redhat-appstudio/integration-service/controllers/snapshot" + "github.com/redhat-appstudio/integration-service/controllers/statusreport" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/manager" ) @@ -34,6 +35,7 @@ var setupFunctions = []func(manager.Manager, *logr.Logger) error{ snapshot.SetupController, scenario.SetupController, binding.SetupController, + statusreport.SetupController, } // SetupControllers invoke all SetupController functions defined in setupFunctions, setting all controllers up and diff --git a/controllers/integrationpipeline/integrationpipeline_adapter_test.go b/controllers/integrationpipeline/integrationpipeline_adapter_test.go index 3135bf18d..cb1e31c0d 100644 --- a/controllers/integrationpipeline/integrationpipeline_adapter_test.go +++ b/controllers/integrationpipeline/integrationpipeline_adapter_test.go @@ -60,7 +60,12 @@ func (r *MockStatusReporter) ReportStatus(client.Client, context.Context, *tekto return r.ReportStatusError } -func (a *MockStatusAdapter) GetReporters(pipelineRun *tektonv1beta1.PipelineRun) ([]status.Reporter, error) { +func (r *MockStatusReporter) ReportStatusForSnapshot(client.Client, context.Context, *applicationapiv1alpha1.Snapshot, *[]gitops.IntegrationTestStatusDetail) error { + r.Called = true + return r.ReportStatusError +} + +func (a *MockStatusAdapter) GetReporters(object client.Object) ([]status.Reporter, error) { return []status.Reporter{a.Reporter}, a.GetReportersError } diff --git a/controllers/statusreport/statusreport_adapter.go b/controllers/statusreport/statusreport_adapter.go new file mode 100644 index 000000000..a8003853a --- /dev/null +++ b/controllers/statusreport/statusreport_adapter.go @@ -0,0 +1,100 @@ +/* +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 statusreport + +import ( + "context" + "encoding/json" + "fmt" + + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + "github.com/redhat-appstudio/integration-service/gitops" + "github.com/redhat-appstudio/integration-service/helpers" + "github.com/redhat-appstudio/integration-service/status" + + "github.com/redhat-appstudio/integration-service/loader" + "github.com/redhat-appstudio/operator-toolkit/controller" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Adapter holds the objects needed to reconcile a snapshot's test status report. +type Adapter struct { + snapshot *applicationapiv1alpha1.Snapshot + application *applicationapiv1alpha1.Application + logger helpers.IntegrationLogger + loader loader.ObjectLoader + client client.Client + context context.Context + status status.Status +} + +// NewAdapter creates and returns an Adapter instance. +func NewAdapter(snapshot *applicationapiv1alpha1.Snapshot, application *applicationapiv1alpha1.Application, logger helpers.IntegrationLogger, loader loader.ObjectLoader, client client.Client, + context context.Context) *Adapter { + return &Adapter{ + snapshot: snapshot, + application: application, + logger: logger, + loader: loader, + client: client, + context: context, + status: status.NewAdapter(logger.Logger, client), + } +} + +// EnsureSnapshotTestStatusReported will ensure that integration test status including env provision and snapshotEnvironmentBinding error is reported to the git provider +// which (indirectly) triggered its execution. +func (a *Adapter) EnsureSnapshotTestStatusReported() (controller.OperationResult, error) { + reporters, err := a.status.GetReporters(a.snapshot) + + if err != nil { + return controller.RequeueWithError(err) + } + + for _, reporter := range reporters { + integrationTestStatusDetails, err := getIntegrationScenarioTestStatusFromAnnotation(a.snapshot) + if err != nil { + a.logger.Error(err, "failed to get test status to snapshot annotations for snapshot %s/%s", "Snapshot.Namespace", a.snapshot.Namespace, "Snapshot.Name", a.snapshot.Name) + return controller.RequeueWithError(err) + } + if integrationTestStatusDetails == nil { + a.logger.Info("no snapshot annotation %s defined for snapshot, no need to report integration test status", "Snapshot.Namespace", a.snapshot.Namespace, "Snapshot.Name", a.snapshot.Name) + return controller.ContinueProcessing() + } + + if err := reporter.ReportStatusForSnapshot(a.client, a.context, a.snapshot, integrationTestStatusDetails); err != nil { + a.logger.Error(err, "failed to report test status to github for snapshot", "Snapshot.Namespace", a.snapshot.Namespace, "Snapshot.Name", a.snapshot.Name) + return controller.RequeueWithError(err) + } + } + + return controller.ContinueProcessing() +} + +func getIntegrationScenarioTestStatusFromAnnotation(snapshot *applicationapiv1alpha1.Snapshot) (*[]gitops.IntegrationTestStatusDetail, error) { + statusAnnotation, ok := snapshot.GetAnnotations()[gitops.SnapshotTestsStatusAnnotation] + if !ok { + return nil, nil + } + + integrationTestStatusDetails := &[]gitops.IntegrationTestStatusDetail{} + err := json.Unmarshal([]byte(statusAnnotation), integrationTestStatusDetails) + if err != nil { + return nil, fmt.Errorf("failed to load tests statuses from the scenario annotation: %w", err) + } + return integrationTestStatusDetails, nil +} diff --git a/controllers/statusreport/statusreport_adapter_test.go b/controllers/statusreport/statusreport_adapter_test.go new file mode 100644 index 000000000..9a17df58a --- /dev/null +++ b/controllers/statusreport/statusreport_adapter_test.go @@ -0,0 +1,150 @@ +package statusreport + +import ( + "context" + "reflect" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + "github.com/redhat-appstudio/integration-service/loader" + "github.com/redhat-appstudio/integration-service/status" + tektonv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/redhat-appstudio/integration-service/gitops" + "github.com/redhat-appstudio/integration-service/helpers" + "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type MockStatusAdapter struct { + Reporter *MockStatusReporter + GetReportersError error +} + +type MockStatusReporter struct { + Called bool + ReportStatusError error +} + +func (r *MockStatusReporter) ReportStatus(client.Client, context.Context, *tektonv1beta1.PipelineRun) error { + r.Called = true + return r.ReportStatusError +} + +func (r *MockStatusReporter) ReportStatusForSnapshot(client.Client, context.Context, *applicationapiv1alpha1.Snapshot, *[]gitops.IntegrationTestStatusDetail) error { + r.Called = true + return r.ReportStatusError +} + +func (a *MockStatusAdapter) GetReporters(object client.Object) ([]status.Reporter, error) { + return []status.Reporter{a.Reporter}, a.GetReportersError +} + +var _ = Describe("Snapshot Adapter", Ordered, func() { + var ( + adapter *Adapter + logger helpers.IntegrationLogger + statusAdapter *MockStatusAdapter + statusReporter *MockStatusReporter + + hasApp *applicationapiv1alpha1.Application + hasSnapshot *applicationapiv1alpha1.Snapshot + ) + const ( + SampleRepoLink = "https://github.com/devfile-samples/devfile-sample-java-springboot-basic" + sample_image = "quay.io/redhat-appstudio/sample-image" + sample_revision = "random-value" + ) + + BeforeAll(func() { + hasApp = &applicationapiv1alpha1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "application-sample", + Namespace: "default", + }, + Spec: applicationapiv1alpha1.ApplicationSpec{ + DisplayName: "application-sample", + Description: "This is an example application", + }, + } + Expect(k8sClient.Create(ctx, hasApp)).Should(Succeed()) + + hasSnapshot = &applicationapiv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "snapshot-sample", + Namespace: "default", + Labels: map[string]string{ + gitops.SnapshotTypeLabel: "component", + gitops.SnapshotComponentLabel: "component-sample", + "build.appstudio.redhat.com/pipeline": "enterprise-contract", + gitops.PipelineAsCodeEventTypeLabel: "push", + gitops.PipelineAsCodeGitProviderLabel: gitops.PipelineAsCodeGitHubProviderType, + }, + Annotations: map[string]string{ + gitops.PipelineAsCodeInstallationIDAnnotation: "123", + "build.appstudio.redhat.com/commit_sha": "6c65b2fcaea3e1a0a92476c8b5dc89e92a85f025", + "appstudio.redhat.com/updateComponentOnSuccess": "false", + gitops.SnapshotTestsStatusAnnotation: "[{\"ScenarioName\":\"scenario-1\",\"Status\":\"EnvironmentProvisionError\",\"StartTime\":\"2023-07-26T16:57:49+02:00\",\"CompletionTime\":\"2023-07-26T17:57:49+02:00\",\"Details\":\"Failed to find deploymentTargetClass with right provisioner for copy of existingEnvironment\"}]", + }, + }, + Spec: applicationapiv1alpha1.SnapshotSpec{ + Application: hasApp.Name, + Components: []applicationapiv1alpha1.SnapshotComponent{ + { + Name: "component-sample", + ContainerImage: sample_image, + Source: applicationapiv1alpha1.ComponentSource{ + ComponentSourceUnion: applicationapiv1alpha1.ComponentSourceUnion{ + GitSource: &applicationapiv1alpha1.GitSource{ + Revision: sample_revision, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, hasSnapshot)).Should(Succeed()) + }) + + BeforeEach(func() { + adapter = NewAdapter(hasSnapshot, hasApp, logger, loader.NewMockLoader(), k8sClient, ctx) + statusReporter = &MockStatusReporter{} + statusAdapter = &MockStatusAdapter{Reporter: statusReporter} + adapter.status = statusAdapter + }) + + AfterAll(func() { + err := k8sClient.Delete(ctx, hasSnapshot) + Expect(err == nil || errors.IsNotFound(err)).To(BeTrue()) + err = k8sClient.Delete(ctx, hasApp) + Expect(err == nil || errors.IsNotFound(err)).To(BeTrue()) + }) + + When("adapter is created", func() { + It("can create a new Adapter instance", func() { + Expect(reflect.TypeOf(NewAdapter(hasSnapshot, hasApp, logger, loader.NewMockLoader(), k8sClient, ctx))).To(Equal(reflect.TypeOf(&Adapter{}))) + }) + + It("ensures the statusResport is called", func() { + adapter = NewAdapter(hasSnapshot, hasApp, logger, loader.NewMockLoader(), k8sClient, ctx) + adapter.context = loader.GetMockedContext(ctx, []loader.MockData{ + { + ContextKey: loader.ApplicationContextKey, + Resource: hasApp, + }, + { + ContextKey: loader.SnapshotContextKey, + Resource: hasSnapshot, + }, + }) + result, err := adapter.EnsureSnapshotTestStatusReported() + Expect(!result.CancelRequest && err == nil).To(BeTrue()) + }) + }) + +}) diff --git a/controllers/statusreport/statusreport_controller.go b/controllers/statusreport/statusreport_controller.go new file mode 100644 index 000000000..a107db4b9 --- /dev/null +++ b/controllers/statusreport/statusreport_controller.go @@ -0,0 +1,125 @@ +/* +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 statusreport + +import ( + "context" + + "github.com/redhat-appstudio/integration-service/cache" + + "github.com/go-logr/logr" + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + "github.com/redhat-appstudio/integration-service/gitops" + "github.com/redhat-appstudio/integration-service/helpers" + "github.com/redhat-appstudio/integration-service/loader" + "github.com/redhat-appstudio/operator-toolkit/controller" + "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 an Snapshot object +type Reconciler struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme +} + +// NewStatusReportReconciler creates and returns a Reconciler. +func NewStatusReportReconciler(client client.Client, logger *logr.Logger, scheme *runtime.Scheme) *Reconciler { + return &Reconciler{ + Client: client, + Log: logger.WithName("statusreport"), + Scheme: scheme, + } +} + +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=snapshots,verbs=get;list;watch +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=snapshots/status,verbs=get +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=snapshotenvironmentbindings,verbs=get;list;watch +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=snapshotenvironmentbindings/status,verbs=get +//+kubebuilder:rbac:groups=tekton.dev,resources=pipelineruns,verbs=get;list;watch +//+kubebuilder:rbac:groups=tekton.dev,resources=pipelineruns/status,verbs=get +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=applications,verbs=get;list;watch +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=applications/status,verbs=get + +// 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("snapshot", req.NamespacedName)} + loader := loader.NewLoader() + + snapshot := &applicationapiv1alpha1.Snapshot{} + err := r.Get(ctx, req.NamespacedName, snapshot) + if err != nil { + logger.Error(err, "Failed to get snapshot for", "req", req.NamespacedName) + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + + return ctrl.Result{}, err + } + + application, err := loader.GetApplicationFromSnapshot(r.Client, ctx, snapshot) + if err != nil { + logger.Error(err, "Failed to get Application from the Snapshot") + return ctrl.Result{}, err + } + logger = logger.WithApp(*application) + + adapter := NewAdapter(snapshot, application, logger, loader, r.Client, ctx) + + return controller.ReconcileHandler([]controller.Operation{ + adapter.EnsureSnapshotTestStatusReported, + }) +} + +// AdapterInterface is an interface defining all the operations that should be defined in an Integration adapter. +type AdapterInterface interface { + EnsureSnapshotTestStatusReported() (controller.OperationResult, error) +} + +// SetupController creates a new Integration controller and adds it to the Manager. +func SetupController(manager ctrl.Manager, log *logr.Logger) error { + return setupControllerWithManager(manager, NewStatusReportReconciler(manager.GetClient(), log, manager.GetScheme())) +} + +// setupCache indexes fields for each of the resources used in the statusreport adapter in those cases where filtering by +// field is required. +func setupCache(mgr ctrl.Manager) error { + if err := cache.SetupIntegrationTestScenarioCache(mgr); err != nil { + return err + } + + return cache.SetupSnapshotCache(mgr) +} + +// setupControllerWithManager sets up the controller with the Manager which monitors new Snapshots +func setupControllerWithManager(manager ctrl.Manager, controller *Reconciler) error { + err := setupCache(manager) + if err != nil { + return err + } + + return ctrl.NewControllerManagedBy(manager). + For(&applicationapiv1alpha1.Snapshot{}). + WithEventFilter(predicate.Or( + gitops.SnapshotTestAnnotationChangePredicate())). + Complete(controller) +} diff --git a/controllers/statusreport/statusreport_controller_test.go b/controllers/statusreport/statusreport_controller_test.go new file mode 100644 index 000000000..12d5f2403 --- /dev/null +++ b/controllers/statusreport/statusreport_controller_test.go @@ -0,0 +1,152 @@ +package statusreport + +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" + "github.com/redhat-appstudio/integration-service/gitops" + + 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("StatusReportController", func() { + var ( + manager ctrl.Manager + statusReportReconciler *Reconciler + scheme runtime.Scheme + req ctrl.Request + hasApp *applicationapiv1alpha1.Application + hasSnapshot *applicationapiv1alpha1.Snapshot + ) + 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()) + + hasSnapshot = &applicationapiv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "snapshot-sample", + Namespace: "default", + Labels: map[string]string{ + gitops.SnapshotTypeLabel: "component", + gitops.SnapshotComponentLabel: "component-sample", + }, + }, + Spec: applicationapiv1alpha1.SnapshotSpec{ + Application: hasApp.Name, + Components: []applicationapiv1alpha1.SnapshotComponent{ + { + Name: "component-sample", + ContainerImage: "testimage", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, hasSnapshot)).Should(Succeed()) + + req = ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: hasSnapshot.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()) + + statusReportReconciler = NewStatusReportReconciler(k8sClient, &logf.Log, &scheme) + }) + AfterEach(func() { + err := k8sClient.Delete(ctx, hasApp) + Expect(err == nil || errors.IsNotFound(err)).To(BeTrue()) + err = k8sClient.Delete(ctx, hasSnapshot) + Expect(err == nil || errors.IsNotFound(err)).To(BeTrue()) + }) + + It("can create and return a new Reconciler object", func() { + Expect(reflect.TypeOf(statusReportReconciler)).To(Equal(reflect.TypeOf(&Reconciler{}))) + }) + + It("can Reconcile when Reconcile fails to prepare the adapter when snapshot is found", func() { + Eventually(func() error { + _, err := statusReportReconciler.Reconcile(ctx, req) + return err + }).Should(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 := statusReportReconciler.Reconcile(ctx, req) + Expect(reflect.TypeOf(result)).To(Equal(reflect.TypeOf(reconcile.Result{}))) + Expect(err).To(BeNil()) + }) + + It("can setup the cache by adding a new index field to search for Snapshot", func() { + err := setupCache(manager) + Expect(err).ToNot(HaveOccurred()) + }) + + It("can setup a new controller manager with the given statusReportReconciler", func() { + err := setupControllerWithManager(manager, statusReportReconciler) + 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/statusreport/statusreport_suite_test.go b/controllers/statusreport/statusreport_suite_test.go new file mode 100644 index 000000000..b4940305e --- /dev/null +++ b/controllers/statusreport/statusreport_suite_test.go @@ -0,0 +1,111 @@ +/* +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 statusreport + +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 TestControllerSnapshot(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "StatusReport 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/git/github/github.go b/git/github/github.go index 407dc2c94..dacfe6a86 100644 --- a/git/github/github.go +++ b/git/github/github.go @@ -26,6 +26,16 @@ type CheckRunAdapter struct { CompletionTime time.Time } +// CommitStatusAdapter is an abstraction for the github.CommiStatus struct. +type CommitStatusAdapter struct { + Owner string + Repository string + SHA string + State string + Description string + Context string +} + // GetStatus returns the appropriate status based on conclusion and start time. func (s *CheckRunAdapter) GetStatus() string { if s.Conclusion == "success" || s.Conclusion == "failure" { @@ -56,6 +66,7 @@ type IssuesService interface { // RepositoriesService defines the methods used in the github Repositories service. type RepositoriesService interface { CreateStatus(ctx context.Context, owner string, repo string, ref string, status *ghapi.RepoStatus) (*ghapi.RepoStatus, *ghapi.Response, error) + ListStatuses(ctx context.Context, owner, repo, ref string, opts *ghapi.ListOptions) ([]*ghapi.RepoStatus, *ghapi.Response, error) } // ClientInterface defines the methods that should be implemented by a GitHub client @@ -65,8 +76,12 @@ type ClientInterface interface { CreateCheckRun(ctx context.Context, cra *CheckRunAdapter) (*int64, error) UpdateCheckRun(ctx context.Context, checkRunID int64, cra *CheckRunAdapter) error GetCheckRunID(ctx context.Context, owner string, repo string, SHA string, externalID string, appID int64) (*int64, error) + GetCheckRunIDAndIfUpdateNeeded(checkRuns []*ghapi.CheckRun, checkRun *CheckRunAdapter) (*int64, bool, error) CreateComment(ctx context.Context, owner string, repo string, issueNumber int, body string) (int64, error) CreateCommitStatus(ctx context.Context, owner string, repo string, SHA string, state string, description string, statusContext string) (int64, error) + GetAllCheckRunsForRef(ctx context.Context, owner string, repo string, SHA string, appID int64) ([]*ghapi.CheckRun, error) + GetAllCommitStatusesForRef(ctx context.Context, owner, repo, sha string) ([]*ghapi.RepoStatus, error) + HasCommitStatusExist(res []*ghapi.RepoStatus, commitStatus *CommitStatusAdapter) (bool, error) } // Client is an abstraction around the API client. @@ -302,6 +317,83 @@ func (c *Client) GetCheckRunID(ctx context.Context, owner string, repo string, S return nil, nil } +// GetAllCheckRunsForRef returns all existing GitHub CheckRuns if a match for the Owner, Repo, SHA, and appID. +func (c *Client) GetAllCheckRunsForRef(ctx context.Context, owner string, repo string, SHA string, appID int64) ([]*ghapi.CheckRun, error) { + filter := "all" + + res, _, err := c.GetChecksService().ListCheckRunsForRef( + ctx, + owner, + repo, + SHA, + &ghapi.ListCheckRunsOptions{ + AppID: &appID, + Filter: &filter, + }, + ) + + if err != nil { + return nil, err + } + + if *res.Total == 0 { + c.logger.Info("Found no CheckRuns for the ref", "SHA", SHA) + return nil, nil + } + + return res.CheckRuns, nil +} + +// GetCheckRunIDAndIfUpdateNeeded returns an existing GitHub CheckRun ID if a match is found and if updated/create is needed for the SHA, externalID and appID. +func (c *Client) GetCheckRunIDAndIfUpdateNeeded(checkRuns []*ghapi.CheckRun, checkRun *CheckRunAdapter) (*int64, bool, error) { + for _, cr := range checkRuns { + cr := cr + if *cr.ExternalID == checkRun.ExternalID { + + if checkRun.CompletionTime.After((*cr.CompletedAt).Time) { + // We need to update the existing checkrun if the completionTime is after the gotten checkrun's CompletionTime + c.logger.Info("Found CheckRun with a matching ExternalID and completedAt not later than the CompletedAt ", "CompletedAt", checkRun.CompletionTime) + return cr.ID, true, nil + } else { + // We don't need to update the existing checkrun if the completionTime is not after the gotten checkrun's CompletionTime + return cr.ID, false, nil + } + } + } + c.logger.Info("Found no CheckRuns with a matching ExternalID", "ExternalID", checkRun.ExternalID) + + return nil, true, nil +} + +func (c *Client) GetAllCommitStatusesForRef(ctx context.Context, owner, repo, sha string) ([]*ghapi.RepoStatus, error) { + res, _, err := c.GetRepositoriesService().ListStatuses(ctx, owner, repo, sha, &ghapi.ListOptions{}) + if err != nil { + return nil, err + } + + if len(res) == 0 { + c.logger.Info("Found no commitStatus for the ref", "SHA", sha) + return nil, nil + } + + return res, nil +} + +// GetCommitStatusID returns if a match is found for the SHA, externalID,appID, status, context and decription. +func (c *Client) HasCommitStatusExist(res []*ghapi.RepoStatus, commitStatus *CommitStatusAdapter) (bool, error) { + for _, cs := range res { + if *cs.State == commitStatus.State && *cs.Description == commitStatus.Description && *cs.Context == commitStatus.Context { + c.logger.Info("Found CommitStatus with matching conditions", "CommitStatus.State", commitStatus.State, "CommitStatus.Description", commitStatus.Description, "CommitStatus.Context", commitStatus.Context) + return true, nil + } else { + return false, nil + } + } + c.logger.Info("Found no CommitStatus with matching conditions", "CommitStatus.State", commitStatus.State, "CommitStatus.Description", commitStatus.Description, "CommitStatus.Context", commitStatus.Context) + + return true, nil +} + // CreateComment creates a new issue comment via the GitHub API. func (c *Client) CreateComment(ctx context.Context, owner string, repo string, issueNumber int, body string) (int64, error) { comment, _, err := c.GetIssuesService().CreateComment(ctx, owner, repo, issueNumber, &ghapi.IssueComment{Body: &body}) @@ -330,7 +422,7 @@ func (c *Client) CreateCommitStatus(ctx context.Context, owner string, repo stri "Owner", owner, "Repository", repo, "SHA", SHA, - "State", status.State, + "State", state, ) return *status.ID, nil } diff --git a/git/github/github_test.go b/git/github/github_test.go index 866d33295..c0e876b13 100644 --- a/git/github/github_test.go +++ b/git/github/github_test.go @@ -60,6 +60,16 @@ func (MockChecksService) ListCheckRunsForRef( return &ghapi.ListCheckRunsResults{Total: &total, CheckRuns: checkRuns}, nil, nil } +// GetAllCheckRunsForRef implements github.ChecksService +func (MockChecksService) GetAllCheckRunsForRef( + ctx context.Context, owner string, repo string, ref string, appID int64, +) ([]*ghapi.CheckRun, error) { + var id int64 = 20 + var externalID string = "example-external-id" + checkRuns := []*ghapi.CheckRun{{ID: &id, ExternalID: &externalID}} + return checkRuns, nil +} + // UpdateCheckRun implements github.ChecksService func (MockChecksService) UpdateCheckRun( ctx context.Context, owner string, repo string, checkRunID int64, opts ghapi.UpdateCheckRunOptions, @@ -89,6 +99,16 @@ func (MockRepositoriesService) CreateStatus( return &ghapi.RepoStatus{ID: &id, State: &state}, nil, nil } +// ListStatuses implements github.RepositoriesService +func (MockRepositoriesService) ListStatuses( + ctx context.Context, owner string, repo string, ref string, opts *ghapi.ListOptions, +) ([]*ghapi.RepoStatus, *ghapi.Response, error) { + var id int64 = 60 + var state = "success" + repoStatus := &ghapi.RepoStatus{ID: &id, State: &state} + return []*ghapi.RepoStatus{repoStatus}, nil, nil +} + var _ = Describe("CheckRunAdapter", func() { It("can compute status", func() { adapter := &github.CheckRunAdapter{Conclusion: "success", StartTime: time.Time{}} diff --git a/gitops/snapshot.go b/gitops/snapshot.go index 02fb74f06..b40a20002 100644 --- a/gitops/snapshot.go +++ b/gitops/snapshot.go @@ -32,6 +32,9 @@ const ( // SnapshotTestScenarioLabel contains the name of the Snapshot test scenario. SnapshotTestScenarioLabel = "test.appstudio.openshift.io/scenario" + // SnapshotTestScenarioLabel contains json data with test results of the particular snapshot + SnapshotTestsStatusAnnotation = "test.appstudio.openshift.io/status" + // BuildPipelineRunPrefix contains the build pipeline run related labels and annotations BuildPipelineRunPrefix = "build.appstudio" @@ -407,6 +410,26 @@ func HasSnapshotTestingChangedToFinished(objectOld, objectNew client.Object) boo return false } +// HasSnapshotAnnotationChanged returns a boolean indicating whether the Snapshot annotation has +// changed. If the objects passed to this function are not Snapshots, the function will return false. +func HasSnapshotTestAnnotationChanged(objectOld, objectNew client.Object) bool { + if oldSnapshot, ok := objectOld.(*applicationapiv1alpha1.Snapshot); ok { + if newSnapshot, ok := objectNew.(*applicationapiv1alpha1.Snapshot); ok { + if !metadata.HasAnnotation(oldSnapshot, SnapshotTestsStatusAnnotation) && metadata.HasAnnotation(newSnapshot, SnapshotTestsStatusAnnotation) { + return true + } + if old_value, ok := oldSnapshot.GetAnnotations()[SnapshotTestsStatusAnnotation]; ok { + if new_value, ok := newSnapshot.GetAnnotations()[SnapshotTestsStatusAnnotation]; ok { + if old_value != new_value { + return true + } + } + } + } + } + return false +} + // PrepareSnapshot 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 PrepareSnapshot(adapterClient client.Client, ctx context.Context, application *applicationapiv1alpha1.Application, applicationComponents *[]applicationapiv1alpha1.Component, component *applicationapiv1alpha1.Component, newContainerImage string, newComponentSource *applicationapiv1alpha1.ComponentSource) (*applicationapiv1alpha1.Snapshot, error) { @@ -480,3 +503,36 @@ func GetComponentSourceFromComponent(component *applicationapiv1alpha1.Component } return componentSource } + +// NewSnapshotIntegrationTestStatuses creates empty SnapshotTestStatus struct +func NewSnapshotIntegrationTestStatuses() *SnapshotIntegrationTestStatuses { + sits := SnapshotIntegrationTestStatuses{ + statuses: make(map[string]*IntegrationTestStatusDetail, 1), + dirty: false, + } + return &sits +} + +// IntegrationTestStatusDetail contains metadata about the particular scenario testing status +type IntegrationTestStatusDetail struct { + // ScenarioName name + ScenarioName string `json:"scenario"` + // The status summary for the ITS and Snapshot + Status IntegrationTestStatus `json:"status"` + // The time of starting to handle integrationTestScenario + StartTime time.Time `json:"started_at"` + // The time of completing to handle integrationTestScenario + CompletionTime time.Time `json:"completed_at"` + // The details of reported status + Details string `json:"details"` +} + +// SnapshotIntegrationTestStatuses type handles details about snapshot tests +// Please note that internal representation differs from marshalled representation +// Data are not written directly into snapshot, they are just cached in this structure +type SnapshotIntegrationTestStatuses struct { + // map scenario name to test details + statuses map[string]*IntegrationTestStatusDetail + // flag if any updates have been done + dirty bool +} diff --git a/gitops/snapshot_predicate.go b/gitops/snapshot_predicate.go index f0c67ef13..c2e1f3735 100644 --- a/gitops/snapshot_predicate.go +++ b/gitops/snapshot_predicate.go @@ -23,3 +23,22 @@ func IntegrationSnapshotChangePredicate() predicate.Predicate { }, } } + +// SnapshotTestAnnotationChangePredicate returns a predicate which filters out all objects except +// snapshot annotation "test.appstudio.openshift.io/status" is changed for update events. +func SnapshotTestAnnotationChangePredicate() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(createEvent event.CreateEvent) bool { + return true + }, + DeleteFunc: func(deleteEvent event.DeleteEvent) bool { + return false + }, + GenericFunc: func(genericEvent event.GenericEvent) bool { + return true + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return HasSnapshotTestAnnotationChanged(e.ObjectOld, e.ObjectNew) + }, + } +} diff --git a/gitops/snapshot_predicate_test.go b/gitops/snapshot_predicate_test.go index f9edfd734..de235bdd3 100644 --- a/gitops/snapshot_predicate_test.go +++ b/gitops/snapshot_predicate_test.go @@ -31,16 +31,20 @@ import ( var _ = Describe("Predicates", Ordered, func() { const ( - namespace = "default" - applicationName = "test-application" - componentName = "test-component" - snapshotOldName = "test-snapshot-old" - snapshotNewName = "test-snapshot-new" + namespace = "default" + applicationName = "test-application" + componentName = "test-component" + snapshotOldName = "test-snapshot-old" + snapshotNewName = "test-snapshot-new" + snapshotAnnotationOld = "snapshot-annotation-old" + snapshotAnnotationNew = "snapshot-annotation-new" ) var ( hasSnapshotUnknownStatus *applicationapiv1alpha1.Snapshot hasSnapshotTrueStatus *applicationapiv1alpha1.Snapshot + hasSnapshotAnnotationOld *applicationapiv1alpha1.Snapshot + hasSnapshotAnnotationNew *applicationapiv1alpha1.Snapshot sampleImage string ) @@ -86,10 +90,57 @@ var _ = Describe("Predicates", Ordered, func() { }, } + hasSnapshotAnnotationOld = &applicationapiv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: snapshotAnnotationOld, + Namespace: namespace, + Labels: map[string]string{ + gitops.SnapshotTypeLabel: gitops.SnapshotComponentType, + gitops.SnapshotComponentLabel: componentName, + }, + Annotations: map[string]string{ + gitops.SnapshotTestsStatusAnnotation: "[{\"ScenarioName\":\"scenario-1\",\"Status\":\"EnvironmentProvisionError\",\"StartTime\":\"2023-07-26T16:57:49+02:00\",\"CompletionTime\":\"2023-07-26T17:57:49+02:00\",\"Details\":\"Failed to find deploymentTargetClass with right provisioner for copy of existingEnvironment\"}]", + }, + }, + Spec: applicationapiv1alpha1.SnapshotSpec{ + Application: applicationName, + Components: []applicationapiv1alpha1.SnapshotComponent{ + { + Name: componentName, + ContainerImage: sampleImage, + }, + }, + }, + } + + hasSnapshotAnnotationNew = &applicationapiv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: snapshotAnnotationNew, + Namespace: namespace, + Labels: map[string]string{ + gitops.SnapshotTypeLabel: gitops.SnapshotComponentType, + gitops.SnapshotComponentLabel: componentName, + }, + Annotations: map[string]string{ + gitops.SnapshotTestsStatusAnnotation: "[{\"ScenarioName\":\"scenario-1\",\"Status\":\"TestPassed\",\"StartTime\":\"2023-07-26T16:57:49+02:00\",\"CompletionTime\":\"2023-07-26T17:57:49+02:00\",\"Details\": \"test pass\"}]", + }, + }, + Spec: applicationapiv1alpha1.SnapshotSpec{ + Application: applicationName, + Components: []applicationapiv1alpha1.SnapshotComponent{ + { + Name: componentName, + ContainerImage: sampleImage, + }, + }, + }, + } ctx := context.Background() Expect(k8sClient.Create(ctx, hasSnapshotUnknownStatus)).Should(Succeed()) Expect(k8sClient.Create(ctx, hasSnapshotTrueStatus)).Should(Succeed()) + Expect(k8sClient.Create(ctx, hasSnapshotAnnotationOld)).Should(Succeed()) + Expect(k8sClient.Create(ctx, hasSnapshotAnnotationNew)).Should(Succeed()) // Set the binding statuses after they are created hasSnapshotUnknownStatus.Status.Conditions = []metav1.Condition{ @@ -137,4 +188,16 @@ var _ = Describe("Predicates", Ordered, func() { Expect(instance.Delete(contextEvent)).To(BeFalse()) }) }) + + Context("when testing IntegrationSnapshotChangePredicate predicate", func() { + instance := gitops.SnapshotTestAnnotationChangePredicate() + + It("returns true when the annotation of Snapshot changed ", func() { + contextEvent := event.UpdateEvent{ + ObjectOld: hasSnapshotAnnotationOld, + ObjectNew: hasSnapshotAnnotationNew, + } + Expect(instance.Update(contextEvent)).To(BeTrue()) + }) + }) }) diff --git a/status/reporters.go b/status/reporters.go index 95ab14b1b..6b6f0d02b 100644 --- a/status/reporters.go +++ b/status/reporters.go @@ -9,6 +9,7 @@ import ( "github.com/go-logr/logr" pacv1alpha1 "github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/v1alpha1" + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" "github.com/redhat-appstudio/integration-service/git/github" "github.com/redhat-appstudio/integration-service/gitops" "github.com/redhat-appstudio/integration-service/helpers" @@ -57,12 +58,12 @@ type appCredentials struct { PrivateKey []byte } -func (r *GitHubReporter) getAppCredentials(ctx context.Context, pipelineRun *tektonv1beta1.PipelineRun) (*appCredentials, error) { +func (r *GitHubReporter) getAppCredentials(ctx context.Context, object client.Object) (*appCredentials, error) { var err error var found bool appInfo := appCredentials{} - appInfo.InstallationID, err = strconv.ParseInt(pipelineRun.GetAnnotations()[gitops.PipelineAsCodeInstallationIDAnnotation], 10, 64) + appInfo.InstallationID, err = strconv.ParseInt(object.GetAnnotations()[gitops.PipelineAsCodeInstallationIDAnnotation], 10, 64) if err != nil { return nil, err } @@ -94,19 +95,19 @@ func (r *GitHubReporter) getAppCredentials(ctx context.Context, pipelineRun *tek return &appInfo, nil } -func (r *GitHubReporter) getToken(ctx context.Context, pipelineRun *tektonv1beta1.PipelineRun) (string, error) { +func (r *GitHubReporter) getToken(ctx context.Context, object client.Object, namespace string) (string, error) { var err error - // List all the Repository CRs in the PipelineRun's namespace + // List all the Repository CRs in the namespace repos := pacv1alpha1.RepositoryList{} - if err = r.k8sClient.List(ctx, &repos, &client.ListOptions{Namespace: pipelineRun.Namespace}); err != nil { + if err = r.k8sClient.List(ctx, &repos, &client.ListOptions{Namespace: namespace}); err != nil { return "", err } // Get the full repo URL - url, found := pipelineRun.GetAnnotations()[gitops.PipelineAsCodeRepoURLAnnotation] + url, found := object.GetAnnotations()[gitops.PipelineAsCodeRepoURLAnnotation] if !found { - return "", fmt.Errorf("PipelineRun annotation not found %q", gitops.PipelineAsCodeRepoURLAnnotation) + return "", fmt.Errorf("object annotation not found %q", gitops.PipelineAsCodeRepoURLAnnotation) } // Find a Repository CR with a matching URL and get its secret details @@ -124,7 +125,7 @@ func (r *GitHubReporter) getToken(ctx context.Context, pipelineRun *tektonv1beta // Get the pipelines as code secret from the PipelineRun's namespace pacSecret := v1.Secret{} - err = r.k8sClient.Get(ctx, types.NamespacedName{Namespace: pipelineRun.Namespace, Name: repoSecret.Name}, &pacSecret) + err = r.k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: repoSecret.Name}, &pacSecret) if err != nil { return "", err } @@ -226,6 +227,44 @@ func (r *GitHubReporter) createCheckRunAdapter(k8sClient client.Client, ctx cont }, nil } +func (r *GitHubReporter) createCheckRunAdapterForSnapshot(snapshot *applicationapiv1alpha1.Snapshot, integrationTestStatusDetail gitops.IntegrationTestStatusDetail, owner, repo, sha string) (*github.CheckRunAdapter, error) { + var title, conclusion, summary string + snapshotName := snapshot.Name + scenarioName := integrationTestStatusDetail.ScenarioName + + switch integrationTestStatusDetail.Status { + case gitops.IntegrationTestStatusEnvironmentProvisionError: + title = "snapshot " + snapshotName + " experienced error when provisioning environment for integrationTestScenario " + scenarioName + conclusion = "errorOccured" + case gitops.IntegrationTestStatusDeploymentError: + title = "snapshot " + snapshotName + " experienced error when deploying snapshotEnvironmentBinding for integrationTestScenario " + scenarioName + conclusion = "errorOccured" + case gitops.IntegrationTestStatusTestPassed: + title = "snapshot " + snapshotName + " has passed integration test against integrationTestScenario " + scenarioName + conclusion = "success" + case gitops.IntegrationTestStatusTestFail: + title = "snapshot " + snapshotName + " has failed integration test against integrationTestScenario " + scenarioName + conclusion = "failure" + default: + return nil, fmt.Errorf("unknown status %s for integrationTestScenario %s/%s", integrationTestStatusDetail.Status, snapshot.Namespace, scenarioName) + } + + return &github.CheckRunAdapter{ + Owner: owner, + Repository: repo, + Name: NamePrefix + " / " + snapshotName + " / " + scenarioName, + SHA: sha, + ExternalID: scenarioName, + Conclusion: conclusion, + Title: title, + // This summary will be reworked once PLNSRVCE-1295 is implemented in the future + Summary: summary, + Text: integrationTestStatusDetail.Details, + StartTime: integrationTestStatusDetail.StartTime, + CompletionTime: integrationTestStatusDetail.CompletionTime, + }, nil +} + func (r *GitHubReporter) createCommitStatus(k8sClient client.Client, ctx context.Context, pipelineRun *tektonv1beta1.PipelineRun) error { var ( state string @@ -289,6 +328,43 @@ func (r *GitHubReporter) createCommitStatus(k8sClient client.Client, ctx context return nil } +func (r *GitHubReporter) createCommitStatusAdapterForSnapshot(snapshot *applicationapiv1alpha1.Snapshot, integrationTestStatusDetail gitops.IntegrationTestStatusDetail, owner, repo, sha string) (*github.CommitStatusAdapter, error) { + var ( + state string + description string + ) + + snapshotName := snapshot.Name + scenarioName := integrationTestStatusDetail.ScenarioName + statusContext := NamePrefix + " / " + snapshot.Name + " / " + scenarioName + + switch integrationTestStatusDetail.Status { + case gitops.IntegrationTestStatusEnvironmentProvisionError: + state = "errorOccured" + description = "snapshot " + snapshotName + " experienced error when provisioning environment for integrationTestScenario " + scenarioName + case gitops.IntegrationTestStatusDeploymentError: + state = "errorOccured" + description = "snapshot " + snapshotName + " experienced error when deploying snapshotEnvironmentBinding for integrationTestScenario " + scenarioName + case gitops.IntegrationTestStatusTestPassed: + state = "success" + description = "snapshot " + snapshotName + " has passed integration test against integrationTestScenario " + scenarioName + case gitops.IntegrationTestStatusTestFail: + state = "failure" + description = "snapshot " + snapshotName + " has failed integration test against integrationTestScenario " + scenarioName + default: + return nil, fmt.Errorf("unknown status %s for integrationTestScenario %s/%s", integrationTestStatusDetail.Status, snapshot.Namespace, scenarioName) + } + + return &github.CommitStatusAdapter{ + Owner: owner, + Repository: repo, + SHA: sha, + State: state, + Description: description, + Context: statusContext, + }, nil +} + func (r *GitHubReporter) createComment(k8sClient client.Client, ctx context.Context, pipelineRun *tektonv1beta1.PipelineRun) error { labels := pipelineRun.GetLabels() @@ -393,7 +469,7 @@ func (r *GitHubReporter) ReportStatus(k8sClient client.Client, ctx context.Conte return err } } else { - token, err := r.getToken(ctx, pipelineRun) + token, err := r.getToken(ctx, pipelineRun, pipelineRun.Namespace) if err != nil { return err } @@ -413,3 +489,134 @@ func (r *GitHubReporter) ReportStatus(k8sClient client.Client, ctx context.Conte return nil } + +// ReportStatusForSnapshot creates CheckRuns when using GitHub App integration. +// When using GitHub webhook integration a commit status +func (r *GitHubReporter) ReportStatusForSnapshot(k8sClient client.Client, ctx context.Context, snapshot *applicationapiv1alpha1.Snapshot, details *[]gitops.IntegrationTestStatusDetail) error { + if !metadata.HasLabelWithValue(snapshot, gitops.PipelineAsCodeEventTypeLabel, gitops.PipelineAsCodePullRequestType) { + return nil + } + + // Existence of the Pipelines as Code installation ID annotation signals configuration using GitHub App integration. + // If it doesn't exist, GitHub webhook integration is configured. + if metadata.HasAnnotation(snapshot, gitops.PipelineAsCodeInstallationIDAnnotation) { + creds, err := r.getAppCredentials(ctx, snapshot) + if err != nil { + return err + } + + token, err := r.client.CreateAppInstallationToken(ctx, creds.AppID, creds.InstallationID, creds.PrivateKey) + if err != nil { + return err + } + + r.client.SetOAuthToken(ctx, token) + + labels := snapshot.GetLabels() + + owner, found := labels[gitops.PipelineAsCodeURLOrgLabel] + if !found { + return fmt.Errorf("snapshot label not found %q", gitops.PipelineAsCodeURLOrgLabel) + } + + repo, found := labels[gitops.PipelineAsCodeURLRepositoryLabel] + if !found { + return fmt.Errorf("snapshot label not found %q", gitops.PipelineAsCodeURLRepositoryLabel) + } + + sha, found := labels[gitops.PipelineAsCodeSHALabel] + if !found { + return fmt.Errorf("snapshot label not found %q", gitops.PipelineAsCodeSHALabel) + } + + allCheckRuns, err := r.client.GetAllCheckRunsForRef(ctx, owner, repo, sha, creds.AppID) + if err != nil { + return err + } + + for _, integrationTestStatusDetail := range *details { + // We report the final status of handling a integrationTestScenario only + // such as TestFail, TestPassed, DeploymentError, EnvironmentProvisionError + integrationTestStatusDetail := integrationTestStatusDetail // G601 + if integrationTestStatusDetail.Status == gitops.IntegrationTestStatusEnvironmentProvisionError || integrationTestStatusDetail.Status == gitops.IntegrationTestStatusDeploymentError || integrationTestStatusDetail.Status == gitops.IntegrationTestStatusTestFail || integrationTestStatusDetail.Status == gitops.IntegrationTestStatusTestPassed { + checkRun, err := r.createCheckRunAdapterForSnapshot(snapshot, integrationTestStatusDetail, owner, repo, sha) + if err != nil { + return err + } + + checkRunID, updateIsNeeded, err := r.client.GetCheckRunIDAndIfUpdateNeeded(allCheckRuns, checkRun) + if err != nil { + return err + } + + if checkRunID == nil { + _, err = r.client.CreateCheckRun(ctx, checkRun) + } else if checkRunID != nil && updateIsNeeded { + err = r.client.UpdateCheckRun(ctx, *checkRunID, checkRun) + } + + if err != nil { + return err + } + } else { + continue + } + + } + } else { + token, err := r.getToken(ctx, snapshot, snapshot.Namespace) + if err != nil { + return err + } + + r.client.SetOAuthToken(ctx, token) + + labels := snapshot.GetLabels() + + owner, found := labels[gitops.PipelineAsCodeURLOrgLabel] + if !found { + return fmt.Errorf("PipelineRun label not found %q", gitops.PipelineAsCodeURLOrgLabel) + } + + repo, found := labels[gitops.PipelineAsCodeURLRepositoryLabel] + if !found { + return fmt.Errorf("PipelineRun label not found %q", gitops.PipelineAsCodeURLRepositoryLabel) + } + + sha, found := labels[gitops.PipelineAsCodeSHALabel] + if !found { + return fmt.Errorf("PipelineRun label not found %q", gitops.PipelineAsCodeSHALabel) + } + + allCommitStatuses, err := r.client.GetAllCommitStatusesForRef(ctx, owner, repo, sha) + if err != nil { + return err + } + + for _, integrationTestStatusDetail := range *details { + integrationTestStatusDetail := integrationTestStatusDetail //G601 + if integrationTestStatusDetail.Status == gitops.IntegrationTestStatusEnvironmentProvisionError || integrationTestStatusDetail.Status == gitops.IntegrationTestStatusDeploymentError || integrationTestStatusDetail.Status == gitops.IntegrationTestStatusTestFail || integrationTestStatusDetail.Status == gitops.IntegrationTestStatusTestPassed { + commitStatus, err := r.createCommitStatusAdapterForSnapshot(snapshot, integrationTestStatusDetail, owner, repo, sha) + if err != nil { + return err + } + + hasCommitStatusExist, err := r.client.HasCommitStatusExist(allCommitStatuses, commitStatus) + if err != nil { + return err + } + + if !hasCommitStatusExist { + _, err = r.client.CreateCommitStatus(ctx, commitStatus.Owner, commitStatus.Repository, commitStatus.SHA, commitStatus.State, commitStatus.Description, commitStatus.Context) + if err != nil { + return err + } + } + } else { + continue + } + } + } + + return nil +} diff --git a/status/reporters_test.go b/status/reporters_test.go index e2685d2a8..7aa21b55b 100644 --- a/status/reporters_test.go +++ b/status/reporters_test.go @@ -9,10 +9,13 @@ import ( "k8s.io/apimachinery/pkg/runtime" "github.com/go-logr/logr" + ghapi "github.com/google/go-github/v45/github" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" pacv1alpha1 "github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/v1alpha1" + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" "github.com/redhat-appstudio/integration-service/git/github" + "github.com/redhat-appstudio/integration-service/gitops" "github.com/redhat-appstudio/integration-service/status" tektonv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" v1 "k8s.io/api/core/v1" @@ -87,6 +90,13 @@ func (c *MockGitHubClient) GetCheckRunID(context.Context, string, string, string return c.GetCheckRunIDResult.ID, c.GetCheckRunIDResult.Error } +func (c *MockGitHubClient) GetCheckRunIDAndIfUpdateNeeded([]*ghapi.CheckRun, *github.CheckRunAdapter) (*int64, bool, error) { + return c.GetCheckRunIDResult.ID, true, c.GetCheckRunIDResult.Error +} + +func (c *MockGitHubClient) HasCommitStatusExist(res []*ghapi.RepoStatus, commitStatus *github.CommitStatusAdapter) (bool, error) { + return false, nil +} func (c *MockGitHubClient) CreateComment(ctx context.Context, owner string, repo string, issueNumber int, body string) (int64, error) { c.CreateCommentResult.body = body c.CreateCommentResult.issueNumber = issueNumber @@ -100,6 +110,23 @@ func (c *MockGitHubClient) CreateCommitStatus(ctx context.Context, owner string, return c.CreateCommitStatusResult.ID, c.CreateCommitStatusResult.Error } +func (c *MockGitHubClient) GetAllCheckRunsForRef( + ctx context.Context, owner string, repo string, ref string, appID int64, +) ([]*ghapi.CheckRun, error) { + var id int64 = 20 + var externalID string = "example-external-id" + checkRuns := []*ghapi.CheckRun{{ID: &id, ExternalID: &externalID}} + return checkRuns, nil +} + +func (c *MockGitHubClient) GetAllCommitStatusesForRef( + ctx context.Context, owner, repo, sha string) ([]*ghapi.RepoStatus, error) { + var id int64 = 60 + var state = "success" + repoStatus := &ghapi.RepoStatus{ID: &id, State: &state} + return []*ghapi.RepoStatus{repoStatus}, nil +} + type MockK8sClient struct { getInterceptor func(key client.ObjectKey, obj client.Object) listInterceptor func(list client.ObjectList) @@ -200,6 +227,8 @@ var _ = Describe("GitHubReporter", func() { var successfulTaskRun *tektonv1beta1.TaskRun var failedTaskRun *tektonv1beta1.TaskRun var skippedTaskRun *tektonv1beta1.TaskRun + var hasSnapshot *applicationapiv1alpha1.Snapshot + var integrationTestStatusDetails *[]gitops.IntegrationTestStatusDetail BeforeEach(func() { now := time.Now() @@ -330,6 +359,61 @@ var _ = Describe("GitHubReporter", func() { }, }, } + + hasSnapshot = &applicationapiv1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "snapshot-sample", + Namespace: "default", + Labels: map[string]string{ + "test.appstudio.openshift.io/type": "component", + "appstudio.openshift.io/component": "component-sample", + "build.appstudio.redhat.com/pipeline": "enterprise-contract", + "pac.test.appstudio.openshift.io/git-provider": "github", + "pac.test.appstudio.openshift.io/url-org": "devfile-sample", + "pac.test.appstudio.openshift.io/url-repository": "devfile-sample-go-basic", + "pac.test.appstudio.openshift.io/sha": "12a4a35ccd08194595179815e4646c3a6c08bb77", + "pac.test.appstudio.openshift.io/event-type": "pull_request", + }, + Annotations: map[string]string{ + "build.appstudio.redhat.com/commit_sha": "6c65b2fcaea3e1a0a92476c8b5dc89e92a85f025", + "appstudio.redhat.com/updateComponentOnSuccess": "false", + "pac.test.appstudio.openshift.io/repo-url": "https://github.com/devfile-sample/devfile-sample-go-basic", + }, + }, + Spec: applicationapiv1alpha1.SnapshotSpec{ + Application: "application-sample", + Components: []applicationapiv1alpha1.SnapshotComponent{ + { + Name: "component-sample", + ContainerImage: "sample_image", + Source: applicationapiv1alpha1.ComponentSource{ + ComponentSourceUnion: applicationapiv1alpha1.ComponentSourceUnion{ + GitSource: &applicationapiv1alpha1.GitSource{ + Revision: "sample_revision", + }, + }, + }, + }, + }, + }, + } + + integrationTestStatusDetails = &[]gitops.IntegrationTestStatusDetail{ + { + ScenarioName: "scenario1", + Status: gitops.IntegrationTestStatusInProgress, + StartTime: time.Now(), + CompletionTime: time.Now(), + Details: "details", + }, + { + ScenarioName: "scenario2", + Status: gitops.IntegrationTestStatusEnvironmentProvisionError, + StartTime: time.Now(), + CompletionTime: time.Now(), + Details: "details", + }, + } }) Context("when provided GitHub app credentials", func() { @@ -338,6 +422,7 @@ var _ = Describe("GitHubReporter", func() { BeforeEach(func() { pipelineRun.Annotations["pac.test.appstudio.openshift.io/installation-id"] = "123" + hasSnapshot.Annotations["pac.test.appstudio.openshift.io/installation-id"] = "123" secretData = map[string][]byte{ "github-application-id": []byte("456"), @@ -369,6 +454,8 @@ var _ = Describe("GitHubReporter", func() { It("doesn't report status for non-pull request events", func() { delete(pipelineRun.Labels, "pac.test.appstudio.openshift.io/event-type") Expect(reporter.ReportStatus(mockK8sClient, context.TODO(), pipelineRun)).To(BeNil()) + delete(hasSnapshot.Labels, "pac.test.appstudio.openshift.io/event-type") + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails)).To(BeNil()) }) It("doesn't report status when the credentials are invalid/missing", func() { @@ -378,22 +465,33 @@ var _ = Describe("GitHubReporter", func() { Expect(err).ToNot(BeNil()) pipelineRun.Annotations["pac.test.appstudio.openshift.io/installation-id"] = "123" + hasSnapshot.Annotations["pac.test.appstudio.openshift.io/installation-id"] = "bad-installation-id" + err = reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails) + Expect(err).ToNot(BeNil()) + hasSnapshot.Annotations["pac.test.appstudio.openshift.io/installation-id"] = "123" + // Invalid app ID value secretData["github-application-id"] = []byte("bad-app-id") err = reporter.ReportStatus(mockK8sClient, context.TODO(), pipelineRun) Expect(err).ToNot(BeNil()) + err = reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails) + Expect(err).ToNot(BeNil()) secretData["github-application-id"] = []byte("456") // Missing app ID value delete(secretData, "github-application-id") err = reporter.ReportStatus(mockK8sClient, context.TODO(), pipelineRun) Expect(err).ToNot(BeNil()) + err = reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails) + Expect(err).ToNot(BeNil()) secretData["github-application-id"] = []byte("456") // Missing private key delete(secretData, "github-private-key") err = reporter.ReportStatus(mockK8sClient, context.TODO(), pipelineRun) Expect(err).ToNot(BeNil()) + err = reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails) + Expect(err).ToNot(BeNil()) }) It("reports status via CheckRuns", func() { @@ -426,6 +524,75 @@ var _ = Describe("GitHubReporter", func() { Expect(mockGitHubClient.UpdateCheckRunResult.cra.Title).To(Equal("example-pass has failed")) Expect(mockGitHubClient.UpdateCheckRunResult.cra.Conclusion).To(Equal("failure")) }) + + It("reports snapshot tests status via CheckRuns", func() { + // Create an in progress CheckRun + integrationTestStatusDetails = &[]gitops.IntegrationTestStatusDetail{ + { + ScenarioName: "scenario1", + Status: gitops.IntegrationTestStatusEnvironmentProvisionError, + StartTime: time.Now(), + CompletionTime: time.Now(), + Details: "details", + }, + } + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails)).To(BeNil()) + Expect(mockGitHubClient.CreateCheckRunResult.cra.Title).To(Equal("snapshot snapshot-sample experienced error when provisioning environment for integrationTestScenario scenario1")) + Expect(mockGitHubClient.CreateCheckRunResult.cra.Conclusion).To(Equal("errorOccured")) + Expect(mockGitHubClient.CreateCheckRunResult.cra.ExternalID).To(Equal("scenario1")) + Expect(mockGitHubClient.CreateCheckRunResult.cra.Owner).To(Equal("devfile-sample")) + Expect(mockGitHubClient.CreateCheckRunResult.cra.Repository).To(Equal("devfile-sample-go-basic")) + Expect(mockGitHubClient.CreateCheckRunResult.cra.SHA).To(Equal("12a4a35ccd08194595179815e4646c3a6c08bb77")) + Expect(mockGitHubClient.CreateCheckRunResult.cra.Name).To(Equal("Red Hat Trusted App Test / snapshot-sample / scenario1")) + Expect(mockGitHubClient.CreateCheckRunResult.cra.StartTime.IsZero()).To(BeFalse()) + Expect(mockGitHubClient.CreateCheckRunResult.cra.CompletionTime.IsZero()).To(BeFalse()) + + // Update existing CheckRun w/failure + var id int64 = 1 + mockGitHubClient.GetCheckRunIDResult.ID = &id + integrationTestStatusDetails = &[]gitops.IntegrationTestStatusDetail{ + { + ScenarioName: "scenario1", + Status: gitops.IntegrationTestStatusDeploymentError, + StartTime: time.Now(), + CompletionTime: time.Now(), + Details: "details", + }, + } + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails)).To(BeNil()) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Title).To(Equal("snapshot snapshot-sample experienced error when deploying snapshotEnvironmentBinding for integrationTestScenario scenario1")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Conclusion).To(Equal("errorOccured")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.CompletionTime.IsZero()).To(BeFalse()) + + integrationTestStatusDetails = &[]gitops.IntegrationTestStatusDetail{ + { + ScenarioName: "scenario1", + Status: gitops.IntegrationTestStatusTestFail, + StartTime: time.Now(), + CompletionTime: time.Now(), + Details: "details", + }, + } + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails)).To(BeNil()) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Title).To(Equal("snapshot snapshot-sample has failed integration test against integrationTestScenario scenario1")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Conclusion).To(Equal("failure")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.CompletionTime.IsZero()).To(BeFalse()) + + // Update existing CheckRun w/success + integrationTestStatusDetails = &[]gitops.IntegrationTestStatusDetail{ + { + ScenarioName: "scenario1", + Status: gitops.IntegrationTestStatusTestPassed, + StartTime: time.Now(), + CompletionTime: time.Now(), + Details: "details", + }, + } + setPipelineRunOutcome(pipelineRun, failedTaskRun) + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails)).To(BeNil()) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Title).To(Equal("snapshot snapshot-sample has passed integration test against integrationTestScenario scenario1")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Conclusion).To(Equal("success")) + }) }) Context("when provided GitHub webhook integration credentials", func() { @@ -435,6 +602,7 @@ var _ = Describe("GitHubReporter", func() { BeforeEach(func() { pipelineRun.Annotations["pac.test.appstudio.openshift.io/pull-request"] = "999" + hasSnapshot.Annotations["pac.test.appstudio.openshift.io/pull-request"] = "999" repo = pacv1alpha1.Repository{ Spec: pacv1alpha1.RepositorySpec{ @@ -531,6 +699,66 @@ var _ = Describe("GitHubReporter", func() { Expect(mockGitHubClient.CreateCommitStatusResult.description).To(Equal("example-pass has failed")) Expect(mockGitHubClient.CreateCommitStatusResult.statusContext).To(Equal("Red Hat Trusted App Test / devfile-sample-go-basic / example-pass")) }) - }) + It("creates a commit status for snapshot", func() { + // Error + integrationTestStatusDetails := &[]gitops.IntegrationTestStatusDetail{ + { + ScenarioName: "scenario1", + Status: gitops.IntegrationTestStatusEnvironmentProvisionError, + StartTime: time.Now(), + CompletionTime: time.Now(), + Details: "details", + }, + } + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails)).To(BeNil()) + Expect(mockGitHubClient.CreateCommitStatusResult.state).To(Equal("errorOccured")) + Expect(mockGitHubClient.CreateCommitStatusResult.description).To(Equal("snapshot snapshot-sample experienced error when provisioning environment for integrationTestScenario scenario1")) + Expect(mockGitHubClient.CreateCommitStatusResult.statusContext).To(Equal("Red Hat Trusted App Test / snapshot-sample / scenario1")) + + integrationTestStatusDetails = &[]gitops.IntegrationTestStatusDetail{ + { + ScenarioName: "scenario1", + Status: gitops.IntegrationTestStatusDeploymentError, + StartTime: time.Now(), + CompletionTime: time.Now(), + Details: "details", + }, + } + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails)).To(BeNil()) + Expect(mockGitHubClient.CreateCommitStatusResult.state).To(Equal("errorOccured")) + Expect(mockGitHubClient.CreateCommitStatusResult.description).To(Equal("snapshot snapshot-sample experienced error when deploying snapshotEnvironmentBinding for integrationTestScenario scenario1")) + Expect(mockGitHubClient.CreateCommitStatusResult.statusContext).To(Equal("Red Hat Trusted App Test / snapshot-sample / scenario1")) + + // Success + integrationTestStatusDetails = &[]gitops.IntegrationTestStatusDetail{ + { + ScenarioName: "scenario1", + Status: gitops.IntegrationTestStatusTestPassed, + StartTime: time.Now(), + CompletionTime: time.Now(), + Details: "details", + }, + } + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails)).To(BeNil()) + Expect(mockGitHubClient.CreateCommitStatusResult.state).To(Equal("success")) + Expect(mockGitHubClient.CreateCommitStatusResult.description).To(Equal("snapshot snapshot-sample has passed integration test against integrationTestScenario scenario1")) + Expect(mockGitHubClient.CreateCommitStatusResult.statusContext).To(Equal("Red Hat Trusted App Test / snapshot-sample / scenario1")) + + // Failure + integrationTestStatusDetails = &[]gitops.IntegrationTestStatusDetail{ + { + ScenarioName: "scenario1", + Status: gitops.IntegrationTestStatusTestFail, + StartTime: time.Now(), + CompletionTime: time.Now(), + Details: "details", + }, + } + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), hasSnapshot, integrationTestStatusDetails)).To(BeNil()) + Expect(mockGitHubClient.CreateCommitStatusResult.state).To(Equal("failure")) + Expect(mockGitHubClient.CreateCommitStatusResult.description).To(Equal("snapshot snapshot-sample has failed integration test against integrationTestScenario scenario1")) + Expect(mockGitHubClient.CreateCommitStatusResult.statusContext).To(Equal("Red Hat Trusted App Test / snapshot-sample / scenario1")) + }) + }) }) diff --git a/status/status.go b/status/status.go index e02cf97a6..eeeec8a95 100644 --- a/status/status.go +++ b/status/status.go @@ -4,6 +4,7 @@ import ( "context" "github.com/go-logr/logr" + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" "github.com/redhat-appstudio/integration-service/gitops" "github.com/redhat-appstudio/operator-toolkit/metadata" tektonv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" @@ -16,11 +17,12 @@ const NamePrefix = "Red Hat Trusted App Test" // Reporter is a generic interface all status implementations must follow. type Reporter interface { ReportStatus(client.Client, context.Context, *tektonv1beta1.PipelineRun) error + ReportStatusForSnapshot(client.Client, context.Context, *applicationapiv1alpha1.Snapshot, *[]gitops.IntegrationTestStatusDetail) error } // Status is the interface of the main status Adapter. type Status interface { - GetReporters(*tektonv1beta1.PipelineRun) ([]Reporter, error) + GetReporters(client.Object) ([]Reporter, error) } // Adapter is responsible for discovering supported Reporter implementations. @@ -57,10 +59,10 @@ func NewAdapter(logger logr.Logger, k8sClient client.Client, opts ...AdapterOpti // GetReporters returns a list of enabled/supported status reporters for a PipelineRun. // All potential reporters must be added to this function for them to be utilized. -func (a *Adapter) GetReporters(pipelineRun *tektonv1beta1.PipelineRun) ([]Reporter, error) { +func (a *Adapter) GetReporters(object client.Object) ([]Reporter, error) { var reporters []Reporter - if metadata.HasLabelWithValue(pipelineRun, gitops.PipelineAsCodeGitProviderLabel, gitops.PipelineAsCodeGitHubProviderType) { + if metadata.HasLabelWithValue(object, gitops.PipelineAsCodeGitProviderLabel, gitops.PipelineAsCodeGitHubProviderType) { reporters = append(reporters, a.githubReporter) } diff --git a/status/status_test.go b/status/status_test.go index d54916575..605bb886d 100644 --- a/status/status_test.go +++ b/status/status_test.go @@ -5,6 +5,8 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + "github.com/redhat-appstudio/integration-service/gitops" "github.com/redhat-appstudio/integration-service/status" "github.com/go-logr/logr" @@ -19,6 +21,10 @@ func (r *MockReporter) ReportStatus(client.Client, context.Context, *tektonv1bet return nil } +func (r *MockReporter) ReportStatusForSnapshot(client.Client, context.Context, *applicationapiv1alpha1.Snapshot, *[]gitops.IntegrationTestStatusDetail) error { + return nil +} + var _ = Describe("Status Adapter", func() { var pipelineRun *tektonv1beta1.PipelineRun