From 05cd934a3a9fa62999ec3695967f28aa9affa4bf 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 * Add diagram for statusreport controller Signed-off-by: Hongwei Liu --- controllers/controllers.go | 2 + .../integrationpipeline_adapter_test.go | 7 +- .../statusreport/statusreport_adapter.go | 73 +++++ .../statusreport/statusreport_adapter_test.go | 169 ++++++++++ .../statusreport/statusreport_controller.go | 105 +++++++ .../statusreport_controller_test.go | 160 ++++++++++ .../statusreport/statusreport_suite_test.go | 111 +++++++ docs/statusreport-controller.md | 63 ++++ git/github/github.go | 106 ++++++- git/github/github_test.go | 102 +++++- gitops/snapshot.go | 38 +++ gitops/snapshot_predicate.go | 20 ++ gitops/snapshot_predicate_test.go | 105 ++++++- status/reporters.go | 295 +++++++++++++++++- status/reporters_test.go | 178 ++++++++++- status/status.go | 9 +- status/status_test.go | 6 + 17 files changed, 1525 insertions(+), 24 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 create mode 100644 docs/statusreport-controller.md 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 0bf35cdcb..954a593e2 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, *helpers.IntegrationLogger, *applicationapiv1alpha1.Snapshot) 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..b8d346827 --- /dev/null +++ b/controllers/statusreport/statusreport_adapter.go @@ -0,0 +1,73 @@ +/* +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" + + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + "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 { + if err := reporter.ReportStatusForSnapshot(a.client, a.context, &a.logger, a.snapshot); 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() +} diff --git a/controllers/statusreport/statusreport_adapter_test.go b/controllers/statusreport/statusreport_adapter_test.go new file mode 100644 index 000000000..cedd95366 --- /dev/null +++ b/controllers/statusreport/statusreport_adapter_test.go @@ -0,0 +1,169 @@ +/* +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" + "fmt" + "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, *helpers.IntegrationLogger, *applicationapiv1alpha1.Snapshot) error { + r.Called = true + r.ReportStatusError = nil + 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: "pull_request", + "pac.test.appstudio.openshift.io/url-org": "testorg", + "pac.test.appstudio.openshift.io/url-repository": "testrepo", + "pac.test.appstudio.openshift.io/sha": "testsha", + 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: "[{\"scenario\":\"scenario-1\",\"status\":\"EnvironmentProvisionError\",\"startTime\":\"2023-07-26T16:57:49+02:00\",\"completionTime\":\"2023-07-26T17:57:49+02:00\",\"lastUpdateTime\":\"2023-08-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()) + }) + + 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) + statusReporter = &MockStatusReporter{} + statusAdapter = &MockStatusAdapter{Reporter: statusReporter} + adapter.status = statusAdapter + adapter.context = loader.GetMockedContext(ctx, []loader.MockData{ + { + ContextKey: loader.ApplicationContextKey, + Resource: hasApp, + }, + { + ContextKey: loader.SnapshotContextKey, + Resource: hasSnapshot, + }, + }) + result, err := adapter.EnsureSnapshotTestStatusReported() + fmt.Fprintf(GinkgoWriter, "-------err: %v\n", err) + fmt.Fprintf(GinkgoWriter, "-------result: %v\n", result) + 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..7f33f182f --- /dev/null +++ b/controllers/statusreport/statusreport_controller.go @@ -0,0 +1,105 @@ +/* +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/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=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() + + logger.Info("start to process snapshot test status since there is change in annotation test.appstudio.openshift.io/status", "snapshot", req.NamespacedName) + + 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())) +} + +// setupControllerWithManager sets up the controller with the Manager which monitors new Snapshots +func setupControllerWithManager(manager ctrl.Manager, controller *Reconciler) error { + 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..772c1ac2b --- /dev/null +++ b/controllers/statusreport/statusreport_controller_test.go @@ -0,0 +1,160 @@ +/* +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 ( + "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 + ) + + 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 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/docs/statusreport-controller.md b/docs/statusreport-controller.md new file mode 100644 index 000000000..bd195f725 --- /dev/null +++ b/docs/statusreport-controller.md @@ -0,0 +1,63 @@ +

StatusReport Controller

+ +```mermaid +%%{init: {'theme':'forest'}}%% +flowchart TD + %% Defining the styles + classDef Amber fill:#FFDEAD; + + predicate((PREDICATE:
Snapshot has annotation
test.appstudio.openshift.io/status
changed)) + + %%%%%%%%%%%%%%%%%%%%%%% Drawing EnsureSnapshotTestStatusReported() function + + %% Node definitions + ensure(Process further if: Snapshot has label
pac.test.appstudio.openshift.io/git-provider:github
defined) + get_annotation_value(Get integration test status from annotation
test.appstudio.openshift.io/status
from Snapshot) + collect_commit_info(Collect commit owner, repo and SHA from Snapshot) + + is_installation_defined{Is annotation
pac.test.appstudio.openshift.io/installation-id
defined?} + + create_appInstallation_token(Create github application installation token) + get_all_checkRuns_from_gh(Get all checkruns from github
according to
commit owner, repo and SHA) + create_checkRunAdapter(Create checkRun adapter according to
commit owner, repo, SHA
and integration test status) + does_checkRun_exist{Does checkRun exist
on github already?} + create_new_checkRun_on_gh(Create new checkrun on github) + is_checkRun_update_needed{Does existing checkRun
have different text?} + update_existing_checkRun_on_gh(Update existing checkRun on github) + + set_oAuth_token(Get token from Snapshot and set oAuth token) + get_all_commitStatuses_from_gh(Get all commitStatuses from github
according to commit owner, repo and SHA) + create_commitStatusAdapter(Create commitStatusAdapter according to
commit owner, repo, SHA
and integration test status) + does_commitStatus_exist{Does commitStatus exist
on github already?} + create_new_commitStatus_on_gh(Create new commitStatus on github) + + continue_processing(Controller continues processing) + + %% Node connections + predicate ----> |"EnsureSnapshotTestStatusReported()"|ensure + ensure --> get_annotation_value + get_annotation_value --> collect_commit_info + collect_commit_info --> is_installation_defined + is_installation_defined --Yes--> create_appInstallation_token + is_installation_defined --No--> set_oAuth_token + + create_appInstallation_token --> get_all_checkRuns_from_gh + get_all_checkRuns_from_gh --> create_checkRunAdapter + create_checkRunAdapter --> does_checkRun_exist + does_checkRun_exist --Yes--> is_checkRun_update_needed + does_checkRun_exist --No--> create_new_checkRun_on_gh + create_new_checkRun_on_gh --> continue_processing + is_checkRun_update_needed --Yes--> update_existing_checkRun_on_gh + is_checkRun_update_needed --No--> continue_processing + update_existing_checkRun_on_gh --> continue_processing + + set_oAuth_token --> get_all_commitStatuses_from_gh + get_all_commitStatuses_from_gh --> create_commitStatusAdapter + create_commitStatusAdapter --> does_commitStatus_exist + does_commitStatus_exist --Yes--> continue_processing + does_commitStatus_exist --No--> create_new_commitStatus_on_gh + create_new_commitStatus_on_gh --> continue_processing + + %% Assigning styles to nodes + class predicate Amber; +``` \ No newline at end of file diff --git a/git/github/github.go b/git/github/github.go index 407dc2c94..ff735a0c8 100644 --- a/git/github/github.go +++ b/git/github/github.go @@ -2,6 +2,7 @@ package github import ( "context" + "fmt" "net/http" "time" @@ -26,6 +27,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 +67,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 @@ -67,6 +79,11 @@ type ClientInterface interface { GetCheckRunID(ctx context.Context, owner string, repo string, SHA string, externalID string, appID int64) (*int64, 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) + IsUpdateNeeded(existingCheckRun *ghapi.CheckRun, newCheckRun *CheckRunAdapter) bool + GetExistingCheckRun(checkRuns []*ghapi.CheckRun, newCheckRun *CheckRunAdapter) *ghapi.CheckRun + GetAllCommitStatusesForRef(ctx context.Context, owner, repo, sha string) ([]*ghapi.RepoStatus, error) + CommitStatusExists(res []*ghapi.RepoStatus, commitStatus *CommitStatusAdapter) (bool, error) } // Client is an abstraction around the API client. @@ -217,7 +234,7 @@ func (c *Client) CreateCheckRun(ctx context.Context, cra *CheckRunAdapter) (*int cr, _, err := c.GetChecksService().CreateCheckRun(ctx, cra.Owner, cra.Repository, options) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create check run for for owner/repo/Ref %s/%s/%s: %w", cra.Owner, cra.Repository, cra.SHA, err) } c.logger.Info("Created CheckRun", @@ -284,7 +301,7 @@ func (c *Client) GetCheckRunID(ctx context.Context, owner string, repo string, S ) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to list all checks run for GitHub owner/repo/Ref %s/%s/%s: %w", owner, repo, SHA, err) } if *res.Total == 0 { @@ -302,6 +319,89 @@ 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, fmt.Errorf("failed to get all check runs for GitHub owner/repo/Ref %s/%s/%s: %w", owner, repo, SHA, err) + } + + if *res.Total == 0 { + c.logger.Info("Found no CheckRuns for the ref", "SHA", SHA) + return nil, nil + } + + return res.CheckRuns, nil +} + +// GetExistingCheckRun returns existing GitHub CheckRun for the ExternalID in checkRunAdapter. +func (c *Client) GetExistingCheckRun(checkRuns []*ghapi.CheckRun, newCheckRun *CheckRunAdapter) *ghapi.CheckRun { + for _, cr := range checkRuns { + if *cr.ExternalID == newCheckRun.ExternalID { + c.logger.Info("found CheckRun with a matching ExternalID", "ExternalID", newCheckRun.ExternalID) + return cr + } + } + c.logger.Info("found no CheckRuns with a matching ExternalID", "ExternalID", newCheckRun.ExternalID) + return nil +} + +// IsUpdateNeeded check if check run update is needed +// according to the text of existingCheckRun and newCheckRun since the details are different every update +func (c *Client) IsUpdateNeeded(existingCheckRun *ghapi.CheckRun, newCheckRun *CheckRunAdapter) bool { + if newCheckRun.Text != *existingCheckRun.Output.Text { + // We need to update the existing checkrun if their ExternalID and Text are different + c.logger.Info("found CheckRun with a matching ExternalID and status, so need to update", "ExternalID", newCheckRun.ExternalID) + return true + } else { + // We don't need to update the existing checkrun if their ExternalID and Text are the same + c.logger.Info("found CheckRun with a matching ExternalID and status, so no need to update", "ExternalID", newCheckRun.ExternalID) + return false + } +} + +// GetAllCommitStatusesForRef returns all existing GitHub CommitStatuses if a match for the Owner, Repo, and SHA. +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, fmt.Errorf("failed to get all commit statuses for GitHub owner/repo/Ref %s/%s/%s: %w", owner, repo, sha, err) + } + + if len(res) == 0 { + c.logger.Info("Found no commitStatus for the ref", "SHA", sha) + return nil, nil + } + + return res, nil +} + +// CommitStatusExists returns if a match is found for the SHA, state, context and decription. +func (c *Client) CommitStatusExists(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 false, 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 +430,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..7d3ea1fc5 100644 --- a/git/github/github_test.go +++ b/git/github/github_test.go @@ -55,11 +55,27 @@ func (MockChecksService) ListCheckRunsForRef( ) (*ghapi.ListCheckRunsResults, *ghapi.Response, error) { var id int64 = 20 var externalID string = "example-external-id" - checkRuns := []*ghapi.CheckRun{{ID: &id, ExternalID: &externalID}} + var text string = "example-text-update" + var checkRunOutput = ghapi.CheckRunOutput{Text: &text} + conclusion := "failure" + checkRuns := []*ghapi.CheckRun{{ID: &id, ExternalID: &externalID, Conclusion: &conclusion, Output: &checkRunOutput}} total := len(checkRuns) 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" + var text string = "example-text-update" + var checkRunOutput = ghapi.CheckRunOutput{Text: &text} + conclusion := "failure" + checkRuns := []*ghapi.CheckRun{{ID: &id, ExternalID: &externalID, Conclusion: &conclusion, Output: &checkRunOutput}} + return checkRuns, nil +} + // UpdateCheckRun implements github.ChecksService func (MockChecksService) UpdateCheckRun( ctx context.Context, owner string, repo string, checkRunID int64, opts ghapi.UpdateCheckRunOptions, @@ -89,6 +105,18 @@ 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" + var description = "example-description" + var context = "example-context" + repoStatus := &ghapi.RepoStatus{ID: &id, State: &state, Description: &description, Context: &context} + return []*ghapi.RepoStatus{repoStatus}, nil, nil +} + var _ = Describe("CheckRunAdapter", func() { It("can compute status", func() { adapter := &github.CheckRunAdapter{Conclusion: "success", StartTime: time.Time{}} @@ -118,7 +146,7 @@ var _ = Describe("Client", func() { Repository: "example-repo", SHA: "abcdef1", ExternalID: "example-external-id", - Conclusion: "success", + Conclusion: "Passed", Title: "example-title", Summary: "example-summary", Text: "example-text", @@ -126,6 +154,15 @@ var _ = Describe("Client", func() { CompletionTime: time.Now(), } + var commitStatusAdapter = &github.CommitStatusAdapter{ + Owner: "example-owner", + Repository: "example-repo", + SHA: "abcdef1", + State: "success", + Description: "example-description", + Context: "example-context", + } + BeforeEach(func() { mockAppsSvc = MockAppsService{} mockChecksSvc = MockChecksService{} @@ -195,4 +232,65 @@ var _ = Describe("Client", func() { Expect(err).To(BeNil()) Expect(checkRunID).To(BeNil()) }) + + It("can check if check run updated is needed", func() { + var checkRunAdapter = &github.CheckRunAdapter{ + Name: "example-name", + Owner: "example-owner", + Repository: "example-repo", + SHA: "abcdef1", + ExternalID: "example-external-id", + Conclusion: "success", + Title: "example-title", + Summary: "example-summary", + Text: "example-text", + StartTime: time.Now(), + CompletionTime: time.Now(), + } + + allCheckRuns, err := client.GetAllCheckRunsForRef(context.TODO(), "", "", "", 1) + Expect(err).To(BeNil()) + Expect(len(allCheckRuns) > 0).To(BeTrue()) + + existingCheckRun := client.GetExistingCheckRun(allCheckRuns, checkRunAdapter) + Expect(existingCheckRun).NotTo(BeNil()) + Expect(client.IsUpdateNeeded(existingCheckRun, checkRunAdapter)).To(BeTrue()) + + checkRunAdapter = &github.CheckRunAdapter{ + Name: "example-name", + Owner: "example-owner", + Repository: "example-repo", + SHA: "abcdef1", + ExternalID: "example-external-id", + Conclusion: "failure", + Title: "example-title", + Summary: "example-summary", + Text: "example-text-update", + StartTime: time.Now(), + CompletionTime: time.Now(), + } + Expect(client.IsUpdateNeeded(existingCheckRun, checkRunAdapter)).To(BeFalse()) + }) + + It("can check if creating a new commit status is needed", func() { + commitStatuses, err := client.GetAllCommitStatusesForRef(context.TODO(), "", "", "") + Expect(err).To(BeNil()) + Expect(len(commitStatuses) > 0).To(BeTrue()) + + commitStatusExist, err := client.CommitStatusExists(commitStatuses, commitStatusAdapter) + Expect(commitStatusExist).To(BeTrue()) + Expect(err).To(BeNil()) + + commitStatusAdapter = &github.CommitStatusAdapter{ + Owner: "example-owner", + Repository: "example-repo", + SHA: "abcdef1", + State: "failure", + Description: "example-description", + Context: "example-context", + } + commitStatusExist, err = client.CommitStatusExists(commitStatuses, commitStatusAdapter) + Expect(commitStatusExist).To(BeFalse()) + Expect(err).To(BeNil()) + }) }) diff --git a/gitops/snapshot.go b/gitops/snapshot.go index 043b01c66..eba63018b 100644 --- a/gitops/snapshot.go +++ b/gitops/snapshot.go @@ -118,6 +118,24 @@ const ( //AppStudioIntegrationStatusFinished is the reason that's set when the AppStudio tests finish. AppStudioIntegrationStatusFinished = "Finished" + + // the statuses needed to report to GiHub when creating check run or commit status, see doc + // https://docs.github.com/en/rest/guides/using-the-rest-api-to-interact-with-checks?apiVersion=2022-11-28 + // https://docs.github.com/en/free-pro-team@latest/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run + //IntegrationTestStatusPendingGithub is the status reported to github when integration test is in a queue + IntegrationTestStatusPendingGithub = "pending" + + //IntegrationTestStatusSuccessGithub is the status reported to github when integration test succeed + IntegrationTestStatusSuccessGithub = "success" + + //IntegrationTestStatusFailureGithub is the status reported to github when integration test fail + IntegrationTestStatusFailureGithub = "failure" + + //IntegrationTestStatusErrorGithub is the status reported to github when integration test experience error + IntegrationTestStatusErrorGithub = "error" + + //IntegrationTestStatusInProgressGithub is the status reported to github when integration test is in progress + IntegrationTestStatusInProgressGithub = "in_progress" ) // IntegrationTestScenario test runs status @@ -415,6 +433,26 @@ func HasSnapshotTestingChangedToFinished(objectOld, objectNew client.Object) boo return false } +// HasSnapshotTestAnnotationChanged 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) { diff --git a/gitops/snapshot_predicate.go b/gitops/snapshot_predicate.go index f0c67ef13..e8244f6a1 100644 --- a/gitops/snapshot_predicate.go +++ b/gitops/snapshot_predicate.go @@ -1,6 +1,7 @@ package gitops import ( + "github.com/redhat-appstudio/operator-toolkit/metadata" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/predicate" ) @@ -23,3 +24,22 @@ func IntegrationSnapshotChangePredicate() predicate.Predicate { }, } } + +// ComponentSnapshotTestAnnotationChangePredicate returns a predicate which filters out all objects except +// pull_request snapshot annotation "test.appstudio.openshift.io/status" is changed for update events. +func ComponentSnapshotTestAnnotationChangePredicate() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(createEvent event.CreateEvent) bool { + return false + }, + DeleteFunc: func(deleteEvent event.DeleteEvent) bool { + return false + }, + GenericFunc: func(genericEvent event.GenericEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return HasSnapshotTestAnnotationChanged(e.ObjectOld, e.ObjectNew) && metadata.HasLabelWithValue(e.ObjectNew, PipelineAsCodeEventTypeLabel, PipelineAsCodePullRequestType) + }, + } +} diff --git a/gitops/snapshot_predicate_test.go b/gitops/snapshot_predicate_test.go index 473147cab..82f7e22df 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,58 @@ 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: "[{\"scenario\":\"scenario-1\",\"status\":\"EnvironmentProvisionError\",\"startTime\":\"2023-07-26T16:57:49+02:00\",\"sompletionTime\":\"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, + "pac.test.appstudio.openshift.io/event-type": "pull_request", + }, + Annotations: map[string]string{ + gitops.SnapshotTestsStatusAnnotation: "[{\"scenario\":\"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 +189,47 @@ var _ = Describe("Predicates", Ordered, func() { Expect(instance.Delete(contextEvent)).To(BeFalse()) }) }) + + Context("when testing IntegrationSnapshotChangePredicate predicate", func() { + instance := gitops.ComponentSnapshotTestAnnotationChangePredicate() + + It("returns true when the test status annotation of Snapshot changed ", func() { + contextEvent := event.UpdateEvent{ + ObjectOld: hasSnapshotAnnotationOld, + ObjectNew: hasSnapshotAnnotationNew, + } + Expect(instance.Update(contextEvent)).To(BeTrue()) + }) + + It("returns false when the test status annotation of Snapshot is not changed ", func() { + contextEvent := event.UpdateEvent{ + ObjectOld: hasSnapshotAnnotationOld, + ObjectNew: hasSnapshotAnnotationOld, + } + Expect(instance.Update(contextEvent)).To(BeFalse()) + }) + + It("returns true when the test status annotation of old Snapshot doesn't exist but exists in new snapshot ", func() { + contextEvent := event.UpdateEvent{ + ObjectOld: hasSnapshotTrueStatus, + ObjectNew: hasSnapshotAnnotationNew, + } + Expect(instance.Update(contextEvent)).To(BeTrue()) + }) + + It("returns false when the test status annotation doesn't exist in old and new Snapshot", func() { + contextEvent := event.UpdateEvent{ + ObjectOld: hasSnapshotTrueStatus, + ObjectNew: hasSnapshotTrueStatus, + } + Expect(instance.Update(contextEvent)).To(BeFalse()) + }) + + It("returns false when the Snapshot is deleted", func() { + contextEvent := event.DeleteEvent{ + Object: hasSnapshotUnknownStatus, + } + Expect(instance.Delete(contextEvent)).To(BeFalse()) + }) + }) }) diff --git a/status/reporters.go b/status/reporters.go index 9667cbd08..94eb8c40b 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,116 @@ func (r *GitHubReporter) createCheckRunAdapter(k8sClient client.Client, ctx cont }, nil } +// generateSummary generate a string for the given state, snapshotName and scenarioName +func generateSummary(state gitops.IntegrationTestStatus, snapshotName, scenarioName string) (string, error) { + var title string + + var statusDesc string = "is unknown" + + switch state { + case gitops.IntegrationTestStatusPending: + statusDesc = "is pending" + case gitops.IntegrationTestStatusInProgress: + statusDesc = "is in progress" + case gitops.IntegrationTestStatusEnvironmentProvisionError: + statusDesc = "experienced an error when provisioning environment" + case gitops.IntegrationTestStatusDeploymentError: + statusDesc = "experienced an error when deploying snapshotEnvironmentBinding" + case gitops.IntegrationTestStatusTestPassed: + statusDesc = "has passed" + case gitops.IntegrationTestStatusTestFail: + statusDesc = "has failed" + default: + return title, fmt.Errorf("unknown status") + } + + title = fmt.Sprintf("Integration test for snapshot %s and scenario %s %s", snapshotName, scenarioName, statusDesc) + + return title, nil +} + +// generateCheckRunConclusion generate a conclusion as the conclusion of CheckRun +// can be Can be one of: action_required, cancelled, failure, neutral, success, skipped, stale, timed_out +// https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run +func generateCheckRunConclusion(state gitops.IntegrationTestStatus) (string, error) { + var conclusion string + + switch state { + case gitops.IntegrationTestStatusTestFail, gitops.IntegrationTestStatusEnvironmentProvisionError, gitops.IntegrationTestStatusDeploymentError: + conclusion = gitops.IntegrationTestStatusFailureGithub + case gitops.IntegrationTestStatusTestPassed: + conclusion = gitops.IntegrationTestStatusSuccessGithub + case gitops.IntegrationTestStatusPending, gitops.IntegrationTestStatusInProgress: + conclusion = "" + default: + return conclusion, fmt.Errorf("unknown status") + } + + return conclusion, nil +} + +// generateCommitState generate state of CommitStatus +// Can be one of: error, failure, pending, success +// https://docs.github.com/en/rest/commits/statuses?apiVersion=2022-11-28#create-a-commit-status +func generateCommitState(state gitops.IntegrationTestStatus) (string, error) { + var commitState string + + switch state { + case gitops.IntegrationTestStatusTestFail: + commitState = gitops.IntegrationTestStatusFailureGithub + case gitops.IntegrationTestStatusEnvironmentProvisionError, gitops.IntegrationTestStatusDeploymentError: + commitState = gitops.IntegrationTestStatusErrorGithub + case gitops.IntegrationTestStatusTestPassed: + commitState = gitops.IntegrationTestStatusSuccessGithub + case gitops.IntegrationTestStatusPending, gitops.IntegrationTestStatusInProgress: + commitState = gitops.IntegrationTestStatusPendingGithub + default: + return commitState, fmt.Errorf("unknown status") + } + + return commitState, nil +} + +// createCheckRunAdapterForSnapshot create a CheckRunAdapter for given snapshot, integrationTestStatusDetail, owner, repo and sha to create a checkRun +// https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run +func (r *GitHubReporter) createCheckRunAdapterForSnapshot(snapshot *applicationapiv1alpha1.Snapshot, integrationTestStatusDetail gitops.IntegrationTestStatusDetail, owner, repo, sha string) (*github.CheckRunAdapter, error) { + snapshotName := snapshot.Name + scenarioName := integrationTestStatusDetail.ScenarioName + + conclusion, err := generateCheckRunConclusion(integrationTestStatusDetail.Status) + if err != nil { + return nil, fmt.Errorf("unknown status %s for integrationTestScenario %s and snapshot %s/%s", integrationTestStatusDetail.Status, scenarioName, snapshot.Namespace, snapshot.Name) + } + + summary, err := generateSummary(integrationTestStatusDetail.Status, snapshotName, scenarioName) + if err != nil { + return nil, fmt.Errorf("unknown status %s for integrationTestScenario %s and snapshot %s/%s", integrationTestStatusDetail.Status, scenarioName, snapshot.Namespace, snapshot.Name) + } + + cra := &github.CheckRunAdapter{ + Owner: owner, + Repository: repo, + Name: NamePrefix + " / " + snapshotName + " / " + scenarioName, + SHA: sha, + ExternalID: scenarioName, + Conclusion: conclusion, + Title: conclusion, + // This summary will be reworked once PLNSRVCE-1295 is implemented in the future + Summary: summary, + Text: integrationTestStatusDetail.Details, + } + + if start := integrationTestStatusDetail.StartTime; start != nil { + cra.StartTime = *start + } + + if complete := integrationTestStatusDetail.CompletionTime; complete != nil { + cra.CompletionTime = *complete + } + + return cra, nil +} + func (r *GitHubReporter) createCommitStatus(k8sClient client.Client, ctx context.Context, pipelineRun *tektonv1beta1.PipelineRun) error { var ( state string @@ -289,6 +400,33 @@ func (r *GitHubReporter) createCommitStatus(k8sClient client.Client, ctx context return nil } +// createCommitStatusAdapterForSnapshot create a commitStatusAdapter used to create commitStatus on GitHub +// https://docs.github.com/en/rest/commits/statuses?apiVersion=2022-11-28#create-a-commit-status +func (r *GitHubReporter) createCommitStatusAdapterForSnapshot(snapshot *applicationapiv1alpha1.Snapshot, integrationTestStatusDetail gitops.IntegrationTestStatusDetail, owner, repo, sha string) (*github.CommitStatusAdapter, error) { + snapshotName := snapshot.Name + scenarioName := integrationTestStatusDetail.ScenarioName + statusContext := NamePrefix + " / " + snapshot.Name + " / " + scenarioName + + state, err := generateCommitState(integrationTestStatusDetail.Status) + if err != nil { + return nil, fmt.Errorf("unknown status %s for integrationTestScenario %s and snapshot %s/%s", integrationTestStatusDetail.Status, scenarioName, snapshot.Namespace, snapshot.Name) + } + + description, err := generateSummary(integrationTestStatusDetail.Status, snapshotName, scenarioName) + if err != nil { + return nil, fmt.Errorf("unknown status %s for integrationTestScenario %s and snapshot %s/%s", integrationTestStatusDetail.Status, scenarioName, snapshot.Namespace, snapshot.Name) + } + + 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 +531,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 +551,142 @@ func (r *GitHubReporter) ReportStatus(k8sClient client.Client, ctx context.Conte return nil } + +// ReportStatusForSnapshot creates CheckRun when using GitHub App integration, +// creates a a commit status when using GitHub webhook integration +func (r *GitHubReporter) ReportStatusForSnapshot(k8sClient client.Client, ctx context.Context, logger *helpers.IntegrationLogger, snapshot *applicationapiv1alpha1.Snapshot) error { + statuses, err := gitops.NewSnapshotIntegrationTestStatusesFromSnapshot(snapshot) + if err != nil { + logger.Error(err, "failed to get test status annotations from snapshot", + "snapshot.Namespace", snapshot.Namespace, "snapshot.Name", snapshot.Name) + return err + } + + labels := snapshot.GetLabels() + + owner, found := labels[gitops.PipelineAsCodeURLOrgLabel] + if !found { + return fmt.Errorf("org label not found %q", gitops.PipelineAsCodeURLOrgLabel) + } + + repo, found := labels[gitops.PipelineAsCodeURLRepositoryLabel] + if !found { + return fmt.Errorf("repository label not found %q", gitops.PipelineAsCodeURLRepositoryLabel) + } + + sha, found := labels[gitops.PipelineAsCodeSHALabel] + if !found { + return fmt.Errorf("sha label not found %q", gitops.PipelineAsCodeSHALabel) + } + integrationTestStatusDetails := statuses.GetStatuses() + // 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 { + logger.Error(err, "failed to get app credentials from Snapshot", + "snapshot.NameSpace", snapshot.Namespace, "snapshot.Name", snapshot.Name) + return err + } + + token, err := r.client.CreateAppInstallationToken(ctx, creds.AppID, creds.InstallationID, creds.PrivateKey) + if err != nil { + logger.Error(err, "failed to create app installation token", + "creds.AppID", creds.AppID, "creds.InstallationID", creds.InstallationID) + return err + } + + r.client.SetOAuthToken(ctx, token) + + allCheckRuns, err := r.client.GetAllCheckRunsForRef(ctx, owner, repo, sha, creds.AppID) + if err != nil { + logger.Error(err, "failed to get all checkruns for ref", + "owner", owner, "repo", repo, "creds.AppID", creds.AppID) + return err + } + + for _, integrationTestStatusDetail := range integrationTestStatusDetails { + integrationTestStatusDetail := *integrationTestStatusDetail // G601 + checkRun, err := r.createCheckRunAdapterForSnapshot(snapshot, integrationTestStatusDetail, owner, repo, sha) + if err != nil { + logger.Error(err, "failed to create checkRunAdapter for snapshot", + "snapshot.NameSpace", snapshot.Namespace, "snapshot.Name", snapshot.Name) + return err + } + + existingCheckrun := r.client.GetExistingCheckRun(allCheckRuns, checkRun) + + if existingCheckrun == nil { + logger.Info("creating checkrun for scenario test status of snapshot", + "snapshot.NameSpace", snapshot.Namespace, "snapshot.Name", snapshot.Name, "scenarioName", integrationTestStatusDetail.ScenarioName) + _, err = r.client.CreateCheckRun(ctx, checkRun) + if err != nil { + logger.Error(err, "failed to create checkrun", + "checkRun", checkRun) + return err + } + } else { + logger.Info("found existing checkrun", "existingCheckRun", existingCheckrun) + if r.client.IsUpdateNeeded(existingCheckrun, checkRun) { + logger.Info("found existing check run with the same ExternalID but different conclusion/status, updating checkrun for scenario test status of snapshot", + "snapshot.NameSpace", snapshot.Namespace, "snapshot.Name", snapshot.Name, "scenarioName", integrationTestStatusDetail.ScenarioName, "checkrun.ExternalID", checkRun.ExternalID) + err = r.client.UpdateCheckRun(ctx, *existingCheckrun.ID, checkRun) + if err != nil { + logger.Error(err, "failed to update checkrun", + "checkRun", checkRun) + return err + } + } else { + logger.Info("found existing check run with the same ExternalID and conclusion/status, no need to update checkrun for scenario test status of snapshot", + "snapshot.NameSpace", snapshot.Namespace, "snapshot.Name", snapshot.Name, "scenarioName", integrationTestStatusDetail.ScenarioName, "checkrun.ExternalID", checkRun.ExternalID) + } + } + } + } else { + token, err := r.getToken(ctx, snapshot, snapshot.Namespace) + if err != nil { + logger.Error(err, "failed to get token from snapshot", + "snapshot.NameSpace", snapshot.Namespace, "snapshot.Name", snapshot.Name) + return err + } + + r.client.SetOAuthToken(ctx, token) + + allCommitStatuses, err := r.client.GetAllCommitStatusesForRef(ctx, owner, repo, sha) + if err != nil { + logger.Error(err, "failed to get all commitStatuses for snapshot", + "snapshot.NameSpace", snapshot.Namespace, "snapshot.Name", snapshot.Name) + return err + } + + for _, integrationTestStatusDetail := range integrationTestStatusDetails { + integrationTestStatusDetail := *integrationTestStatusDetail //G601 + commitStatus, err := r.createCommitStatusAdapterForSnapshot(snapshot, integrationTestStatusDetail, owner, repo, sha) + if err != nil { + logger.Error(err, "failed to create CommitStatusAdapter for snapshot", + "snapshot.NameSpace", snapshot.Namespace, "snapshot.Name", snapshot.Name) + return err + } + + commitStatusExist, err := r.client.CommitStatusExists(allCommitStatuses, commitStatus) + if err != nil { + return err + } + + if !commitStatusExist { + logger.Info("creating commit status for scenario test status of snapshot", + "snapshot.NameSpace", snapshot.Namespace, "snapshot.Name", snapshot.Name, "scenarioName", integrationTestStatusDetail.ScenarioName) + _, err = r.client.CreateCommitStatus(ctx, commitStatus.Owner, commitStatus.Repository, commitStatus.SHA, commitStatus.State, commitStatus.Description, commitStatus.Context) + if err != nil { + return err + } + } else { + logger.Info("found existing commitStatus for scenario test status of snapshot, no need to create new commit status", + "snapshot.NameSpace", snapshot.Namespace, "snapshot.Name", snapshot.Name, "scenarioName", integrationTestStatusDetail.ScenarioName) + } + + } + } + + return nil +} diff --git a/status/reporters_test.go b/status/reporters_test.go index e2685d2a8..871af24fa 100644 --- a/status/reporters_test.go +++ b/status/reporters_test.go @@ -2,6 +2,7 @@ package status_test import ( "context" + "fmt" "strings" "time" @@ -9,10 +10,14 @@ 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/helpers" "github.com/redhat-appstudio/integration-service/status" tektonv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" v1 "k8s.io/api/core/v1" @@ -43,6 +48,10 @@ type GetCheckRunIDResult struct { Error error } +type GetCheckRunResult struct { + cr *ghapi.CheckRun +} + type CreateCommentResult struct { ID int64 Error error @@ -63,6 +72,7 @@ type MockGitHubClient struct { CreateCheckRunResult UpdateCheckRunResult GetCheckRunIDResult + GetCheckRunResult CreateCommentResult CreateCommitStatusResult } @@ -87,6 +97,18 @@ func (c *MockGitHubClient) GetCheckRunID(context.Context, string, string, string return c.GetCheckRunIDResult.ID, c.GetCheckRunIDResult.Error } +func (c *MockGitHubClient) IsUpdateNeeded(existingCheckRun *ghapi.CheckRun, cra *github.CheckRunAdapter) bool { + return true +} + +func (c *MockGitHubClient) GetExistingCheckRun(checkRuns []*ghapi.CheckRun, cra *github.CheckRunAdapter) *ghapi.CheckRun { + return c.GetCheckRunResult.cr +} + +func (c *MockGitHubClient) CommitStatusExists(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 +122,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 +239,8 @@ var _ = Describe("GitHubReporter", func() { var successfulTaskRun *tektonv1beta1.TaskRun var failedTaskRun *tektonv1beta1.TaskRun var skippedTaskRun *tektonv1beta1.TaskRun + var hasSnapshot *applicationapiv1alpha1.Snapshot + var logger helpers.IntegrationLogger BeforeEach(func() { now := time.Now() @@ -330,6 +371,44 @@ 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", + }, + }, + }, + }, + }, + }, + } }) Context("when provided GitHub app credentials", func() { @@ -338,6 +417,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 +449,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(), &logger, hasSnapshot)).To(BeNil()) }) It("doesn't report status when the credentials are invalid/missing", func() { @@ -378,22 +460,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(), &logger, hasSnapshot) + 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(), &logger, hasSnapshot) + 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(), &logger, hasSnapshot) + 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(), &logger, hasSnapshot) + Expect(err).ToNot(BeNil()) }) It("reports status via CheckRuns", func() { @@ -426,6 +519,59 @@ 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 pending CheckRun + hasSnapshot.Annotations["test.appstudio.openshift.io/status"] = "[{\"scenario\":\"scenario1\",\"status\":\"Pending\",\"lastUpdateTime\":\"2023-08-26T17:57:49+02:00\",\"details\":\"pending\"}]" + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), &logger, hasSnapshot)).To(BeNil()) + Expect(mockGitHubClient.CreateCheckRunResult.cra.Summary).To(Equal("Integration test for snapshot snapshot-sample and scenario scenario1 is pending")) + Expect(mockGitHubClient.CreateCheckRunResult.cra.Conclusion).To(Equal("")) + fmt.Fprintf(GinkgoWriter, "-------Time: %v\n", mockGitHubClient.CreateCheckRunResult.cra.StartTime) + + // Update existing CheckRun w/inprogress + hasSnapshot.Annotations["test.appstudio.openshift.io/status"] = "[{\"scenario\":\"scenario1\",\"status\":\"InProgress\",\"startTime\":\"2023-07-26T16:57:49+02:00\",\"lastUpdateTime\":\"2023-08-26T17:57:49+02:00\",\"details\":\"Failed to find deploymentTargetClass with right provisioner for copy of existingEnvironment\"}]" + var id int64 = 1 + var externalID string = "example-external-id" + conclusion := "" + mockGitHubClient.GetCheckRunResult.cr = &ghapi.CheckRun{ID: &id, ExternalID: &externalID, Conclusion: &conclusion} + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), &logger, hasSnapshot)).To(BeNil()) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Summary).To(Equal("Integration test for snapshot snapshot-sample and scenario scenario1 is in progress")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Conclusion).To(Equal("")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.StartTime.IsZero()).To(BeFalse()) + + // Update existing CheckRun w/failure + hasSnapshot.Annotations["test.appstudio.openshift.io/status"] = "[{\"scenario\":\"scenario1\",\"status\":\"EnvironmentProvisionError\",\"startTime\":\"2023-07-26T16:57:49+02:00\",\"completionTime\":\"2023-07-26T17:57:49+02:00\",\"lastUpdateTime\":\"2023-08-26T17:57:49+02:00\",\"details\":\"Failed to find deploymentTargetClass with right provisioner for copy of existingEnvironment\"}]" + mockGitHubClient.GetCheckRunResult.cr = &ghapi.CheckRun{ID: &id, ExternalID: &externalID, Conclusion: &conclusion} + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), &logger, hasSnapshot)).To(BeNil()) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Summary).To(Equal("Integration test for snapshot snapshot-sample and scenario scenario1 experienced an error when provisioning environment")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Conclusion).To(Equal(gitops.IntegrationTestStatusFailureGithub)) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.ExternalID).To(Equal("scenario1")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Owner).To(Equal("devfile-sample")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Repository).To(Equal("devfile-sample-go-basic")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.SHA).To(Equal("12a4a35ccd08194595179815e4646c3a6c08bb77")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Name).To(Equal("Red Hat Trusted App Test / snapshot-sample / scenario1")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.StartTime.IsZero()).To(BeFalse()) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.CompletionTime.IsZero()).To(BeFalse()) + + // Update existing CheckRun w/failure + hasSnapshot.Annotations["test.appstudio.openshift.io/status"] = "[{\"scenario\":\"scenario1\",\"status\":\"DeploymentError\",\"startTime\":\"2023-07-26T16:57:49+02:00\",\"completionTime\":\"2023-07-26T17:57:49+02:00\",\"lastUpdateTime\":\"2023-08-26T17:57:49+02:00\",\"details\":\"error\"}]" + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), &logger, hasSnapshot)).To(BeNil()) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Summary).To(Equal("Integration test for snapshot snapshot-sample and scenario scenario1 experienced an error when deploying snapshotEnvironmentBinding")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Conclusion).To(Equal(gitops.IntegrationTestStatusFailureGithub)) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.CompletionTime.IsZero()).To(BeFalse()) + + hasSnapshot.Annotations["test.appstudio.openshift.io/status"] = "[{\"scenario\":\"scenario1\",\"status\":\"TestFail\",\"startTime\":\"2023-07-26T16:57:49+02:00\",\"completionTime\":\"2023-07-26T17:57:49+02:00\",\"lastUpdateTime\":\"2023-08-26T17:57:49+02:00\",\"details\":\"failed\"}]" + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), &logger, hasSnapshot)).To(BeNil()) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Summary).To(Equal("Integration test for snapshot snapshot-sample and scenario scenario1 has failed")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Conclusion).To(Equal(gitops.IntegrationTestStatusFailureGithub)) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.CompletionTime.IsZero()).To(BeFalse()) + + // Update existing CheckRun w/success + hasSnapshot.Annotations["test.appstudio.openshift.io/status"] = "[{\"scenario\":\"scenario1\",\"status\":\"TestPassed\",\"startTime\":\"2023-07-26T16:57:49+02:00\",\"completionTime\":\"2023-07-26T17:57:49+02:00\",\"lastUpdateTime\":\"2023-08-26T17:57:49+02:00\",\"details\":\"failed\"}]" + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), &logger, hasSnapshot)).To(BeNil()) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Summary).To(Equal("Integration test for snapshot snapshot-sample and scenario scenario1 has passed")) + Expect(mockGitHubClient.UpdateCheckRunResult.cra.Conclusion).To(Equal(gitops.IntegrationTestStatusSuccessGithub)) + }) }) Context("when provided GitHub webhook integration credentials", func() { @@ -435,6 +581,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 +678,35 @@ 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 + hasSnapshot.Annotations["test.appstudio.openshift.io/status"] = "[{\"scenario\":\"scenario1\",\"status\":\"EnvironmentProvisionError\",\"startTime\":\"2023-07-26T16:57:49+02:00\",\"completionTime\":\"2023-07-26T17:57:49+02:00\",\"lastUpdateTime\":\"2023-08-26T17:57:49+02:00\",\"details\":\"failed\"}]" + + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), &logger, hasSnapshot)).To(BeNil()) + Expect(mockGitHubClient.CreateCommitStatusResult.state).To(Equal(gitops.IntegrationTestStatusErrorGithub)) + Expect(mockGitHubClient.CreateCommitStatusResult.description).To(Equal("Integration test for snapshot snapshot-sample and scenario scenario1 experienced an error when provisioning environment")) + Expect(mockGitHubClient.CreateCommitStatusResult.statusContext).To(Equal("Red Hat Trusted App Test / snapshot-sample / scenario1")) + + hasSnapshot.Annotations["test.appstudio.openshift.io/status"] = "[{\"scenario\":\"scenario1\",\"status\":\"DeploymentError\",\"startTime\":\"2023-07-26T16:57:49+02:00\",\"completionTime\":\"2023-07-26T17:57:49+02:00\",\"lastUpdateTime\":\"2023-08-26T17:57:49+02:00\",\"details\":\"failed\"}]" + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), &logger, hasSnapshot)).To(BeNil()) + Expect(mockGitHubClient.CreateCommitStatusResult.state).To(Equal(gitops.IntegrationTestStatusErrorGithub)) + Expect(mockGitHubClient.CreateCommitStatusResult.description).To(Equal("Integration test for snapshot snapshot-sample and scenario scenario1 experienced an error when deploying snapshotEnvironmentBinding")) + Expect(mockGitHubClient.CreateCommitStatusResult.statusContext).To(Equal("Red Hat Trusted App Test / snapshot-sample / scenario1")) + + // Success + hasSnapshot.Annotations["test.appstudio.openshift.io/status"] = "[{\"scenario\":\"scenario1\",\"status\":\"TestPassed\",\"startTime\":\"2023-07-26T16:57:49+02:00\",\"completionTime\":\"2023-07-26T17:57:49+02:00\",\"lastUpdateTime\":\"2023-08-26T17:57:49+02:00\",\"details\":\"passed\"}]" + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), &logger, hasSnapshot)).To(BeNil()) + Expect(mockGitHubClient.CreateCommitStatusResult.state).To(Equal(gitops.IntegrationTestStatusSuccessGithub)) + Expect(mockGitHubClient.CreateCommitStatusResult.description).To(Equal("Integration test for snapshot snapshot-sample and scenario scenario1 has passed")) + Expect(mockGitHubClient.CreateCommitStatusResult.statusContext).To(Equal("Red Hat Trusted App Test / snapshot-sample / scenario1")) + + // Failure + hasSnapshot.Annotations["test.appstudio.openshift.io/status"] = "[{\"scenario\":\"scenario1\",\"status\":\"TestFail\",\"startTime\":\"2023-07-26T16:57:49+02:00\",\"completionTime\":\"2023-07-26T17:57:49+02:00\",\"lastUpdateTime\":\"2023-08-26T17:57:49+02:00\",\"details\":\"passed\"}]" + Expect(reporter.ReportStatusForSnapshot(mockK8sClient, context.TODO(), &logger, hasSnapshot)).To(BeNil()) + Expect(mockGitHubClient.CreateCommitStatusResult.state).To(Equal(gitops.IntegrationTestStatusFailureGithub)) + Expect(mockGitHubClient.CreateCommitStatusResult.description).To(Equal("Integration test for snapshot snapshot-sample and scenario scenario1 has failed")) + 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..c88791358 100644 --- a/status/status.go +++ b/status/status.go @@ -4,7 +4,9 @@ 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/integration-service/helpers" "github.com/redhat-appstudio/operator-toolkit/metadata" tektonv1beta1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -16,11 +18,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, *helpers.IntegrationLogger, *applicationapiv1alpha1.Snapshot) 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 +60,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..e0df8767b 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/helpers" "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, *helpers.IntegrationLogger, *applicationapiv1alpha1.Snapshot) error { + return nil +} + var _ = Describe("Status Adapter", func() { var pipelineRun *tektonv1beta1.PipelineRun