diff --git a/cmd/openshift/operator/kodata/static/tekton-results/00-postreconcile/pipeline_console_plugin.yaml b/cmd/openshift/operator/kodata/static/tekton-results/00-postreconcile/pipeline_console_plugin.yaml new file mode 100644 index 0000000000..2768c4fa32 --- /dev/null +++ b/cmd/openshift/operator/kodata/static/tekton-results/00-postreconcile/pipeline_console_plugin.yaml @@ -0,0 +1,139 @@ +# Copyright 2023 The Tekton Authors +# +# 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. + +# to know about dynamic plugin visit, +# https://github.com/openshift/enhancements/blob/master/enhancements/console/dynamic-plugins.md + +# service to access static contents: js, CSS, HTML. etc., +# this service creates and manages secret called "pipeline-console-plugin-cert" +# generated secret will be used in the console plugin container (nginx + static content) +--- +apiVersion: v1 +kind: Service +metadata: + name: pipeline-console-plugin + namespace: openshift-pipelines + annotations: + # https://docs.openshift.com/container-platform/4.13/security/certificates/service-serving-certificate.html + service.beta.openshift.io/serving-cert-secret-name: pipeline-console-plugin-cert + labels: + app.kubernetes.io/part-of: tektoncd-results +spec: + ports: + - name: 8443-tcp + protocol: TCP + port: 8443 + targetPort: 8443 + selector: + name: pipeline-console-plugin + app: pipeline-console-plugin + +# nginx configuration +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: pipeline-console-plugin + namespace: openshift-pipelines + labels: + app.kubernetes.io/part-of: tektoncd-results +data: + nginx.conf: | + error_log /dev/stdout warn; + events {} + http { + access_log /dev/stdout; + include /etc/nginx/mime.types; + default_type application/octet-stream; + keepalive_timeout 65; + server { + listen 8443 ssl; + listen [::]:8443 ssl; + ssl_certificate /var/cert/tls.crt; + ssl_certificate_key /var/cert/tls.key; + root /usr/share/nginx/html; + } + } + +# nginx + pipeline dynamic console custom static contents +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pipeline-console-plugin + namespace: openshift-pipelines + labels: + app.kubernetes.io/part-of: tektoncd-results +spec: + replicas: 1 + selector: + matchLabels: + name: pipeline-console-plugin + app: pipeline-console-plugin + template: + metadata: + labels: + name: pipeline-console-plugin + app: pipeline-console-plugin + app.kubernetes.io/part-of: tektoncd-results + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + volumes: + - name: pipeline-console-plugin-cert + secret: + secretName: pipeline-console-plugin-cert + defaultMode: 420 + - name: nginx-conf + configMap: + name: pipeline-console-plugin + defaultMode: 420 + containers: + - name: pipeline-console-plugin + image: ghcr.io/openshift-pipelines/console-plugin:main + imagePullPolicy: Always + ports: + - protocol: TCP + containerPort: 8443 + volumeMounts: + - name: pipeline-console-plugin-cert + readOnly: true + mountPath: /var/cert + - name: nginx-conf + readOnly: true + mountPath: /etc/nginx/nginx.conf + subPath: nginx.conf + +# Console plugin is a cluster wide resource +# updates pipeline dynamic content provider service details +--- +apiVersion: console.openshift.io/v1 +kind: ConsolePlugin +metadata: + name: pipeline-console-plugin + labels: + app.kubernetes.io/part-of: tektoncd-results +spec: + displayName: Pipeline Console Plugin + backend: + type: Service + service: + name: pipeline-console-plugin + namespace: openshift-pipelines + port: 8443 + basePath: "/" + i18n: + loadType: Preload # options: Preload, Lazy diff --git a/config/openshift/base/operator.yaml b/config/openshift/base/operator.yaml index 30a260bf97..b15b3c0812 100644 --- a/config/openshift/base/operator.yaml +++ b/config/openshift/base/operator.yaml @@ -96,6 +96,8 @@ spec: value: registry.redhat.io/source-to-image/source-to-image-rhel8@sha256:6a6025914296a62fdf2092c3a40011bd9b966a6806b094d51eec5e1bd5026ef4 - name: IMAGE_ADDONS_PARAM_MAVEN_IMAGE value: registry.redhat.io/ubi8/openjdk-17@sha256:0d12c4097e098b62f78a7a31c0d711d78e1e5a53f4c007b9a5fc6cc6ab4dc018 + - name: IMAGE_PIPELINE_CONSOLE_PLUGIN + value: ghcr.io/openshift-pipelines/console-plugin:main - name: openshift-pipelines-operator-cluster-operations # tektoninstallerset reconciler image: ko://github.com/tektoncd/operator/cmd/openshift/operator args: diff --git a/config/openshift/base/role.yaml b/config/openshift/base/role.yaml index d23fe7a5e9..27d291d969 100644 --- a/config/openshift/base/role.yaml +++ b/config/openshift/base/role.yaml @@ -376,3 +376,16 @@ rules: - delete - update - patch +# to manage ConsolePlugin custom resource +- apiGroups: + - console.openshift.io + resources: + - consoleplugins + verbs: + - get + - list + - watch + - create + - delete + - update + - patch diff --git a/operatorhub/openshift/manifests/bases/openshift-pipelines-operator-rh.clusterserviceversion.template.yaml b/operatorhub/openshift/manifests/bases/openshift-pipelines-operator-rh.clusterserviceversion.template.yaml index b2e4932226..7cf1bf9069 100644 --- a/operatorhub/openshift/manifests/bases/openshift-pipelines-operator-rh.clusterserviceversion.template.yaml +++ b/operatorhub/openshift/manifests/bases/openshift-pipelines-operator-rh.clusterserviceversion.template.yaml @@ -6,6 +6,7 @@ metadata: capabilities: Full Lifecycle categories: Developer Tools, Integration & Delivery certified: "false" + console.openshift.io/plugins: '["pipeline-console-plugin"]' description: Red Hat OpenShift Pipelines is a cloud-native CI/CD solution for building pipelines using Tekton concepts which run natively on OpenShift and Kubernetes. operators.operatorframework.io/builder: operator-sdk-v1.7.2 operators.openshift.io/infrastructure-features: '["disconnected","proxy-aware"]' diff --git a/pkg/apis/operator/v1alpha1/tektonresult_lifecycle.go b/pkg/apis/operator/v1alpha1/tektonresult_lifecycle.go index d07d59e390..d8129fc6f8 100644 --- a/pkg/apis/operator/v1alpha1/tektonresult_lifecycle.go +++ b/pkg/apis/operator/v1alpha1/tektonresult_lifecycle.go @@ -27,6 +27,8 @@ var ( DependenciesInstalled, InstallerSetAvailable, InstallerSetReady, + PreReconciler, + PostReconciler, ) ) @@ -125,3 +127,28 @@ func (trs *TektonResultStatus) GetVersion() string { func (trs *TektonResultStatus) SetVersion(version string) { trs.Version = version } + +func (trs *TektonResultStatus) MarkPreReconcilerComplete() { + resultsCondSet.Manage(trs).MarkTrue(PreReconciler) +} + +func (trs *TektonResultStatus) MarkPostReconcilerComplete() { + resultsCondSet.Manage(trs).MarkTrue(PostReconciler) +} + +func (trs *TektonResultStatus) MarkPreReconcilerFailed(msg string) { + trs.MarkNotReady("PreReconciliation failed") + resultsCondSet.Manage(trs).MarkFalse( + PreReconciler, + "Error", + "PreReconciliation failed with message: %s", msg) + resultsCondSet.Manage(trs).MarkTrue(PreReconciler) +} + +func (trs *TektonResultStatus) MarkPostReconcilerFailed(msg string) { + trs.MarkNotReady("PostReconciliation failed") + resultsCondSet.Manage(trs).MarkFalse( + PostReconciler, + "Error", + "PostReconciliation failed with message: %s", msg) +} diff --git a/pkg/apis/operator/v1alpha1/tektonresult_lifecycle_test.go b/pkg/apis/operator/v1alpha1/tektonresult_lifecycle_test.go index cf4b2925c2..d822a0db12 100644 --- a/pkg/apis/operator/v1alpha1/tektonresult_lifecycle_test.go +++ b/pkg/apis/operator/v1alpha1/tektonresult_lifecycle_test.go @@ -42,6 +42,11 @@ func TestTektonResultHappyPath(t *testing.T) { apistest.CheckConditionOngoing(tt, DependenciesInstalled, t) apistest.CheckConditionOngoing(tt, InstallerSetAvailable, t) apistest.CheckConditionOngoing(tt, InstallerSetReady, t) + apistest.CheckConditionOngoing(tt, PreReconciler, t) + apistest.CheckConditionOngoing(tt, PostReconciler, t) + + tt.MarkPreReconcilerComplete() + apistest.CheckConditionSucceeded(tt, PreReconciler, t) // Dependencies installed tt.MarkDependenciesInstalled() @@ -53,6 +58,9 @@ func TestTektonResultHappyPath(t *testing.T) { tt.MarkInstallerSetReady() apistest.CheckConditionSucceeded(tt, InstallerSetReady, t) + tt.MarkPostReconcilerComplete() + apistest.CheckConditionSucceeded(tt, PostReconciler, t) + if ready := tt.IsReady(); !ready { t.Errorf("tt.IsReady() = %v, want true", ready) } @@ -65,6 +73,11 @@ func TestTektonResultErrorPath(t *testing.T) { apistest.CheckConditionOngoing(tt, DependenciesInstalled, t) apistest.CheckConditionOngoing(tt, InstallerSetAvailable, t) apistest.CheckConditionOngoing(tt, InstallerSetReady, t) + apistest.CheckConditionOngoing(tt, PreReconciler, t) + apistest.CheckConditionOngoing(tt, PostReconciler, t) + + tt.MarkPreReconcilerComplete() + apistest.CheckConditionSucceeded(tt, PreReconciler, t) // Dependencies installed tt.MarkDependenciesInstalled() @@ -81,6 +94,9 @@ func TestTektonResultErrorPath(t *testing.T) { tt.MarkInstallerSetReady() apistest.CheckConditionSucceeded(tt, InstallerSetReady, t) + tt.MarkPostReconcilerComplete() + apistest.CheckConditionSucceeded(tt, PostReconciler, t) + if ready := tt.IsReady(); !ready { t.Errorf("tt.IsReady() = %v, want true", ready) } diff --git a/pkg/apis/operator/v1alpha1/tektonresult_types.go b/pkg/apis/operator/v1alpha1/tektonresult_types.go index 1518231860..ea0f8be9a9 100644 --- a/pkg/apis/operator/v1alpha1/tektonresult_types.go +++ b/pkg/apis/operator/v1alpha1/tektonresult_types.go @@ -99,16 +99,6 @@ type TektonResultStatus struct { TektonInstallerSet string `json:"tektonInstallerSet,omitempty"` } -func (in *TektonResultStatus) MarkPreReconcilerFailed(s string) { - //TODO implement me - panic("implement me") -} - -func (in *TektonResultStatus) MarkPostReconcilerFailed(s string) { - //TODO implement me - panic("implement me") -} - // TektonResultsList contains a list of TektonResult // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type TektonResultList struct { diff --git a/pkg/reconciler/kubernetes/tektonresult/tektonresult.go b/pkg/reconciler/kubernetes/tektonresult/tektonresult.go index 9e03270665..3a8e8603ee 100644 --- a/pkg/reconciler/kubernetes/tektonresult/tektonresult.go +++ b/pkg/reconciler/kubernetes/tektonresult/tektonresult.go @@ -122,6 +122,18 @@ func (r *Reconciler) ReconcileKind(ctx context.Context, tr *v1alpha1.TektonResul return nil } + // execute PreReconcile + if err := r.extension.PreReconcile(ctx, tr); err != nil { + logger.Error("pre reconcile failed", err) + if err == v1alpha1.REQUEUE_EVENT_AFTER { + return err + } + tr.Status.MarkPostReconcilerFailed(err.Error()) + return nil + } + // mark PreReconcile Complete + tr.Status.MarkPreReconcilerComplete() + // find the valid tekton-pipeline installation tp, err := common.PipelineReady(r.pipelineInformer) if err != nil { @@ -267,6 +279,18 @@ func (r *Reconciler) ReconcileKind(ctx context.Context, tr *v1alpha1.TektonResul return v1alpha1.REQUEUE_EVENT_AFTER } + // execute PostReconcile + if err := r.extension.PostReconcile(ctx, tr); err != nil { + logger.Error("post reconcile failed", err) + if err == v1alpha1.REQUEUE_EVENT_AFTER { + return err + } + tr.Status.MarkPostReconcilerFailed(err.Error()) + return nil + } + // mark PostReconcile Complete + tr.Status.MarkPostReconcilerComplete() + // Mark InstallerSet Ready tr.Status.MarkInstallerSetReady() diff --git a/pkg/reconciler/openshift/tektonresult/extension.go b/pkg/reconciler/openshift/tektonresult/extension.go index ade1503137..b04579ca01 100644 --- a/pkg/reconciler/openshift/tektonresult/extension.go +++ b/pkg/reconciler/openshift/tektonresult/extension.go @@ -18,6 +18,8 @@ package tektonresult import ( "context" + "fmt" + "path/filepath" mf "github.com/manifestival/manifestival" "github.com/tektoncd/operator/pkg/apis/operator/v1alpha1" @@ -25,17 +27,37 @@ import ( operatorclient "github.com/tektoncd/operator/pkg/client/injection/client" "github.com/tektoncd/operator/pkg/reconciler/common" occommon "github.com/tektoncd/operator/pkg/reconciler/openshift/common" + "go.uber.org/zap" + "knative.dev/pkg/logging" ) func OpenShiftExtension(ctx context.Context) common.Extension { + logger := logging.FromContext(ctx) + + operatorVer, err := common.OperatorVersion(ctx) + if err != nil { + logger.Fatal(err) + } + ext := openshiftExtension{ + logger: logger, operatorClientSet: operatorclient.Get(ctx), } + + ext.postManifestReconcile = &postReconcileManifest{ + resourcesYamlDirectory: filepath.Join(common.ComponentBaseDir(), postReconcileManifestYamlDirectory), + logger: logger, + operatorClientSet: ext.operatorClientSet, + operatorVersion: operatorVer, + } + return ext } type openshiftExtension struct { - operatorClientSet versioned.Interface + logger *zap.SugaredLogger + operatorClientSet versioned.Interface + postManifestReconcile *postReconcileManifest } func (oe openshiftExtension) Transformers(comp v1alpha1.TektonComponent) []mf.Transformer { @@ -45,12 +67,21 @@ func (oe openshiftExtension) Transformers(comp v1alpha1.TektonComponent) []mf.Tr occommon.ApplyCABundles, } } -func (oe openshiftExtension) PreReconcile(ctx context.Context, tc v1alpha1.TektonComponent) error { + +func (oe openshiftExtension) PreReconcile(ctx context.Context, comp v1alpha1.TektonComponent) error { return nil } -func (oe openshiftExtension) PostReconcile(context.Context, v1alpha1.TektonComponent) error { - return nil + +func (oe openshiftExtension) PostReconcile(ctx context.Context, comp v1alpha1.TektonComponent) error { + resultCR, ok := comp.(*v1alpha1.TektonResult) + if !ok { + return fmt.Errorf("expecting tektonResult component, but received:%t", comp) + } + + // reconcile post manifests + return oe.postManifestReconcile.reconcile(ctx, resultCR) } -func (oe openshiftExtension) Finalize(context.Context, v1alpha1.TektonComponent) error { + +func (oe openshiftExtension) Finalize(ctx context.Context, comp v1alpha1.TektonComponent) error { return nil } diff --git a/pkg/reconciler/openshift/tektonresult/post_reconciler.go b/pkg/reconciler/openshift/tektonresult/post_reconciler.go new file mode 100644 index 0000000000..3d25183df6 --- /dev/null +++ b/pkg/reconciler/openshift/tektonresult/post_reconciler.go @@ -0,0 +1,208 @@ +package tektonresult + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + + "github.com/go-logr/zapr" + mf "github.com/manifestival/manifestival" + "github.com/tektoncd/operator/pkg/apis/operator/v1alpha1" + "github.com/tektoncd/operator/pkg/client/clientset/versioned" + "github.com/tektoncd/operator/pkg/reconciler/common" + "github.com/tektoncd/operator/pkg/reconciler/shared/hash" + "go.uber.org/zap" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + // post reconcile yaml directory location + postReconcileManifestYamlDirectory = "static/tekton-results/00-postreconcile" + // installerSet label value + postReconcileLabelCreatedByValue = "tekton-results-post-reconcile" + // pipeline console plugin environment variable key + PipelineConsolePluginImageEnvironmentKey = "IMAGE_PIPELINE_CONSOLE_PLUGIN" + // pipeline console plugin container name, used to replace the image from the environment + PipelineConsolePluginContainerName = "pipeline-console-plugin" +) + +var ( + // label filter to set/get installerSet specific to this reconciler + postReconcileManifestInstallerSetLabel = metav1.LabelSelector{ + MatchLabels: map[string]string{ + v1alpha1.InstallerSetType: v1alpha1.ResultResourceName, + v1alpha1.CreatedByKey: postReconcileLabelCreatedByValue, + }, + } +) + +type postReconcileManifest struct { + logger *zap.SugaredLogger + operatorClientSet versioned.Interface + syncOnce sync.Once + resourcesYamlDirectory string + operatorVersion string + pipelineConsolePluginImage string + manifest mf.Manifest + transformers []mf.Transformer +} + +// reconcile steps +// 1. get post reconcile (pipeline console plugin) manifests from kodata +// 2. verify the existing installerSet hash value +// 3. if there is a mismatch or the installerSet not available, (re)create it +func (prm *postReconcileManifest) reconcile(ctx context.Context, resultCR *v1alpha1.TektonResult) error { + + prm.updateOnce(ctx) + + // verify availability of the installerSet + labelSelector, err := common.LabelSelector(postReconcileManifestInstallerSetLabel) + if err != nil { + return err + } + + installerSetList, err := prm.operatorClientSet.OperatorV1alpha1().TektonInstallerSets().List(ctx, metav1.ListOptions{LabelSelector: labelSelector}) + if err != nil { + return err + } + + doCreateInstallerSet := false + var deployedInstallerSet v1alpha1.TektonInstallerSet + + if len(installerSetList.Items) > 1 { + for _, installerSet := range installerSetList.Items { + err = prm.operatorClientSet.OperatorV1alpha1().TektonInstallerSets().Delete(ctx, installerSet.GetName(), metav1.DeleteOptions{}) + if err != nil { + return err + } + } + doCreateInstallerSet = true + } else if len(installerSetList.Items) == 1 { + deployedInstallerSet = installerSetList.Items[0] + } else { + doCreateInstallerSet = true + } + + // clone the manifest + manifest := prm.manifest.Append() + // apply transformations + if err := prm.transform(ctx, &manifest, resultCR); err != nil { + resultCR.Status.MarkNotReady(fmt.Sprintf("transformation failed: %s", err.Error())) + return err + } + + // get expected hash value of the manifests + expectedHash, err := prm.getHash(manifest.Resources()) + if err != nil { + return err + } + + if !doCreateInstallerSet { + // compute hash from the deployed installerSet + deployedHash, err := prm.getHash(deployedInstallerSet.Spec.Manifests) + if err != nil { + return err + } + + releaseVersion := deployedInstallerSet.GetLabels()[v1alpha1.ReleaseVersionKey] + // delete the existing installerSet, + // if hash mismatch or version mismatch + if expectedHash != deployedHash || prm.operatorVersion != releaseVersion { + if err := prm.operatorClientSet.OperatorV1alpha1().TektonInstallerSets().Delete(ctx, deployedInstallerSet.GetName(), metav1.DeleteOptions{}); err != nil { + return err + } + doCreateInstallerSet = true + } + } + + if doCreateInstallerSet { + return prm.createInstallerSet(ctx, &manifest, resultCR) + } + + return nil +} + +func (prm *postReconcileManifest) updateOnce(ctx context.Context) { + // reads all yaml files from the directory, it is expensive process to access disk on each reconcile call. + // hence fetch only once at startup, it helps not to degrade the performance of the reconcile loop + // also it not necessary to read the files frequently, as the files are shipped along the container and never change + prm.syncOnce.Do(func() { + // fetch manifests from disk + manifest, err := mf.NewManifest(prm.resourcesYamlDirectory, mf.UseLogger(zapr.NewLogger(prm.logger.Desugar()))) + if err != nil { + prm.logger.Fatal("error creating initial manifest", zap.Error(err)) + } + prm.manifest = manifest + + // update pipeline console image details + consoleImage, found := os.LookupEnv(PipelineConsolePluginImageEnvironmentKey) + if found { + prm.pipelineConsolePluginImage = consoleImage + prm.logger.Debugw("pipeline console plugin image found from environment", + "image", consoleImage, + "environmentVariable", PipelineConsolePluginImageEnvironmentKey, + ) + } else { + prm.logger.Warnw("pipeline console plugin image not found from environment, continuing with the default image from the manifest", + "environmentVariable", PipelineConsolePluginImageEnvironmentKey, + ) + } + + // update transformers + prm.transformers = []mf.Transformer{ + common.InjectOperandNameLabelOverwriteExisting(v1alpha1.OperandTektoncdResults), + } + if prm.pipelineConsolePluginImage != "" { + prm.transformers = append(prm.transformers, common.DeploymentImages(map[string]string{ + // on the transformer, in the container name, the '-' replaced with '_' + strings.ReplaceAll(PipelineConsolePluginContainerName, "-", "_"): prm.pipelineConsolePluginImage, + })) + } + }) +} + +func (prm *postReconcileManifest) createInstallerSet(ctx context.Context, manifest *mf.Manifest, resultCR *v1alpha1.TektonResult) error { + // setup installerSet + ownerRef := *metav1.NewControllerRef(resultCR, resultCR.GetGroupVersionKind()) + installerSet := &v1alpha1.TektonInstallerSet{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: fmt.Sprintf("%s-post-reconcile-manifest-", v1alpha1.ResultResourceName), + Labels: postReconcileManifestInstallerSetLabel.MatchLabels, + Annotations: map[string]string{ + v1alpha1.TargetNamespaceKey: resultCR.Spec.TargetNamespace, + }, + OwnerReferences: []metav1.OwnerReference{ownerRef}, + }, + Spec: v1alpha1.TektonInstallerSetSpec{ + Manifests: manifest.Resources(), + }, + } + // update operator version + installerSet.Labels[v1alpha1.ReleaseVersionKey] = prm.operatorVersion + + // creates installerSet in the cluster + _, err := prm.operatorClientSet.OperatorV1alpha1().TektonInstallerSets().Create(ctx, installerSet, metav1.CreateOptions{}) + if err != nil { + prm.logger.Error("error on creating installerset", err) + } + return err +} + +// apply transformations +func (prm *postReconcileManifest) transform(ctx context.Context, manifest *mf.Manifest, resultCR *v1alpha1.TektonResult) error { + err := common.Transform(ctx, manifest, resultCR, prm.transformers...) + if err != nil { + return err + } + + // additional options transformer + // always execute as last transformer, so that the values in options will be final update values on the manifests + return common.ExecuteAdditionalOptionsTransformer(ctx, manifest, resultCR.Spec.GetTargetNamespace(), resultCR.Spec.Options) +} + +func (prm *postReconcileManifest) getHash(resources []unstructured.Unstructured) (string, error) { + return hash.Compute(resources) +} diff --git a/pkg/reconciler/openshift/tektonresult/post_reconciler_test.go b/pkg/reconciler/openshift/tektonresult/post_reconciler_test.go new file mode 100644 index 0000000000..fb6f16ff60 --- /dev/null +++ b/pkg/reconciler/openshift/tektonresult/post_reconciler_test.go @@ -0,0 +1,192 @@ +package tektonresult + +import ( + "context" + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tektoncd/operator/pkg/apis/operator/v1alpha1" + "github.com/tektoncd/operator/pkg/client/clientset/versioned/fake" + "github.com/tektoncd/operator/pkg/reconciler/common" + "github.com/tektoncd/operator/pkg/reconciler/shared/hash" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apimachineryRuntime "k8s.io/apimachinery/pkg/runtime" + k8sTesting "k8s.io/client-go/testing" + "knative.dev/pkg/logging" +) + +// reactor required for the GenerateName field to work when using the fake client +func generateNameReactor(action k8sTesting.Action) (bool, apimachineryRuntime.Object, error) { + resource := action.(k8sTesting.CreateAction).GetObject() + meta, ok := resource.(metav1.Object) + if !ok { + return false, resource, nil + } + + if meta.GetName() == "" && meta.GetGenerateName() != "" { + meta.SetName(common.SimpleNameGenerator.RestrictLengthWithRandomSuffix(meta.GetGenerateName())) + } + return false, resource, nil +} + +func TestPostReconcileManifest(t *testing.T) { + defaultConsolePluginImage := "ghcr.io/openshift-pipelines/console-plugin:main" + + tests := []struct { + name string + consolePluginImage string + operatorVersion string + targetNamespace string + expectedHash string + }{ + { + name: "test-without-console-plugin-image", + operatorVersion: "1.14.0", + targetNamespace: "foo", + expectedHash: "e1d425f4c54fc0f9a468df0ef9fad7e82201a569f947e098ce9006d8e0b733eb", + }, + { + name: "test-with-console-plugin-image", + consolePluginImage: "custom-image:tag1", + operatorVersion: "0.70.0", + targetNamespace: "bar", + expectedHash: "8bdfc9256c4222234cedc3b261c1fee6d7116130e4caccbcf52fdd2e624a82cd", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + + ctx := context.TODO() + operatorFakeClientSet := fake.NewSimpleClientset() + + // add reactor to update generateName + operatorFakeClientSet.PrependReactor("create", "*", generateNameReactor) + + // TEST: verifies required values in generated manifests (InstallerSet) + verifyManifestFunc := func(expectedImage, expectedOperatorVersion string) { + // verify installersets availability + installerSetList, err := operatorFakeClientSet.OperatorV1alpha1().TektonInstallerSets().List( + ctx, + metav1.ListOptions{LabelSelector: fmt.Sprintf("operator.tekton.dev/created-by=%s", postReconcileLabelCreatedByValue)}, + ) + require.NoError(t, err) + + require.Equal(t, 1, len(installerSetList.Items)) + installerSet := installerSetList.Items[0] + + // verify operator version label + operatorVersion := installerSet.GetLabels()[v1alpha1.ReleaseVersionKey] + require.Equal(t, expectedOperatorVersion, operatorVersion) + + // verify with hash + installerSetHash, err := hash.Compute(installerSet.Spec.Manifests) + require.NoError(t, err) + require.Equal(t, test.expectedHash, installerSetHash) + + // get installerset and verify transform values + for _, u := range installerSet.Spec.Manifests { + // verify targetNamespace + require.Equal(t, test.targetNamespace, u.GetNamespace()) + + switch u.GetKind() { + case "Deployment": + deployment := &appsv1.Deployment{} + err := apimachineryRuntime.DefaultUnstructuredConverter.FromUnstructured(u.Object, deployment) + require.NoError(t, err) + require.Equal(t, "pipeline-console-plugin", deployment.GetName()) + container := deployment.Spec.Template.Spec.Containers[0] + require.Equal(t, expectedImage, container.Image) + + case "ConfigMap", "ConsolePlugin", "Service": + // nothing to verify on these resources + } + } + } + + // reconciler reference + postReconcile := &postReconcileManifest{ + logger: logging.FromContext(ctx).Named("post-reconcile-manifest-test"), + operatorClientSet: operatorFakeClientSet, + syncOnce: sync.Once{}, + resourcesYamlDirectory: "./testdata", + operatorVersion: test.operatorVersion, + } + + // results CR + resultCR := &v1alpha1.TektonResult{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.ResultResourceName, + }, + Spec: v1alpha1.TektonResultSpec{ + CommonSpec: v1alpha1.CommonSpec{ + TargetNamespace: test.targetNamespace, + }, + }, + } + + // console plugin image + consolePluginImage := defaultConsolePluginImage + // update image env variable + if test.consolePluginImage != "" { + t.Setenv("IMAGE_PIPELINE_CONSOLE_PLUGIN", test.consolePluginImage) + consolePluginImage = test.consolePluginImage + } + // TEST: image name + err := postReconcile.reconcile(ctx, resultCR) // perform reconcile + require.NoError(t, err) + verifyManifestFunc(consolePluginImage, test.operatorVersion) // verify manifests + + // TEST: operator version change + // update operator version in installerSet and reconcile + postReconcile.operatorVersion = "foo" + err = postReconcile.reconcile(ctx, resultCR) // perform reconcile + require.NoError(t, err) + verifyManifestFunc(consolePluginImage, "foo") // verify + postReconcile.operatorVersion = test.operatorVersion + + // TEST: removal of extra installerSet + // add another result post reconcile installerset and reconcile + newInstallerSet := &v1alpha1.TektonInstallerSet{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "result-foo-", + Labels: postReconcileManifestInstallerSetLabel.MatchLabels, + }, + } + _, err = operatorFakeClientSet.OperatorV1alpha1().TektonInstallerSets().Create(ctx, newInstallerSet, metav1.CreateOptions{}) + require.NoError(t, err) + err = postReconcile.reconcile(ctx, resultCR) // perform reconcile + require.NoError(t, err) + verifyManifestFunc(consolePluginImage, test.operatorVersion) // verify manifests + + // TEST: do not touch others installerSet + // add another installerset(not result post reconcile) and reconcile + // this installerSet should not be removed + anotherInstallerSetName := "pipelines-foo" + anotherInstallerSet := &v1alpha1.TektonInstallerSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: anotherInstallerSetName, + }, + } + _, err = operatorFakeClientSet.OperatorV1alpha1().TektonInstallerSets().Create(ctx, anotherInstallerSet, metav1.CreateOptions{}) + require.NoError(t, err) + err = postReconcile.reconcile(ctx, resultCR) // perform reconcile + require.NoError(t, err) + verifyManifestFunc(consolePluginImage, test.operatorVersion) // verify manifests + installerSetList, err := operatorFakeClientSet.OperatorV1alpha1().TektonInstallerSets().List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, 2, len(installerSetList.Items)) + expectedInstallerSetFound := false + for _, installerSet := range installerSetList.Items { + if installerSet.GetName() == anotherInstallerSetName { + expectedInstallerSetFound = true + break + } + } + require.True(t, expectedInstallerSetFound) + }) + } +} diff --git a/pkg/reconciler/openshift/tektonresult/testdata/test_post_manifest.yaml b/pkg/reconciler/openshift/tektonresult/testdata/test_post_manifest.yaml new file mode 100644 index 0000000000..8bfe27b9fc --- /dev/null +++ b/pkg/reconciler/openshift/tektonresult/testdata/test_post_manifest.yaml @@ -0,0 +1,126 @@ +# Copyright 2023 The Tekton Authors +# +# 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. + +--- +apiVersion: v1 +kind: Service +metadata: + name: pipeline-console-plugin + namespace: openshift-pipelines + labels: + app.kubernetes.io/part-of: tektoncd-results +spec: + ports: + - name: 8443-tcp + protocol: TCP + port: 8443 + targetPort: 8443 + selector: + name: pipeline-console-plugin + app: pipeline-console-plugin + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: pipeline-console-plugin + namespace: openshift-pipelines + labels: + app.kubernetes.io/part-of: tektoncd-results +data: + nginx.conf: | + error_log /dev/stdout warn; + events {} + http { + access_log /dev/stdout; + include /etc/nginx/mime.types; + default_type application/octet-stream; + keepalive_timeout 65; + server { + listen 8443 ssl; + listen [::]:8443 ssl; + ssl_certificate /var/cert/tls.crt; + ssl_certificate_key /var/cert/tls.key; + root /usr/share/nginx/html; + } + } + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pipeline-console-plugin + namespace: openshift-pipelines + labels: + app.kubernetes.io/part-of: tektoncd-results +spec: + replicas: 1 + selector: + matchLabels: + name: pipeline-console-plugin + app: pipeline-console-plugin + template: + metadata: + labels: + name: pipeline-console-plugin + app: pipeline-console-plugin + app.kubernetes.io/part-of: tektoncd-results + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + volumes: + - name: pipeline-console-plugin-cert + secret: + secretName: pipeline-console-plugin-cert + defaultMode: 420 + - name: nginx-conf + configMap: + name: pipeline-console-plugin + defaultMode: 420 + containers: + - name: pipeline-console-plugin + image: ghcr.io/openshift-pipelines/console-plugin:main + imagePullPolicy: Always + ports: + - protocol: TCP + containerPort: 8443 + volumeMounts: + - name: pipeline-console-plugin-cert + readOnly: true + mountPath: /var/cert + - name: nginx-conf + readOnly: true + mountPath: /etc/nginx/nginx.conf + subPath: nginx.conf + +--- +apiVersion: console.openshift.io/v1 +kind: ConsolePlugin +metadata: + name: pipeline-console-plugin + labels: + app.kubernetes.io/part-of: tektoncd-results +spec: + displayName: Pipeline Console Plugin + backend: + type: Service + service: + name: pipeline-console-plugin + namespace: openshift-pipelines + port: 8443 + basePath: "/" + i18n: + loadType: Preload # options: Preload, Lazy