diff --git a/src/mas/devops/tekton.py b/src/mas/devops/tekton.py index cb91ed80..bc2f2068 100644 --- a/src/mas/devops/tekton.py +++ b/src/mas/devops/tekton.py @@ -843,6 +843,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. @@ -1342,6 +1419,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..1a44e718 --- /dev/null +++ b/test/src/test_tekton_mcpi.py @@ -0,0 +1,219 @@ +# ***************************************************************************** +# 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"