From 51b142d9ec51ef02c0a980fd08b050e996ec9b66 Mon Sep 17 00:00:00 2001 From: Thierno-Bah1 Date: Thu, 27 Aug 2026 12:32:05 -0400 Subject: [PATCH 1/4] feat(mcpi-cli-install): add MCPI pipelines namespace setup and install pipeline launch --- src/mas/devops/tekton.py | 97 ++++++++ .../templates/mcpi-pipelines-pvc.yml.j2 | 15 ++ .../templates/mcpi-pipelines-rbac.yml.j2 | 14 ++ .../templates/pipelinerun-mcpi-install.yml.j2 | 150 ++++++++++++ test/src/test_tekton_mcpi.py | 223 ++++++++++++++++++ 5 files changed, 499 insertions(+) create mode 100644 src/mas/devops/templates/mcpi-pipelines-pvc.yml.j2 create mode 100644 src/mas/devops/templates/mcpi-pipelines-rbac.yml.j2 create mode 100644 src/mas/devops/templates/pipelinerun-mcpi-install.yml.j2 create mode 100644 test/src/test_tekton_mcpi.py diff --git a/src/mas/devops/tekton.py b/src/mas/devops/tekton.py index 2ccfef2b..dc32fa4e 100644 --- a/src/mas/devops/tekton.py +++ b/src/mas/devops/tekton.py @@ -810,6 +810,83 @@ def prepareAiServicePipelinesNamespace( logger.info(f"Storage class {storageClass} uses volumeBindingMode={volumeBindingMode}, skipping PVC bind wait") +def prepareMcpiPipelinesNamespace( + dynClient: DynamicClient, + instanceId: str = None, + storageClass: str = None, + accessMode: str = None, + waitForBind: bool = True, + configureRBAC: bool = True, +): + """ + Prepare a namespace for MCPI pipelines by creating RBAC and PVC resources. + + Creates MCPI-specific pipeline namespace with necessary role bindings + and persistent volume claims. + + Args: + dynClient (DynamicClient): OpenShift Dynamic Client + instanceId (str, optional): MCPI instance ID. Defaults to None. + storageClass (str, optional): Storage class for the PVC. Defaults to None. + accessMode (str, optional): Access mode for the PVC. Defaults to None. + waitForBind (bool, optional): Whether to wait for PVC to bind. Defaults to True. + configureRBAC (bool, optional): Whether to configure RBAC. Defaults to True. + + Raises: + NotFoundError: If resources cannot be created + """ + templateDir = path.join(path.abspath(path.dirname(__file__)), "templates") + env = Environment(loader=FileSystemLoader(searchpath=templateDir)) + namespace = f"mcpi-{instanceId}-pipelines" + template = env.get_template("mcpi-pipelines-rbac.yml.j2") + + if configureRBAC: + renderedTemplate = template.render(mcpi_instance_id=instanceId) + logger.debug(renderedTemplate) + crb = yaml.safe_load(renderedTemplate) + applyResource( + dynClient=dynClient, + apiVersion="rbac.authorization.k8s.io/v1", + kind="ClusterRoleBinding", + body=crb, + namespace=namespace, + ) + + template = env.get_template("mcpi-pipelines-pvc.yml.j2") + renderedTemplate = template.render( + mcpi_instance_id=instanceId, + pipeline_storage_class=storageClass, + pipeline_storage_accessmode=accessMode, + ) + logger.debug(renderedTemplate) + pvc = yaml.safe_load(renderedTemplate) + pvcAPI = dynClient.resources.get(api_version="v1", kind="PersistentVolumeClaim") + applyResource( + dynClient=dynClient, + apiVersion="v1", + kind="PersistentVolumeClaim", + body=pvc, + namespace=namespace, + ) + + volumeBindingMode = getStorageClassVolumeBindingMode(dynClient, storageClass) + waitForBind = volumeBindingMode == "Immediate" + + if waitForBind: + logger.info(f"Storage class {storageClass} uses volumeBindingMode={volumeBindingMode}, waiting for PVC to bind") + pvcIsBound = False + while not pvcIsBound: + configPVC = pvcAPI.get(name="config-pvc", namespace=namespace) + if configPVC.status.phase == "Bound": + pvcIsBound = True + else: + logger.debug("Waiting 15s before checking status of PVC again") + logger.debug(configPVC) + sleep(15) + else: + logger.info(f"Storage class {storageClass} uses volumeBindingMode={volumeBindingMode}, skipping PVC bind wait") + + def prepareRestoreSecrets(dynClient: DynamicClient, namespace: str, restoreConfigs: dict = None): """ Create or update secret required for MAS Restore pipeline. @@ -1309,6 +1386,26 @@ def launchInstallPipeline(dynClient: DynamicClient, params: dict) -> str: return pipelineURL +def launchMcpiInstallPipeline(dynClient: DynamicClient, params: dict) -> str: + """ + Create a PipelineRun to install an MCPI instance. + + Args: + dynClient (DynamicClient): OpenShift Dynamic Client + params (dict): Installation parameters including mas_instance_id and mcpi_channel + + Returns: + str: URL to the PipelineRun in the OpenShift console + + Raises: + NotFoundError: If resources cannot be created + """ + instanceId = params["mas_instance_id"] + namespace = f"mcpi-{instanceId}-pipelines" + timestamp = launchPipelineRun(dynClient, namespace, "pipelinerun-mcpi-install", params) + return f"{getConsoleURL(dynClient)}/k8s/ns/mcpi-{instanceId}-pipelines/tekton.dev~v1beta1~PipelineRun/{instanceId}-install-{timestamp}" + + def launchUpdatePipeline(dynClient: DynamicClient, params: dict) -> str: """ Create a PipelineRun to update the Maximo Operator Catalog. diff --git a/src/mas/devops/templates/mcpi-pipelines-pvc.yml.j2 b/src/mas/devops/templates/mcpi-pipelines-pvc.yml.j2 new file mode 100644 index 00000000..35aa39e0 --- /dev/null +++ b/src/mas/devops/templates/mcpi-pipelines-pvc.yml.j2 @@ -0,0 +1,15 @@ +--- +# 1. Set up a PVC for shared storage +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: config-pvc + namespace: mcpi-{{ mcpi_instance_id }}-pipelines +spec: + accessModes: + - {{ pipeline_storage_accessmode }} + volumeMode: Filesystem + storageClassName: {{ pipeline_storage_class }} + resources: + requests: + storage: 500Mi diff --git a/src/mas/devops/templates/mcpi-pipelines-rbac.yml.j2 b/src/mas/devops/templates/mcpi-pipelines-rbac.yml.j2 new file mode 100644 index 00000000..bbe0941e --- /dev/null +++ b/src/mas/devops/templates/mcpi-pipelines-rbac.yml.j2 @@ -0,0 +1,14 @@ +--- +# 1. Configure RBAC for the pipeline tasks +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: mcpi-pipeline-{{ mcpi_instance_id }} +subjects: + - kind: ServiceAccount + name: pipeline + namespace: mcpi-{{ mcpi_instance_id }}-pipelines +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin diff --git a/src/mas/devops/templates/pipelinerun-mcpi-install.yml.j2 b/src/mas/devops/templates/pipelinerun-mcpi-install.yml.j2 new file mode 100644 index 00000000..37affae8 --- /dev/null +++ b/src/mas/devops/templates/pipelinerun-mcpi-install.yml.j2 @@ -0,0 +1,150 @@ +--- +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + name: "{{mas_instance_id}}-install-{{ timestamp }}" + labels: + tekton.dev/pipeline: mcpi-install +spec: + pipelineRef: + name: mcpi-install + + taskRunTemplate: + serviceAccountName: "{{ service_account_name | default('pipeline', True) }}" + timeouts: + pipeline: "0" + + params: + # IBM Entitlement Key + # ------------------------------------------------------------------------- + - name: ibm_entitlement_key + value: "{{ ibm_entitlement_key }}" +{%- if skip_pre_check is defined and skip_pre_check != "" %} + + # Pipeline config + # ------------------------------------------------------------------------- + - name: skip_pre_check + value: "{{ skip_pre_check }}" +{%- endif %} +{%- if image_pull_policy is defined and image_pull_policy != "" %} + + # Image Pull Policy + # ------------------------------------------------------------------------- + - name: image_pull_policy + value: "{{ image_pull_policy }}" +{%- endif %} +{%- if artifactory_username is defined and artifactory_username != "" %} + + # Enable development catalogs + # ------------------------------------------------------------------------- + - name: artifactory_username + value: "{{ artifactory_username }}" + - name: artifactory_token + value: "{{ artifactory_token }}" +{%- endif %} + + # Storage Classes + # ------------------------------------------------------------------------- + - name: storage_class_rwx + value: "{{ storage_class_rwx }}" + - name: storage_class_rwo + value: "{{ storage_class_rwo }}" + + # Dependencies - SLS + # ------------------------------------------------------------------------- +{%- if sls_channel is defined and sls_channel != "" %} + - name: sls_channel + value: "{{ sls_channel }}" +{%- endif %} +{%- if sls_entitlement_file is defined and sls_entitlement_file != "" %} + - name: sls_entitlement_file + value: "{{ sls_entitlement_file }}" +{%- endif %} +{%- if sls_namespace is defined and sls_namespace != "" %} + - name: sls_namespace + value: "{{ sls_namespace }}" +{%- endif %} +{%- if sls_action is defined and sls_action != "" %} + - name: sls_action + value: "{{ sls_action }}" +{%- endif %} + + # Dependencies - DRO (Required) + # ------------------------------------------------------------------------- + - name: dro_action + value: "{{ dro_action }}" + - name: dro_contact_email + value: "{{ dro_contact_email }}" + - name: dro_contact_firstname + value: "{{ dro_contact_firstname }}" + - name: dro_contact_lastname + value: "{{ dro_contact_lastname }}" +{%- if dro_namespace is defined and dro_namespace != "" %} + - name: dro_namespace + value: "{{ dro_namespace }}" +{%- endif %} + + # Dependencies - Certificate Manager + # ------------------------------------------------------------------------- + - name: cert_manager_provider + value: "{{ cert_manager_provider }}" + - name: cert_manager_action + value: "{{ cert_manager_action }}" + + # MAS Catalog + # ------------------------------------------------------------------------- + - name: mas_catalog_version + value: "{{ mas_catalog_version }}" +{%- if mas_catalog_digest is defined and mas_catalog_digest != "" %} + - name: mas_catalog_digest + value: "{{ mas_catalog_digest }}" +{%- endif %} + + # MAS Core + # ------------------------------------------------------------------------- + - name: mas_instance_id + value: "{{ mas_instance_id }}" + + # MCPI Add-On + # ------------------------------------------------------------------------- + - name: mcpi_channel + value: "{{ mcpi_channel }}" +{%- if routing_mode is defined and routing_mode != "" %} + - name: routing_mode + value: "{{ routing_mode }}" +{%- endif %} +{%- if manual_route_mgmt is defined and manual_route_mgmt != "" %} + - name: manual_route_mgmt + value: "{{ manual_route_mgmt }}" +{%- endif %} + + workspaces: + # The generated configuration files + # ------------------------------------------------------------------------- + - name: shared-configs + persistentVolumeClaim: + claimName: config-pvc + + # User-provided configurations + # ------------------------------------------------------------------------- + - name: shared-additional-configs + secret: + secretName: pipeline-additional-configs + + # SLS entitlement + # ------------------------------------------------------------------------- + - name: shared-entitlement + secret: + secretName: pipeline-sls-entitlement + + # PodTemplates configurations + # ------------------------------------------------------------------------- + - name: shared-pod-templates + secret: + secretName: pipeline-pod-templates + + # Certificates configurations + # ------------------------------------------------------------------------- + - name: shared-certificates + secret: + secretName: pipeline-certificates diff --git a/test/src/test_tekton_mcpi.py b/test/src/test_tekton_mcpi.py new file mode 100644 index 00000000..ef596c40 --- /dev/null +++ b/test/src/test_tekton_mcpi.py @@ -0,0 +1,223 @@ +# ***************************************************************************** +# Copyright (c) 2026 IBM Corporation and other Contributors. +# +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v1.0 +# which accompanies this distribution, and is available at +# http://www.eclipse.org/legal/epl-v10.html +# +# ***************************************************************************** + +"""Tests for MCPI Tekton pipeline support functions. + +This module covers prepareMcpiPipelinesNamespace() and launchMcpiInstallPipeline() +added to mas.devops.tekton. +""" + +import pytest +from unittest.mock import MagicMock, patch, call + + +class TestPrepareMcpiPipelinesNamespace: + """Test suite for tekton.prepareMcpiPipelinesNamespace().""" + + @patch("mas.devops.tekton.getStorageClassVolumeBindingMode") + @patch("mas.devops.tekton.applyResource") + def test_creates_namespace_rbac_and_pvc(self, mockApply, mockVolumeBindingMode): + """Test that RBAC ClusterRoleBinding and PVC are created in the correct namespace. + + GIVEN a valid instanceId, storageClass, and accessMode + WHEN prepareMcpiPipelinesNamespace() is called + THEN applyResource is called for ClusterRoleBinding and PVC in mcpi--pipelines. + """ + from mas.devops.tekton import prepareMcpiPipelinesNamespace + + mockVolumeBindingMode.return_value = "WaitForFirstConsumer" + mockDynClient = MagicMock() + mockPvcAPI = MagicMock() + mockPvcAPI.get.side_effect = Exception("not found") + mockDynClient.resources.get.return_value = mockPvcAPI + + prepareMcpiPipelinesNamespace( + dynClient=mockDynClient, + instanceId="test1", + storageClass="ocs-storagecluster-cephfs", + accessMode="ReadWriteMany", + ) + + assert mockApply.call_count == 2 + # First call: ClusterRoleBinding + crbCall = mockApply.call_args_list[0] + assert crbCall.kwargs["kind"] == "ClusterRoleBinding" + assert crbCall.kwargs["namespace"] == "mcpi-test1-pipelines" + # Second call: PVC + pvcCall = mockApply.call_args_list[1] + assert pvcCall.kwargs["kind"] == "PersistentVolumeClaim" + assert pvcCall.kwargs["namespace"] == "mcpi-test1-pipelines" + + @patch("mas.devops.tekton.getStorageClassVolumeBindingMode") + @patch("mas.devops.tekton.applyResource") + def test_skips_rbac_when_configure_rbac_false(self, mockApply, mockVolumeBindingMode): + """Test that RBAC is not created when configureRBAC=False. + + GIVEN configureRBAC=False + WHEN prepareMcpiPipelinesNamespace() is called + THEN only PVC apply is called (not ClusterRoleBinding). + """ + from mas.devops.tekton import prepareMcpiPipelinesNamespace + + mockVolumeBindingMode.return_value = "WaitForFirstConsumer" + mockDynClient = MagicMock() + mockPvcAPI = MagicMock() + mockPvcAPI.get.side_effect = Exception("not found") + mockDynClient.resources.get.return_value = mockPvcAPI + + prepareMcpiPipelinesNamespace( + dynClient=mockDynClient, + instanceId="test1", + storageClass="ocs-storagecluster-cephfs", + accessMode="ReadWriteMany", + configureRBAC=False, + ) + + assert mockApply.call_count == 1 + pvcCall = mockApply.call_args_list[0] + assert pvcCall.kwargs["kind"] == "PersistentVolumeClaim" + + @patch("mas.devops.tekton.getStorageClassVolumeBindingMode") + @patch("mas.devops.tekton.applyResource") + def test_rbac_template_uses_mcpi_instance_id(self, mockApply, mockVolumeBindingMode): + """Test that the ClusterRoleBinding body references the mcpi namespace and instance ID. + + GIVEN instanceId='myinst' + WHEN prepareMcpiPipelinesNamespace() is called + THEN the ClusterRoleBinding body names use 'mcpi-myinst' prefix. + """ + from mas.devops.tekton import prepareMcpiPipelinesNamespace + + mockVolumeBindingMode.return_value = "WaitForFirstConsumer" + mockDynClient = MagicMock() + mockPvcAPI = MagicMock() + mockPvcAPI.get.side_effect = Exception("not found") + mockDynClient.resources.get.return_value = mockPvcAPI + + prepareMcpiPipelinesNamespace( + dynClient=mockDynClient, + instanceId="myinst", + storageClass="gp2", + accessMode="ReadWriteOnce", + ) + + crbBody = mockApply.call_args_list[0].kwargs["body"] + assert crbBody["metadata"]["name"] == "mcpi-pipeline-myinst" + subjects = crbBody["subjects"] + assert subjects[0]["namespace"] == "mcpi-myinst-pipelines" + + @patch("mas.devops.tekton.getStorageClassVolumeBindingMode") + @patch("mas.devops.tekton.applyResource") + def test_pvc_template_uses_mcpi_namespace(self, mockApply, mockVolumeBindingMode): + """Test that the PVC body is created in the mcpi namespace with the given storage class. + + GIVEN instanceId='inst2' and storageClass='thin' + WHEN prepareMcpiPipelinesNamespace() is called + THEN the PVC body has namespace mcpi-inst2-pipelines and storageClassName thin. + """ + from mas.devops.tekton import prepareMcpiPipelinesNamespace + + mockVolumeBindingMode.return_value = "WaitForFirstConsumer" + mockDynClient = MagicMock() + mockPvcAPI = MagicMock() + mockPvcAPI.get.side_effect = Exception("not found") + mockDynClient.resources.get.return_value = mockPvcAPI + + prepareMcpiPipelinesNamespace( + dynClient=mockDynClient, + instanceId="inst2", + storageClass="thin", + accessMode="ReadWriteOnce", + ) + + pvcBody = mockApply.call_args_list[1].kwargs["body"] + assert pvcBody["metadata"]["namespace"] == "mcpi-inst2-pipelines" + assert pvcBody["spec"]["storageClassName"] == "thin" + assert pvcBody["spec"]["accessModes"] == ["ReadWriteOnce"] + + +class TestLaunchMcpiInstallPipeline: + """Test suite for tekton.launchMcpiInstallPipeline().""" + + @patch("mas.devops.tekton.getConsoleURL") + @patch("mas.devops.tekton.launchPipelineRun") + def test_launches_in_mcpi_namespace(self, mockLaunchPipelineRun, mockGetConsoleURL): + """Test that the pipeline run is launched in the mcpi--pipelines namespace. + + GIVEN params with mas_instance_id='inst1' + WHEN launchMcpiInstallPipeline() is called + THEN launchPipelineRun is called with namespace=mcpi-inst1-pipelines. + """ + from mas.devops.tekton import launchMcpiInstallPipeline + + mockLaunchPipelineRun.return_value = "260101-1200" + mockGetConsoleURL.return_value = "https://console.example.com" + mockDynClient = MagicMock() + + launchMcpiInstallPipeline( + dynClient=mockDynClient, + params={"mas_instance_id": "inst1", "mcpi_channel": "v9.2"}, + ) + + mockLaunchPipelineRun.assert_called_once_with( + mockDynClient, + "mcpi-inst1-pipelines", + "pipelinerun-mcpi-install", + {"mas_instance_id": "inst1", "mcpi_channel": "v9.2"}, + ) + + @patch("mas.devops.tekton.getConsoleURL") + @patch("mas.devops.tekton.launchPipelineRun") + def test_returns_console_url(self, mockLaunchPipelineRun, mockGetConsoleURL): + """Test that the returned URL points to the correct PipelineRun in the OCP console. + + GIVEN params with mas_instance_id='inst1' and a known timestamp + WHEN launchMcpiInstallPipeline() is called + THEN the returned URL contains the instance ID, namespace, and timestamp. + """ + from mas.devops.tekton import launchMcpiInstallPipeline + + mockLaunchPipelineRun.return_value = "260101-1200" + mockGetConsoleURL.return_value = "https://console.example.com" + mockDynClient = MagicMock() + + url = launchMcpiInstallPipeline( + dynClient=mockDynClient, + params={"mas_instance_id": "inst1", "mcpi_channel": "v9.2"}, + ) + + assert url == ( + "https://console.example.com/k8s/ns/mcpi-inst1-pipelines" + "/tekton.dev~v1beta1~PipelineRun/inst1-install-260101-1200" + ) + + @patch("mas.devops.tekton.getConsoleURL") + @patch("mas.devops.tekton.launchPipelineRun") + def test_uses_pipelinerun_mcpi_install_template(self, mockLaunchPipelineRun, mockGetConsoleURL): + """Test that the correct pipelinerun template name is used. + + GIVEN any valid params + WHEN launchMcpiInstallPipeline() is called + THEN launchPipelineRun is called with templateName='pipelinerun-mcpi-install'. + """ + from mas.devops.tekton import launchMcpiInstallPipeline + + mockLaunchPipelineRun.return_value = "260101-0900" + mockGetConsoleURL.return_value = "https://console.example.com" + mockDynClient = MagicMock() + + launchMcpiInstallPipeline( + dynClient=mockDynClient, + params={"mas_instance_id": "dev1", "mcpi_channel": "v9.3"}, + ) + + _, args, _ = mockLaunchPipelineRun.mock_calls[0] + templateName = args[2] + assert templateName == "pipelinerun-mcpi-install" From 32a58ea9eefc0235beccd46e113413d1ca3df911 Mon Sep 17 00:00:00 2001 From: Thierno-Bah1 Date: Thu, 3 Sep 2026 13:13:42 -0400 Subject: [PATCH 2/4] test(mcpi-cli-install): remove unused import and inline assert string --- test/src/test_tekton_mcpi.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/src/test_tekton_mcpi.py b/test/src/test_tekton_mcpi.py index ef596c40..1a44e718 100644 --- a/test/src/test_tekton_mcpi.py +++ b/test/src/test_tekton_mcpi.py @@ -14,8 +14,7 @@ added to mas.devops.tekton. """ -import pytest -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch class TestPrepareMcpiPipelinesNamespace: @@ -193,10 +192,7 @@ def test_returns_console_url(self, mockLaunchPipelineRun, mockGetConsoleURL): params={"mas_instance_id": "inst1", "mcpi_channel": "v9.2"}, ) - assert url == ( - "https://console.example.com/k8s/ns/mcpi-inst1-pipelines" - "/tekton.dev~v1beta1~PipelineRun/inst1-install-260101-1200" - ) + assert url == ("https://console.example.com/k8s/ns/mcpi-inst1-pipelines" "/tekton.dev~v1beta1~PipelineRun/inst1-install-260101-1200") @patch("mas.devops.tekton.getConsoleURL") @patch("mas.devops.tekton.launchPipelineRun") From 180cce1693b80fc6c48f6d3bbcdc0dcfc6fa6f6b Mon Sep 17 00:00:00 2001 From: Thierno-Bah1 Date: Fri, 9 Oct 2026 02:11:52 -0400 Subject: [PATCH 3/4] fix(tekton): remove duplicate prepareMcpiPipelinesNamespace function --- src/mas/devops/tekton.py | 77 ---------------------------------------- 1 file changed, 77 deletions(-) diff --git a/src/mas/devops/tekton.py b/src/mas/devops/tekton.py index dd81e73e..cde8b280 100644 --- a/src/mas/devops/tekton.py +++ b/src/mas/devops/tekton.py @@ -920,83 +920,6 @@ def prepareMcpiPipelinesNamespace( logger.info(f"Storage class {storageClass} uses volumeBindingMode={volumeBindingMode}, skipping PVC bind wait") -def prepareMcpiPipelinesNamespace( - dynClient: DynamicClient, - instanceId: str = None, - storageClass: str = None, - accessMode: str = None, - waitForBind: bool = True, - configureRBAC: bool = True, -): - """ - Prepare a namespace for MCPI pipelines by creating RBAC and PVC resources. - - Creates MCPI-specific pipeline namespace with necessary role bindings - and persistent volume claims. - - Args: - dynClient (DynamicClient): OpenShift Dynamic Client - instanceId (str, optional): MCPI instance ID. Defaults to None. - storageClass (str, optional): Storage class for the PVC. Defaults to None. - accessMode (str, optional): Access mode for the PVC. Defaults to None. - waitForBind (bool, optional): Whether to wait for PVC to bind. Defaults to True. - configureRBAC (bool, optional): Whether to configure RBAC. Defaults to True. - - Raises: - NotFoundError: If resources cannot be created - """ - templateDir = path.join(path.abspath(path.dirname(__file__)), "templates") - env = Environment(loader=FileSystemLoader(searchpath=templateDir)) - namespace = f"mcpi-{instanceId}-pipelines" - template = env.get_template("mcpi-pipelines-rbac.yml.j2") - - if configureRBAC: - renderedTemplate = template.render(mcpi_instance_id=instanceId) - logger.debug(renderedTemplate) - crb = yaml.safe_load(renderedTemplate) - applyResource( - dynClient=dynClient, - apiVersion="rbac.authorization.k8s.io/v1", - kind="ClusterRoleBinding", - body=crb, - namespace=namespace, - ) - - template = env.get_template("mcpi-pipelines-pvc.yml.j2") - renderedTemplate = template.render( - mcpi_instance_id=instanceId, - pipeline_storage_class=storageClass, - pipeline_storage_accessmode=accessMode, - ) - logger.debug(renderedTemplate) - pvc = yaml.safe_load(renderedTemplate) - pvcAPI = dynClient.resources.get(api_version="v1", kind="PersistentVolumeClaim") - applyResource( - dynClient=dynClient, - apiVersion="v1", - kind="PersistentVolumeClaim", - body=pvc, - namespace=namespace, - ) - - volumeBindingMode = getStorageClassVolumeBindingMode(dynClient, storageClass) - waitForBind = volumeBindingMode == "Immediate" - - if waitForBind: - logger.info(f"Storage class {storageClass} uses volumeBindingMode={volumeBindingMode}, waiting for PVC to bind") - pvcIsBound = False - while not pvcIsBound: - configPVC = pvcAPI.get(name="config-pvc", namespace=namespace) - if configPVC.status.phase == "Bound": - pvcIsBound = True - else: - logger.debug("Waiting 15s before checking status of PVC again") - logger.debug(configPVC) - sleep(15) - else: - logger.info(f"Storage class {storageClass} uses volumeBindingMode={volumeBindingMode}, skipping PVC bind wait") - - def prepareRestoreSecrets( dynClient: DynamicClient, namespace: str, From 4e68de489a162abdd69c3449dda5f21ec8c66cd9 Mon Sep 17 00:00:00 2001 From: Thierno-Bah1 Date: Sun, 11 Oct 2026 12:08:05 -0400 Subject: [PATCH 4/4] refactor(tekton): remove mcpi install pipeline code --- src/mas/devops/tekton.py | 97 -------- .../templates/mcpi-pipelines-pvc.yml.j2 | 15 -- .../templates/mcpi-pipelines-rbac.yml.j2 | 14 -- .../templates/pipelinerun-mcpi-install.yml.j2 | 150 ------------ test/src/test_tekton_mcpi.py | 219 ------------------ 5 files changed, 495 deletions(-) delete mode 100644 src/mas/devops/templates/mcpi-pipelines-pvc.yml.j2 delete mode 100644 src/mas/devops/templates/mcpi-pipelines-rbac.yml.j2 delete mode 100644 src/mas/devops/templates/pipelinerun-mcpi-install.yml.j2 delete mode 100644 test/src/test_tekton_mcpi.py diff --git a/src/mas/devops/tekton.py b/src/mas/devops/tekton.py index cde8b280..ed44dc4e 100644 --- a/src/mas/devops/tekton.py +++ b/src/mas/devops/tekton.py @@ -843,83 +843,6 @@ def prepareAiServicePipelinesNamespace( logger.info(f"Storage class {storageClass} uses volumeBindingMode={volumeBindingMode}, skipping PVC bind wait") -def prepareMcpiPipelinesNamespace( - dynClient: DynamicClient, - instanceId: str = None, - storageClass: str = None, - accessMode: str = None, - waitForBind: bool = True, - configureRBAC: bool = True, -): - """ - Prepare a namespace for MCPI pipelines by creating RBAC and PVC resources. - - Creates MCPI-specific pipeline namespace with necessary role bindings - and persistent volume claims. - - Args: - dynClient (DynamicClient): OpenShift Dynamic Client - instanceId (str, optional): MCPI instance ID. Defaults to None. - storageClass (str, optional): Storage class for the PVC. Defaults to None. - accessMode (str, optional): Access mode for the PVC. Defaults to None. - waitForBind (bool, optional): Whether to wait for PVC to bind. Defaults to True. - configureRBAC (bool, optional): Whether to configure RBAC. Defaults to True. - - Raises: - NotFoundError: If resources cannot be created - """ - templateDir = path.join(path.abspath(path.dirname(__file__)), "templates") - env = Environment(loader=FileSystemLoader(searchpath=templateDir)) - namespace = f"mcpi-{instanceId}-pipelines" - template = env.get_template("mcpi-pipelines-rbac.yml.j2") - - if configureRBAC: - renderedTemplate = template.render(mcpi_instance_id=instanceId) - logger.debug(renderedTemplate) - crb = yaml.safe_load(renderedTemplate) - applyResource( - dynClient=dynClient, - apiVersion="rbac.authorization.k8s.io/v1", - kind="ClusterRoleBinding", - body=crb, - namespace=namespace, - ) - - template = env.get_template("mcpi-pipelines-pvc.yml.j2") - renderedTemplate = template.render( - mcpi_instance_id=instanceId, - pipeline_storage_class=storageClass, - pipeline_storage_accessmode=accessMode, - ) - logger.debug(renderedTemplate) - pvc = yaml.safe_load(renderedTemplate) - pvcAPI = dynClient.resources.get(api_version="v1", kind="PersistentVolumeClaim") - applyResource( - dynClient=dynClient, - apiVersion="v1", - kind="PersistentVolumeClaim", - body=pvc, - namespace=namespace, - ) - - volumeBindingMode = getStorageClassVolumeBindingMode(dynClient, storageClass) - waitForBind = volumeBindingMode == "Immediate" - - if waitForBind: - logger.info(f"Storage class {storageClass} uses volumeBindingMode={volumeBindingMode}, waiting for PVC to bind") - pvcIsBound = False - while not pvcIsBound: - configPVC = pvcAPI.get(name="config-pvc", namespace=namespace) - if configPVC.status.phase == "Bound": - pvcIsBound = True - else: - logger.debug("Waiting 15s before checking status of PVC again") - logger.debug(configPVC) - sleep(15) - else: - logger.info(f"Storage class {storageClass} uses volumeBindingMode={volumeBindingMode}, skipping PVC bind wait") - - def prepareRestoreSecrets( dynClient: DynamicClient, namespace: str, @@ -1607,26 +1530,6 @@ def launchInstallPipeline(dynClient: DynamicClient, params: dict) -> str: return pipelineURL -def launchMcpiInstallPipeline(dynClient: DynamicClient, params: dict) -> str: - """ - Create a PipelineRun to install an MCPI instance. - - Args: - dynClient (DynamicClient): OpenShift Dynamic Client - params (dict): Installation parameters including mas_instance_id and mcpi_channel - - Returns: - str: URL to the PipelineRun in the OpenShift console - - Raises: - NotFoundError: If resources cannot be created - """ - instanceId = params["mas_instance_id"] - namespace = f"mcpi-{instanceId}-pipelines" - timestamp = launchPipelineRun(dynClient, namespace, "pipelinerun-mcpi-install", params) - return f"{getConsoleURL(dynClient)}/k8s/ns/mcpi-{instanceId}-pipelines/tekton.dev~v1beta1~PipelineRun/{instanceId}-install-{timestamp}" - - def launchUpdatePipeline(dynClient: DynamicClient, params: dict) -> str: """ Create a PipelineRun to update the Maximo Operator Catalog. diff --git a/src/mas/devops/templates/mcpi-pipelines-pvc.yml.j2 b/src/mas/devops/templates/mcpi-pipelines-pvc.yml.j2 deleted file mode 100644 index 35aa39e0..00000000 --- a/src/mas/devops/templates/mcpi-pipelines-pvc.yml.j2 +++ /dev/null @@ -1,15 +0,0 @@ ---- -# 1. Set up a PVC for shared storage -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: config-pvc - namespace: mcpi-{{ mcpi_instance_id }}-pipelines -spec: - accessModes: - - {{ pipeline_storage_accessmode }} - volumeMode: Filesystem - storageClassName: {{ pipeline_storage_class }} - resources: - requests: - storage: 500Mi diff --git a/src/mas/devops/templates/mcpi-pipelines-rbac.yml.j2 b/src/mas/devops/templates/mcpi-pipelines-rbac.yml.j2 deleted file mode 100644 index bbe0941e..00000000 --- a/src/mas/devops/templates/mcpi-pipelines-rbac.yml.j2 +++ /dev/null @@ -1,14 +0,0 @@ ---- -# 1. Configure RBAC for the pipeline tasks -kind: ClusterRoleBinding -apiVersion: rbac.authorization.k8s.io/v1 -metadata: - name: mcpi-pipeline-{{ mcpi_instance_id }} -subjects: - - kind: ServiceAccount - name: pipeline - namespace: mcpi-{{ mcpi_instance_id }}-pipelines -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: cluster-admin diff --git a/src/mas/devops/templates/pipelinerun-mcpi-install.yml.j2 b/src/mas/devops/templates/pipelinerun-mcpi-install.yml.j2 deleted file mode 100644 index 37affae8..00000000 --- a/src/mas/devops/templates/pipelinerun-mcpi-install.yml.j2 +++ /dev/null @@ -1,150 +0,0 @@ ---- -apiVersion: tekton.dev/v1 -kind: PipelineRun -metadata: - name: "{{mas_instance_id}}-install-{{ timestamp }}" - labels: - tekton.dev/pipeline: mcpi-install -spec: - pipelineRef: - name: mcpi-install - - taskRunTemplate: - serviceAccountName: "{{ service_account_name | default('pipeline', True) }}" - timeouts: - pipeline: "0" - - params: - # IBM Entitlement Key - # ------------------------------------------------------------------------- - - name: ibm_entitlement_key - value: "{{ ibm_entitlement_key }}" -{%- if skip_pre_check is defined and skip_pre_check != "" %} - - # Pipeline config - # ------------------------------------------------------------------------- - - name: skip_pre_check - value: "{{ skip_pre_check }}" -{%- endif %} -{%- if image_pull_policy is defined and image_pull_policy != "" %} - - # Image Pull Policy - # ------------------------------------------------------------------------- - - name: image_pull_policy - value: "{{ image_pull_policy }}" -{%- endif %} -{%- if artifactory_username is defined and artifactory_username != "" %} - - # Enable development catalogs - # ------------------------------------------------------------------------- - - name: artifactory_username - value: "{{ artifactory_username }}" - - name: artifactory_token - value: "{{ artifactory_token }}" -{%- endif %} - - # Storage Classes - # ------------------------------------------------------------------------- - - name: storage_class_rwx - value: "{{ storage_class_rwx }}" - - name: storage_class_rwo - value: "{{ storage_class_rwo }}" - - # Dependencies - SLS - # ------------------------------------------------------------------------- -{%- if sls_channel is defined and sls_channel != "" %} - - name: sls_channel - value: "{{ sls_channel }}" -{%- endif %} -{%- if sls_entitlement_file is defined and sls_entitlement_file != "" %} - - name: sls_entitlement_file - value: "{{ sls_entitlement_file }}" -{%- endif %} -{%- if sls_namespace is defined and sls_namespace != "" %} - - name: sls_namespace - value: "{{ sls_namespace }}" -{%- endif %} -{%- if sls_action is defined and sls_action != "" %} - - name: sls_action - value: "{{ sls_action }}" -{%- endif %} - - # Dependencies - DRO (Required) - # ------------------------------------------------------------------------- - - name: dro_action - value: "{{ dro_action }}" - - name: dro_contact_email - value: "{{ dro_contact_email }}" - - name: dro_contact_firstname - value: "{{ dro_contact_firstname }}" - - name: dro_contact_lastname - value: "{{ dro_contact_lastname }}" -{%- if dro_namespace is defined and dro_namespace != "" %} - - name: dro_namespace - value: "{{ dro_namespace }}" -{%- endif %} - - # Dependencies - Certificate Manager - # ------------------------------------------------------------------------- - - name: cert_manager_provider - value: "{{ cert_manager_provider }}" - - name: cert_manager_action - value: "{{ cert_manager_action }}" - - # MAS Catalog - # ------------------------------------------------------------------------- - - name: mas_catalog_version - value: "{{ mas_catalog_version }}" -{%- if mas_catalog_digest is defined and mas_catalog_digest != "" %} - - name: mas_catalog_digest - value: "{{ mas_catalog_digest }}" -{%- endif %} - - # MAS Core - # ------------------------------------------------------------------------- - - name: mas_instance_id - value: "{{ mas_instance_id }}" - - # MCPI Add-On - # ------------------------------------------------------------------------- - - name: mcpi_channel - value: "{{ mcpi_channel }}" -{%- if routing_mode is defined and routing_mode != "" %} - - name: routing_mode - value: "{{ routing_mode }}" -{%- endif %} -{%- if manual_route_mgmt is defined and manual_route_mgmt != "" %} - - name: manual_route_mgmt - value: "{{ manual_route_mgmt }}" -{%- endif %} - - workspaces: - # The generated configuration files - # ------------------------------------------------------------------------- - - name: shared-configs - persistentVolumeClaim: - claimName: config-pvc - - # User-provided configurations - # ------------------------------------------------------------------------- - - name: shared-additional-configs - secret: - secretName: pipeline-additional-configs - - # SLS entitlement - # ------------------------------------------------------------------------- - - name: shared-entitlement - secret: - secretName: pipeline-sls-entitlement - - # PodTemplates configurations - # ------------------------------------------------------------------------- - - name: shared-pod-templates - secret: - secretName: pipeline-pod-templates - - # Certificates configurations - # ------------------------------------------------------------------------- - - name: shared-certificates - secret: - secretName: pipeline-certificates diff --git a/test/src/test_tekton_mcpi.py b/test/src/test_tekton_mcpi.py deleted file mode 100644 index 1a44e718..00000000 --- a/test/src/test_tekton_mcpi.py +++ /dev/null @@ -1,219 +0,0 @@ -# ***************************************************************************** -# Copyright (c) 2026 IBM Corporation and other Contributors. -# -# All rights reserved. This program and the accompanying materials -# are made available under the terms of the Eclipse Public License v1.0 -# which accompanies this distribution, and is available at -# http://www.eclipse.org/legal/epl-v10.html -# -# ***************************************************************************** - -"""Tests for MCPI Tekton pipeline support functions. - -This module covers prepareMcpiPipelinesNamespace() and launchMcpiInstallPipeline() -added to mas.devops.tekton. -""" - -from unittest.mock import MagicMock, patch - - -class TestPrepareMcpiPipelinesNamespace: - """Test suite for tekton.prepareMcpiPipelinesNamespace().""" - - @patch("mas.devops.tekton.getStorageClassVolumeBindingMode") - @patch("mas.devops.tekton.applyResource") - def test_creates_namespace_rbac_and_pvc(self, mockApply, mockVolumeBindingMode): - """Test that RBAC ClusterRoleBinding and PVC are created in the correct namespace. - - GIVEN a valid instanceId, storageClass, and accessMode - WHEN prepareMcpiPipelinesNamespace() is called - THEN applyResource is called for ClusterRoleBinding and PVC in mcpi--pipelines. - """ - from mas.devops.tekton import prepareMcpiPipelinesNamespace - - mockVolumeBindingMode.return_value = "WaitForFirstConsumer" - mockDynClient = MagicMock() - mockPvcAPI = MagicMock() - mockPvcAPI.get.side_effect = Exception("not found") - mockDynClient.resources.get.return_value = mockPvcAPI - - prepareMcpiPipelinesNamespace( - dynClient=mockDynClient, - instanceId="test1", - storageClass="ocs-storagecluster-cephfs", - accessMode="ReadWriteMany", - ) - - assert mockApply.call_count == 2 - # First call: ClusterRoleBinding - crbCall = mockApply.call_args_list[0] - assert crbCall.kwargs["kind"] == "ClusterRoleBinding" - assert crbCall.kwargs["namespace"] == "mcpi-test1-pipelines" - # Second call: PVC - pvcCall = mockApply.call_args_list[1] - assert pvcCall.kwargs["kind"] == "PersistentVolumeClaim" - assert pvcCall.kwargs["namespace"] == "mcpi-test1-pipelines" - - @patch("mas.devops.tekton.getStorageClassVolumeBindingMode") - @patch("mas.devops.tekton.applyResource") - def test_skips_rbac_when_configure_rbac_false(self, mockApply, mockVolumeBindingMode): - """Test that RBAC is not created when configureRBAC=False. - - GIVEN configureRBAC=False - WHEN prepareMcpiPipelinesNamespace() is called - THEN only PVC apply is called (not ClusterRoleBinding). - """ - from mas.devops.tekton import prepareMcpiPipelinesNamespace - - mockVolumeBindingMode.return_value = "WaitForFirstConsumer" - mockDynClient = MagicMock() - mockPvcAPI = MagicMock() - mockPvcAPI.get.side_effect = Exception("not found") - mockDynClient.resources.get.return_value = mockPvcAPI - - prepareMcpiPipelinesNamespace( - dynClient=mockDynClient, - instanceId="test1", - storageClass="ocs-storagecluster-cephfs", - accessMode="ReadWriteMany", - configureRBAC=False, - ) - - assert mockApply.call_count == 1 - pvcCall = mockApply.call_args_list[0] - assert pvcCall.kwargs["kind"] == "PersistentVolumeClaim" - - @patch("mas.devops.tekton.getStorageClassVolumeBindingMode") - @patch("mas.devops.tekton.applyResource") - def test_rbac_template_uses_mcpi_instance_id(self, mockApply, mockVolumeBindingMode): - """Test that the ClusterRoleBinding body references the mcpi namespace and instance ID. - - GIVEN instanceId='myinst' - WHEN prepareMcpiPipelinesNamespace() is called - THEN the ClusterRoleBinding body names use 'mcpi-myinst' prefix. - """ - from mas.devops.tekton import prepareMcpiPipelinesNamespace - - mockVolumeBindingMode.return_value = "WaitForFirstConsumer" - mockDynClient = MagicMock() - mockPvcAPI = MagicMock() - mockPvcAPI.get.side_effect = Exception("not found") - mockDynClient.resources.get.return_value = mockPvcAPI - - prepareMcpiPipelinesNamespace( - dynClient=mockDynClient, - instanceId="myinst", - storageClass="gp2", - accessMode="ReadWriteOnce", - ) - - crbBody = mockApply.call_args_list[0].kwargs["body"] - assert crbBody["metadata"]["name"] == "mcpi-pipeline-myinst" - subjects = crbBody["subjects"] - assert subjects[0]["namespace"] == "mcpi-myinst-pipelines" - - @patch("mas.devops.tekton.getStorageClassVolumeBindingMode") - @patch("mas.devops.tekton.applyResource") - def test_pvc_template_uses_mcpi_namespace(self, mockApply, mockVolumeBindingMode): - """Test that the PVC body is created in the mcpi namespace with the given storage class. - - GIVEN instanceId='inst2' and storageClass='thin' - WHEN prepareMcpiPipelinesNamespace() is called - THEN the PVC body has namespace mcpi-inst2-pipelines and storageClassName thin. - """ - from mas.devops.tekton import prepareMcpiPipelinesNamespace - - mockVolumeBindingMode.return_value = "WaitForFirstConsumer" - mockDynClient = MagicMock() - mockPvcAPI = MagicMock() - mockPvcAPI.get.side_effect = Exception("not found") - mockDynClient.resources.get.return_value = mockPvcAPI - - prepareMcpiPipelinesNamespace( - dynClient=mockDynClient, - instanceId="inst2", - storageClass="thin", - accessMode="ReadWriteOnce", - ) - - pvcBody = mockApply.call_args_list[1].kwargs["body"] - assert pvcBody["metadata"]["namespace"] == "mcpi-inst2-pipelines" - assert pvcBody["spec"]["storageClassName"] == "thin" - assert pvcBody["spec"]["accessModes"] == ["ReadWriteOnce"] - - -class TestLaunchMcpiInstallPipeline: - """Test suite for tekton.launchMcpiInstallPipeline().""" - - @patch("mas.devops.tekton.getConsoleURL") - @patch("mas.devops.tekton.launchPipelineRun") - def test_launches_in_mcpi_namespace(self, mockLaunchPipelineRun, mockGetConsoleURL): - """Test that the pipeline run is launched in the mcpi--pipelines namespace. - - GIVEN params with mas_instance_id='inst1' - WHEN launchMcpiInstallPipeline() is called - THEN launchPipelineRun is called with namespace=mcpi-inst1-pipelines. - """ - from mas.devops.tekton import launchMcpiInstallPipeline - - mockLaunchPipelineRun.return_value = "260101-1200" - mockGetConsoleURL.return_value = "https://console.example.com" - mockDynClient = MagicMock() - - launchMcpiInstallPipeline( - dynClient=mockDynClient, - params={"mas_instance_id": "inst1", "mcpi_channel": "v9.2"}, - ) - - mockLaunchPipelineRun.assert_called_once_with( - mockDynClient, - "mcpi-inst1-pipelines", - "pipelinerun-mcpi-install", - {"mas_instance_id": "inst1", "mcpi_channel": "v9.2"}, - ) - - @patch("mas.devops.tekton.getConsoleURL") - @patch("mas.devops.tekton.launchPipelineRun") - def test_returns_console_url(self, mockLaunchPipelineRun, mockGetConsoleURL): - """Test that the returned URL points to the correct PipelineRun in the OCP console. - - GIVEN params with mas_instance_id='inst1' and a known timestamp - WHEN launchMcpiInstallPipeline() is called - THEN the returned URL contains the instance ID, namespace, and timestamp. - """ - from mas.devops.tekton import launchMcpiInstallPipeline - - mockLaunchPipelineRun.return_value = "260101-1200" - mockGetConsoleURL.return_value = "https://console.example.com" - mockDynClient = MagicMock() - - url = launchMcpiInstallPipeline( - dynClient=mockDynClient, - params={"mas_instance_id": "inst1", "mcpi_channel": "v9.2"}, - ) - - assert url == ("https://console.example.com/k8s/ns/mcpi-inst1-pipelines" "/tekton.dev~v1beta1~PipelineRun/inst1-install-260101-1200") - - @patch("mas.devops.tekton.getConsoleURL") - @patch("mas.devops.tekton.launchPipelineRun") - def test_uses_pipelinerun_mcpi_install_template(self, mockLaunchPipelineRun, mockGetConsoleURL): - """Test that the correct pipelinerun template name is used. - - GIVEN any valid params - WHEN launchMcpiInstallPipeline() is called - THEN launchPipelineRun is called with templateName='pipelinerun-mcpi-install'. - """ - from mas.devops.tekton import launchMcpiInstallPipeline - - mockLaunchPipelineRun.return_value = "260101-0900" - mockGetConsoleURL.return_value = "https://console.example.com" - mockDynClient = MagicMock() - - launchMcpiInstallPipeline( - dynClient=mockDynClient, - params={"mas_instance_id": "dev1", "mcpi_channel": "v9.3"}, - ) - - _, args, _ = mockLaunchPipelineRun.mock_calls[0] - templateName = args[2] - assert templateName == "pipelinerun-mcpi-install"