diff --git a/python/src/mas/cli/install/app.py b/python/src/mas/cli/install/app.py index bca00fb4523..6b9f5a8d6c2 100644 --- a/python/src/mas/cli/install/app.py +++ b/python/src/mas/cli/install/app.py @@ -50,7 +50,8 @@ createNamespace, getStorageClasses, getClusterVersion, - isClusterVersionInRange + isClusterVersionInRange, + configureIngressForPathBasedRouting ) from mas.devops.mas import ( getCurrentCatalog, @@ -576,9 +577,9 @@ def configMAS(self): self.setParam("sls_namespace", f"mas-{self.getParam('mas_instance_id')}-sls") self.configOperationMode() - self.configRoutingMode() self.configCATrust() self.configDNSAndCerts() + self.configRoutingMode() self.configSSOProperties() self.configSpecialCharacters() self.configReportAdoptionMetricsFlag() @@ -618,6 +619,25 @@ def configOperationMode(self): self.setParam("aiservice_rhoai_model_deployment_type", "serverless") self.setParam("rhoai", "false") + def _getMasDomainForDisplay(self): + masDomain = self.getParam("mas_domain") + if not masDomain: + try: + ingressAPI = self.dynamicClient.resources.get( + api_version="config.openshift.io/v1", + kind="Ingress" + ) + ingressConfig = ingressAPI.get(name="cluster") + masDomain = ingressConfig.spec.get('domain', 'yourdomain.com') + except Exception: + masDomain = 'yourdomain.com' + + masInstanceId = self.getParam("mas_instance_id") + if masInstanceId: + masDomain = f"{masInstanceId}.{masDomain}" + + return masDomain + def _promptForIngressController(self): try: ingressControllerAPI = self.dynamicClient.resources.get( @@ -670,11 +690,23 @@ def configRoutingMode(self): if self.showAdvancedOptions and isVersionEqualOrAfter('9.2.0', self.getParam("mas_channel")) and self.getParam("mas_channel") != '9.2.x-feature': self.printH1("Configure Routing Mode") + masDomain = self._getMasDomainForDisplay() + self.printDescription([ - "Maximo Application Suite can be installed so it can be accessed with single domain URLs (path mode) or multi-domain URLs (subdomain mode):", + "Maximo Application Suite can be configured in one of two ways:", + "", + " 1. Single domain with path-based routing across the suite", + f" Example: https://{masDomain}/admin", + "", + " 2. Multi domain with subdomain-based routing across the suite", + f" Example: https://admin.{masDomain}", "", - " 1. Path (single domain)", - " 2. Subdomain (multi domain)" + "Path-based routing requires the IngressController to have the routeAdmission policy", + "set to 'InterNamespaceAllowed'. This allows routes to claim the same hostname across", + "different namespaces, which is necessary for path-based routing to function correctly.", + "", + "For more information refer to:", + "https://docs.redhat.com/en/documentation/openshift_container_platform/4.20/html/ingress_and_load_balancing/routes#nw-route-admission-policy_configuring-routes" ]) routingModeInt = self.promptForInt("Routing Mode", default=1, min=1, max=2) @@ -682,43 +714,58 @@ def configRoutingMode(self): selectedMode = routingModeOptions[routingModeInt - 1] if selectedMode == "path": - selectedController = self._promptForIngressController() - self.setParam("mas_ingress_controller_name", selectedController) - - # Check if selected IngressController is configured for path-based routing - # Note: In interactive mode, we only check configuration, not existence, - # since the user selects from a list of existing controllers - _, isConfigured = self._checkIngressControllerForPathRouting(selectedController) - - if isConfigured: - self.setParam("mas_routing_mode", "path") - self.printDescription([f" IngressController '{selectedController}' is configured for path-based routing."]) - else: + canConfigure = self._checkIngressControllerPermissions() + if not canConfigure: self.printDescription([ "", - f"The IngressController '{selectedController}' requires configuration for path-based routing.", + "Your cluster ingress currently does not support path-based routing", "", - "The following setting needs to be applied:", + "If you wish to configure MAS with path-based routing, contact your OpenShift", + "administrator to apply the following configuration:", "", " spec:", " routeAdmission:", " namespaceOwnership: InterNamespaceAllowed", "", - "Would you like to configure it during installation?" + "MAS will be configured to use subdomain-based routing." ]) + self.setParam("mas_routing_mode", "subdomain") + self.setParam("mas_ingress_controller_name", "") + else: + selectedController = self._promptForIngressController() + self.setParam("mas_ingress_controller_name", selectedController) + + # Check if selected IngressController is configured for path-based routing + _, isConfigured = self._checkIngressControllerForPathRouting(selectedController) - if self.yesOrNo("Configure IngressController for path-based routing"): + if isConfigured: self.setParam("mas_routing_mode", "path") - self.setParam("mas_configure_ingress", "true") - self.printDescription([f"IngressController '{selectedController}' will be configured during installation."]) + self.printDescription([f"IngressController '{selectedController}' is configured for path-based routing."]) else: self.printDescription([ "", - "Path-based routing requires IngressController configuration.", - "Falling back to subdomain mode." + "Your cluster ingress currently does not support path-based routing", + "", + "The following setting needs to be applied to the IngressController:", + "", + " spec:", + " routeAdmission:", + " namespaceOwnership: InterNamespaceAllowed", + "" ]) - self.setParam("mas_routing_mode", "subdomain") - self.setParam("mas_ingress_controller_name", "") + + if self.yesOrNo("Configure ingress namespace ownership policy to enable path-based routing for MAS"): + self.setParam("mas_routing_mode", "path") + self.setParam("mas_configure_ingress", "true") + self.printDescription([f"IngressController '{selectedController}' will be configured before MAS installation begins."]) + else: + self.printDescription([ + "", + "Path-based routing requires IngressController configuration.", + "MAS will be configured to use subdomain-based routing." + ]) + self.setParam("mas_routing_mode", "subdomain") + self.setParam("mas_ingress_controller_name", "") else: self.setParam("mas_routing_mode", "subdomain") @@ -758,6 +805,26 @@ def _checkIngressControllerForPathRouting(self, controllerName='default'): logger.warning(f"Failed to check IngressController '{controllerName}' configuration: {e}") return (False, False) + def _checkIngressControllerPermissions(self, controllerName='default'): + try: + ingressControllerAPI = self.dynamicClient.resources.get( + api_version="operator.openshift.io/v1", + kind="IngressController" + ) + + # Attempt to get the IngressController to verify permissions + ingressControllerAPI.get( + name=controllerName, + namespace="openshift-ingress-operator" + ) + + logger.info(f"User has permissions to access IngressController '{controllerName}'") + return True + + except Exception as e: + logger.warning(f"User may not have permissions to configure IngressController '{controllerName}': {e}") + return False + @logMethodCall def configAnnotations(self): if self.operationalMode == 2: @@ -1808,6 +1875,29 @@ def install(self, argv): self.setParam("mas_ingress_controller_name", ingressControllerName) + # Check permissions BEFORE attempting to check the IngressController + canConfigure = self._checkIngressControllerPermissions() + if not canConfigure: + + self.fatalError( + "\n".join([ + "IngressController Configuration Requires Administrator Permissions", + "========================================================================", + "You do not have sufficient permissions to check or configure the", + f"IngressController '{ingressControllerName}'.", + "", + "If you wish to configure MAS with path-based routing, contact your OpenShift", + "administrator to apply the following configuration:", + "", + " spec:", + " routeAdmission:", + " namespaceOwnership: InterNamespaceAllowed", + "", + "Alternatively, you can use subdomain routing mode:", + " mas install --routing subdomain ..." + ]) + ) + exists, isConfigured = self._checkIngressControllerForPathRouting(ingressControllerName) if not exists: @@ -1836,7 +1926,7 @@ def install(self, argv): ) elif not isConfigured: if hasattr(self.args, 'mas_configure_ingress') and self.args.mas_configure_ingress: - logger.info(f"IngressController '{ingressControllerName}' will be configured for path-based routing during installation pipeline") + logger.info(f"IngressController '{ingressControllerName}' will be configured for path-based routing before MAS installation") self.setParam("mas_configure_ingress", "true") else: self.fatalError( @@ -1896,6 +1986,15 @@ def install(self, argv): h.stop_and_persist(symbol=self.successIcon, text="OpenShift Pipelines Operator installation failed") self.fatalError("Installation failed") + if self.getParam("mas_routing_mode") == "path" and self.getParam("mas_configure_ingress") == "true": + with Halo(text='Configuring cluster for path-based routing', spinner=self.spinner) as h: + ingressControllerName = self.getParam("mas_ingress_controller_name") if self.getParam("mas_ingress_controller_name") else "default" + if configureIngressForPathBasedRouting(self.dynamicClient, ingressControllerName): + h.stop_and_persist(symbol=self.successIcon, text="Cluster configured for path-based routing") + else: + h.stop_and_persist(symbol=self.failureIcon, text="Failed to configure cluster for path-based routing") + self.fatalError("Installation failed - unable to configure IngressController for path-based routing") + with Halo(text=f'Preparing namespace ({pipelinesNamespace})', spinner=self.spinner) as h: createNamespace(self.dynamicClient, pipelinesNamespace) preparePipelinesNamespace( diff --git a/python/test/install/test_dev_mode.py b/python/test/install/test_dev_mode.py index e084d11213a..5810596651c 100644 --- a/python/test/install/test_dev_mode.py +++ b/python/test/install/test_dev_mode.py @@ -207,6 +207,143 @@ def test_install_master_dev_mode_existing_catalog(tmpdir): run_install_test(tmpdir, config) +def test_install_master_dev_mode_with_path_routing(tmpdir): + """Test interactive installation with 9.2.0 channel including path-based routing mode configuration. + + This test verifies the complete routing mode flow including IngressController configuration: + - Mock IngressController is initially NOT configured (namespaceOwnership='Strict') + - User selects path-based routing mode + - CLI detects IngressController needs configuration + - User agrees to configure it + - IngressController will be patched during installation + + Flow: + 1. User selects path-based routing mode + 2. CLI checks permissions (mocked to succeed) + 3. CLI auto-selects the only available IngressController ('default') + 4. CLI detects IngressController is NOT configured for path-based routing + 5. User is prompted to configure it + 6. User agrees (responds 'y') + 7. mas_configure_ingress parameter is set to 'true' + 8. IngressController will be patched during installation + """ + + # Define prompt handlers with expected patterns and responses + prompt_handlers = { + # 1. Cluster connection + '.*Proceed with this cluster?.*': lambda msg: 'y', + # 2. Install flavour (advanced options) - MUST be 'y' to enable routing mode + '.*Show advanced installation options.*': lambda msg: 'y', + # 3. Catalog selection + '.*Select catalog.*': lambda msg: "v9-master-amd64", + '.*Select channel.*': lambda msg: '9.2.x-dev', # Use 9.2.x-dev channel + # 4. Routing Mode Configuration - Select path-based routing + '.*Routing Mode.*': lambda msg: '1', # Select path-based routing + # Note: IngressController selection prompt does NOT appear because there's only one controller + # 5. Configure IngressController for path-based routing + '.*Configure ingress namespace ownership.*': lambda msg: 'y', # Agree to configure + # 5. Storage classes + ".*Use the auto-detected storage classes.*": lambda msg: 'y', + # 6. SLS configuration + '.*SLS Mode.*': lambda msg: '1', # SLS Mode prompt (appears with advanced options) + '.*SLS channel.*': lambda msg: '1.x-stable', + '.*License file.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', + # 7. DRO configuration + '.*DRO.*Namespace.*': lambda msg: '', # DRO Namespace prompt (appears with advanced options) + ".*Contact e-mail address.*": lambda msg: 'maximo@ibm.com', + ".*Contact first name.*": lambda msg: 'Test', + ".*Contact last name.*": lambda msg: 'Test', + # 8. ICR & Artifactory credentials + ".*IBM entitlement key.*": lambda msg: 'testEntitlementKey', + ".*Artifactory username.*": lambda msg: 'testUsername', + ".*Artifactory token.*": lambda msg: 'testToken', + # 9. MAS Instance configuration + '.*Instance ID.*': lambda msg: 'testinst', + '.*Workspace ID.*': lambda msg: 'testws', + '.*Workspace.*name.*': lambda msg: 'Test Workspace', + # 10. Operational mode + '.*Operational Mode.*': lambda msg: '1', + # 11. Certificate Authority Trust + '.*Trust default CAs.*': lambda msg: 'y', + # 12. Cluster ingress certificate secret name + '.*Cluster ingress certificate secret name.*': lambda msg: '', # Leave empty for auto-detection + # 13. Domain & certificate management + '.*Configure domain.*certificate management.*': lambda msg: 'n', # Skip domain/cert config for simplicity + # 14. SSO properties + '.*Configure SSO properties.*': lambda msg: 'n', # Skip SSO config + # 15. Special characters for user IDs + '.*Allow special characters for user IDs and usernames.*': lambda msg: 'n', + # 16. Guided Tour + '.*Enable Guided Tour.*': lambda msg: 'y', + # 17. Feature adoption metrics + '.*Enable feature adoption metrics.*': lambda msg: 'y', + # 18. Deployment progression metrics + '.*Enable deployment progression metrics.*': lambda msg: 'y', + # 19. Usability metrics + '.*Enable usability metrics.*': lambda msg: 'y', + # 20. Application selection + '.*Install IoT.*': lambda msg: 'y', + '.*Custom channel for iot.*': lambda msg: '9.2.x-dev', + '.*Install Monitor.*': lambda msg: 'n', + '.*Install Manage.*': lambda msg: 'y', + '.*Custom channel for manage.*': lambda msg: '9.2.x-dev', + '.*Select a server bundle configuration.*': lambda msg: '1', # Select dev server bundle + '.*Customize database settings.*': lambda msg: 'n', # Skip database customization + '.*Create demo data.*': lambda msg: 'n', # Skip demo data + '.*Manage server timezone.*': lambda msg: 'GMT', # Use GMT timezone + '.*Base language.*': lambda msg: 'EN', # Use English as base language + '.*Secondary language.*': lambda msg: '', # No secondary language + '.*Select components to enable.*': lambda msg: 'n', + '.*Include customization archive.*': lambda msg: 'n', + '.*Install Predict.*': lambda msg: 'n', + '.*Install Assist.*': lambda msg: 'n', + '.*Install Optimizer.*': lambda msg: 'n', + '.*Install Visual Inspection.*': lambda msg: 'n', + '.*Install.*Real Estate and Facilities.*': lambda msg: 'n', + '.*Install AI Service.*': lambda msg: 'n', + # 21. MongoDB configuration + '.*MongoDb namespace.*': lambda msg: 'mongoce', # Use default MongoDB namespace + '.*Create MongoDb cluster.*': lambda msg: 'y', + # 22. Db2 configuration + '.*Create system Db2 instance.*': lambda msg: 'y', + '.*Re-use System Db2 instance for Manage application.*': lambda msg: 'n', + '.*Create Manage dedicated Db2 instance.*': lambda msg: 'y', + '.*Select the Manage dedicated DB2 instance type.*': lambda msg: '1', # Select default DB2 type + '.*Install namespace.*': lambda msg: 'db2u', # DB2 install namespace + '.*Configure node affinity.*': lambda msg: 'n', # Skip node affinity configuration + '.*Configure node tolerations.*': lambda msg: 'n', # Skip node tolerations configuration + '.*Customize CPU and memory request/limit.*': lambda msg: 'n', # Skip CPU/memory customization + '.*Customize storage capacity.*': lambda msg: 'n', # Skip storage capacity customization + '.*Select Kafka provider.*': lambda msg: '1', # Select default Kafka provider + '.*Strimzi namespace.*': lambda msg: 'strimzi', # Strimzi namespace + '.*Use pod templates.*': lambda msg: 'n', # Skip pod templates + # 23. Kafka configuration + '.*Create system Kafka instance.*': lambda msg: 'y', + '.*Kafka version.*': lambda msg: '3.8.0', + # 24. Final confirmation + '.*Use additional configurations.*': lambda msg: 'n', + ".*Proceed with these settings.*": lambda msg: 'y', + } + + # Create test configuration with --dev-mode flag and 9.2.x-dev channel + config = InstallTestConfig( + prompt_handlers=prompt_handlers, + current_catalog={'catalogId': "v9-master-amd64"}, + architecture='amd64', + is_sno=False, + is_airgap=False, + storage_class_name='nfs-client', + storage_provider='nfs', + storage_provider_name='NFS Client', + ocp_version='4.18.0', + timeout_seconds=30, + argv=['--dev-mode'] + ) + + # Run the test + run_install_test(tmpdir, config) + + def test_install_master_dev_mode_non_interactive(tmpdir): """Test non-interactive installation when no catalog is installed with --dev-mode flag.""" @@ -285,4 +422,91 @@ def test_install_master_dev_mode_non_interactive(tmpdir): # Run the test run_install_test(tmpdir, config) + +def test_install_master_dev_mode_non_interactive_with_path_routing(tmpdir): + """Test non-interactive installation with path-based routing mode using CLI flags. + + This test verifies the complete non-interactive flow with path-based routing: + - Uses --routing flag to specify path mode + - Uses --ingress-controller-name to specify the controller + - Uses --configure-ingress to enable IngressController patching + """ + + # Define prompt handlers - should be empty for non-interactive mode + prompt_handlers = {} + + # Create test configuration with routing flags + config = InstallTestConfig( + prompt_handlers=prompt_handlers, + current_catalog=None, # No catalog installed + architecture='amd64', + is_sno=False, + is_airgap=False, + storage_class_name='nfs-client', + storage_provider='nfs', + storage_provider_name='NFS Client', + ocp_version='4.18.0', + timeout_seconds=30, + argv=[ + "--dev-mode", + "--artifactory-username", "ARTIFACTORY_USERNAME", + "--artifactory-token", "ARTIFACTORY_TOKEN", + "--mas-catalog-version", "v9-master-amd64", + "--mas-instance-id", "fvtcore", + "--mas-workspace-id", "masdev", + "--mas-workspace-name", "MAS Development", + "--superuser-username", "MAS_SUPERUSER_USERNAME", + "--superuser-password", "MAS_SUPERUSER_PASSWORD", + "--mas-channel", "9.2.x-dev", + "--routing", "path", + "--ingress-controller-name", "default", + "--configure-ingress", + "--iot-channel", "9.2.x-dev", + "--db2-system", "--kafka-provider", "strimzi", + "--monitor-channel", "9.2.x-dev", + "--manage-channel", "9.2.x-dev", + "--manage-components", "", + "--db2-manage", "--manage-jdbc", "workspace-application", + "--manage-customization-archive-name", "fvtcustomarchive", + "--manage-customization-archive-url", "https://ibm.com/manage-custom-archive-latest.zip", + "--manage-customization-archive-username", "FVT_ARTIFACTORY_USERNAME", + "--manage-customization-archive-password", "FVT_ARTIFACTORY_TOKEN", + "--optimizer-channel", "", + "--predict-channel", "", + "--visualinspection-channel", "", + "--facilities-channel", "", + "--cos", "ibm", + "--cos-resourcegroup", "fvt-layer3", + "--cos-apikey", "IBMCLOUD_APIKEY", + "--cos-instance-name", "Object Storage for MAS - fvtcore", + "--cos-bucket-name", "fvtcore-masdev-bucket-20260209-0209", + "--db2-channel", "rotate", + "--additional-configs", f"{tmpdir}", + "--storage-class-rwx", "ibmc-file-gold-gid", + "--storage-class-rwo", "ibmc-block-gold", + "--storage-pipeline", "ibmc-file-gold-gid", + "--storage-accessmode", "ReadWriteMany", + "--ibm-entitlement-key", "IBM_ENTITLEMENT_KEY", + "--license-file", f"{tmpdir}/authorized_entitlement.lic", + "--uds-email", "iotf@uk.ibm.com", + "--uds-firstname", "First", + "--uds-lastname", "Last", + "--sls-namespace", "sls-fvtcore", + "--sls-channel", "3.x-dev", + "--approval-core", "100:300:true", + "--approval-assist", "100:300:true", + "--approval-iot", "100:300:true", + "--approval-manage", "100:600:true", + "--approval-monitor", "100:300:true", + "--approval-optimizer", "100:300:true", + "--approval-predict", "100:300:true", + "--approval-visualinspection", "100:300:true", + "--approval-facilities", "100:300:true", + "--accept-license", + "--no-confirm", + ] + ) + # Run the test + run_install_test(tmpdir, config) + # Made with Bob diff --git a/python/test/install/test_routing_mode.py b/python/test/install/test_routing_mode.py new file mode 100644 index 00000000000..05997e503cd --- /dev/null +++ b/python/test/install/test_routing_mode.py @@ -0,0 +1,979 @@ +#!/usr/bin/env python +# ***************************************************************************** +# 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 +# +# ***************************************************************************** + +""" +Comprehensive test suite for routing mode functionality in MAS CLI. +Tests cover interactive and non-interactive modes for both path-based and subdomain routing. +""" + +from mas.cli.install.app import InstallApp +from mas.cli.install.argParser import installArgParser +import sys +import os +import pytest +from unittest.mock import MagicMock, patch +from kubernetes.client.rest import ApiException + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + +# ============================================================================= +# Test Helper Functions +# ============================================================================= +def create_mock_app(): + """Create a mock InstallApp with a mocked dynamicClient.""" + app = MagicMock(spec=InstallApp) + app.dynamicClient = MagicMock() + app.showAdvancedOptions = True + app.isInteractiveMode = False + app.params = {} + app.setParam = lambda key, value: app.params.__setitem__(key, value) + app.getParam = lambda key: app.params.get(key, "") + + # Mock UI methods directly on the app object (not on the class) + # This is necessary because MagicMock attributes shadow class methods + app.promptForInt = MagicMock(return_value=1) + app.yesOrNo = MagicMock(return_value=True) + app.printDescription = MagicMock() + app.printH1 = MagicMock() + app.promptForString = MagicMock(return_value="") + + # Add the actual methods we want to test + app._checkIngressControllerPermissions = InstallApp._checkIngressControllerPermissions.__get__(app, InstallApp) + app._checkIngressControllerForPathRouting = InstallApp._checkIngressControllerForPathRouting.__get__(app, InstallApp) + app._promptForIngressController = InstallApp._promptForIngressController.__get__(app, InstallApp) + app._getMasDomainForDisplay = InstallApp._getMasDomainForDisplay.__get__(app, InstallApp) + app.configRoutingMode = InstallApp.configRoutingMode.__get__(app, InstallApp) + + return app + + +def create_ingress_controller_mock(name="default", domain="apps.cluster.example.com", + namespace_ownership="InterNamespaceAllowed", available=True): + """Create a mock IngressController object that supports both dict and attribute access.""" + controller = MagicMock() + controller.metadata.name = name + + if available: + controller.status.domain = domain + controller.status.conditions = [ + MagicMock(type='Available', status='True') + ] + + # Create proper nested structure for attribute access + controller.spec = MagicMock() + controller.spec.routeAdmission = MagicMock() + controller.spec.routeAdmission.namespaceOwnership = namespace_ownership + + # Support dict-style access for _checkIngressControllerForPathRouting + # The method calls: ingressController.get('spec', {}) + # Then: spec.get('routeAdmission', {}) + # Then: routeAdmission.get('namespaceOwnership', '') + def mock_get(key, default=None): + if key == 'spec': + spec_dict = { + 'routeAdmission': { + 'namespaceOwnership': namespace_ownership + } + } + # Return a dict-like object that supports .get() + return type('obj', (object,), { + 'get': lambda self, k, d=None: spec_dict.get(k, d) + })() + return default + + controller.get = mock_get + + return controller + + +# ============================================================================= +# Interactive Mode - Path Routing Tests +# ============================================================================= +class TestInteractivePathRouting: + """Test suite for interactive mode path-based routing scenarios.""" + + def test_path_routing_single_controller_auto_selected(self): + """Test that single available IngressController is automatically selected.""" + app = create_mock_app() + app.isInteractiveMode = True + + # Mock single IngressController + controller = create_ingress_controller_mock() + ingress_api = MagicMock() + ingress_api.get.return_value = MagicMock(items=[controller]) + app.dynamicClient.resources.get.return_value = ingress_api + + result = app._promptForIngressController() + + assert result == "default" + + def test_path_routing_multiple_controllers_user_selection(self): + """Test user selection when multiple IngressControllers are available.""" + app = create_mock_app() + app.isInteractiveMode = True + + # Mock multiple IngressControllers + controller1 = create_ingress_controller_mock("default", "apps.cluster1.com") + controller2 = create_ingress_controller_mock("custom", "apps.cluster2.com") + + ingress_api = MagicMock() + ingress_api.get.return_value = MagicMock(items=[controller1, controller2]) + app.dynamicClient.resources.get.return_value = ingress_api + + # Set promptForInt to return 2 (select second controller) + app.promptForInt.return_value = 2 + result = app._promptForIngressController() + + assert result == "custom" + + def test_path_routing_no_controllers_defaults_to_default(self): + """Test fallback to 'default' when no IngressControllers are found.""" + app = create_mock_app() + app.isInteractiveMode = True + + ingress_api = MagicMock() + ingress_api.get.return_value = MagicMock(items=[]) + app.dynamicClient.resources.get.return_value = ingress_api + + result = app._promptForIngressController() + + assert result == "default" + + def test_interactive_path_routing_complete_flow_with_configuration(self): + """Test complete interactive flow for path routing with IngressController configuration.""" + app = create_mock_app() + app.isInteractiveMode = True + app.showAdvancedOptions = True + app.setParam("mas_channel", "9.2.0") + app.setParam("mas_instance_id", "test-inst") + + # Mock IngressController API - not configured + controller = create_ingress_controller_mock(namespace_ownership="Strict") + ingress_api = MagicMock() + ingress_api.get.side_effect = [ + controller, # For permission check + MagicMock(items=[controller]), # For listing controllers + controller # For configuration check + ] + app.dynamicClient.resources.get.return_value = ingress_api + + # Mock Ingress config for domain display + ingress_config_api = MagicMock() + ingress_config = MagicMock() + ingress_config.spec.get.return_value = "apps.cluster.example.com" + ingress_config_api.get.return_value = ingress_config + + with patch.object(app.dynamicClient.resources, 'get') as mock_get: + def get_side_effect(api_version, kind): + if kind == "IngressController": + return ingress_api + elif kind == "Ingress": + return ingress_config_api + return MagicMock() + + mock_get.side_effect = get_side_effect + + # Mock isVersionEqualOrAfter to return True for version check + with patch('mas.cli.install.app.isVersionEqualOrAfter', return_value=True): + app.configRoutingMode() + + # Verify parameters are set correctly + assert app.getParam("mas_routing_mode") == "path" + assert app.getParam("mas_ingress_controller_name") == "default" + assert app.getParam("mas_configure_ingress") == "true" + + def test_interactive_path_routing_user_declines_falls_back_to_subdomain(self): + """Test fallback to subdomain when user declines to configure IngressController.""" + app = create_mock_app() + app.isInteractiveMode = True + app.showAdvancedOptions = True + app.setParam("mas_channel", "9.2.0") + app.setParam("mas_instance_id", "test-inst") + + # User declines to configure ingress + app.yesOrNo.return_value = False + + # Mock IngressController not configured + controller = create_ingress_controller_mock(namespace_ownership="Strict") + ingress_api = MagicMock() + ingress_api.get.side_effect = [ + controller, # For permission check + MagicMock(items=[controller]), # For listing controllers + controller # For configuration check + ] + app.dynamicClient.resources.get.return_value = ingress_api + + # Mock Ingress config + ingress_config_api = MagicMock() + ingress_config = MagicMock() + ingress_config.spec.get.return_value = "apps.cluster.example.com" + ingress_config_api.get.return_value = ingress_config + + with patch.object(app.dynamicClient.resources, 'get') as mock_get: + def get_side_effect(api_version, kind): + if kind == "IngressController": + return ingress_api + elif kind == "Ingress": + return ingress_config_api + return MagicMock() + + mock_get.side_effect = get_side_effect + + # Mock isVersionEqualOrAfter to return True for version check + with patch('mas.cli.install.app.isVersionEqualOrAfter', return_value=True): + app.configRoutingMode() + + # Verify fallback to subdomain + assert app.getParam("mas_routing_mode") == "subdomain" + assert app.getParam("mas_ingress_controller_name") == "" + + def test_interactive_path_routing_patch_fails_gracefully(self): + """Test graceful failure when IngressController patch operation fails.""" + app = create_mock_app() + app.isInteractiveMode = True + app.showAdvancedOptions = True + app.setParam("mas_channel", "9.2.0") + app.setParam("mas_instance_id", "test-inst") + + # Mock IngressController not configured + controller = create_ingress_controller_mock(namespace_ownership="Strict") + ingress_api = MagicMock() + ingress_api.get.side_effect = [ + controller, # For permission check + MagicMock(items=[controller]), # For listing controllers + controller # For configuration check + ] + + # Mock Ingress config + ingress_config_api = MagicMock() + ingress_config = MagicMock() + ingress_config.spec.get.return_value = "apps.cluster.example.com" + ingress_config_api.get.return_value = ingress_config + + with patch.object(app.dynamicClient.resources, 'get') as mock_get: + def get_side_effect(api_version, kind): + if kind == "IngressController": + return ingress_api + elif kind == "Ingress": + return ingress_config_api + return MagicMock() + + mock_get.side_effect = get_side_effect + + with patch('mas.cli.install.app.isVersionEqualOrAfter', return_value=True): + # User chooses to configure, but we'll simulate patch failure later + app.yesOrNo.return_value = True + app.configRoutingMode() + + # Even if patch fails later, parameters should be set for the pipeline to attempt + assert app.getParam("mas_routing_mode") == "path" + assert app.getParam("mas_configure_ingress") == "true" + assert app.getParam("mas_ingress_controller_name") == "default" + + def test_interactive_path_routing_no_permissions_fails_gracefully(self): + """Test graceful failure when user lacks permissions to check IngressController.""" + app = create_mock_app() + app.isInteractiveMode = True + app.showAdvancedOptions = True + app.setParam("mas_channel", "9.2.0") + app.setParam("mas_instance_id", "test-inst") + + # Mock permission denied + ingress_api = MagicMock() + ingress_api.get.side_effect = ApiException(status=403, reason="Forbidden") + + # Mock Ingress config + ingress_config_api = MagicMock() + ingress_config = MagicMock() + ingress_config.spec.get.return_value = "apps.cluster.example.com" + ingress_config_api.get.return_value = ingress_config + + with patch.object(app.dynamicClient.resources, 'get') as mock_get: + def get_side_effect(api_version, kind): + if kind == "IngressController": + return ingress_api + elif kind == "Ingress": + return ingress_config_api + return MagicMock() + + mock_get.side_effect = get_side_effect + + with patch('mas.cli.install.app.isVersionEqualOrAfter', return_value=True): + app.configRoutingMode() + + # Should fall back to subdomain when permissions are denied + assert app.getParam("mas_routing_mode") == "subdomain" + assert app.getParam("mas_ingress_controller_name") == "" + + +# ============================================================================= +# Interactive Mode - Subdomain Routing Tests +# ============================================================================= +class TestInteractiveSubdomainRouting: + """Test suite for interactive mode subdomain routing scenarios.""" + + def test_interactive_subdomain_routing_selection(self): + """Test direct selection of subdomain routing mode.""" + app = create_mock_app() + app.isInteractiveMode = True + app.showAdvancedOptions = True + app.setParam("mas_channel", "9.2.0") + app.setParam("mas_instance_id", "test-inst") + + # User selects subdomain mode (option 2) + app.promptForInt.return_value = 2 + + # Mock Ingress config + ingress_config_api = MagicMock() + ingress_config = MagicMock() + ingress_config.spec.get.return_value = "apps.cluster.example.com" + ingress_config_api.get.return_value = ingress_config + app.dynamicClient.resources.get.return_value = ingress_config_api + + # Mock isVersionEqualOrAfter to return True for version check + with patch('mas.cli.install.app.isVersionEqualOrAfter', return_value=True): + app.configRoutingMode() + + # Verify subdomain mode is set + assert app.getParam("mas_routing_mode") == "subdomain" + assert app.getParam("mas_ingress_controller_name") == "" + assert app.getParam("mas_configure_ingress") == "" + + +# ============================================================================= +# Non-Interactive Mode - Path Routing Tests +# ============================================================================= +class TestNonInteractivePathRouting: + """Test suite for non-interactive mode path-based routing scenarios.""" + + def test_noninteractive_path_mode_with_default_controller_configured(self): + """Test non-interactive path mode with default controller already configured.""" + app = create_mock_app() + app.isInteractiveMode = False + app.setParam("mas_routing_mode", "path") + app.setParam("mas_ingress_controller_name", "default") + + # Mock controller already configured + controller = create_ingress_controller_mock() + ingress_api = MagicMock() + ingress_api.get.return_value = controller + app.dynamicClient.resources.get.return_value = ingress_api + + # Simulate validation logic + canConfigure = app._checkIngressControllerPermissions("default") + exists, isConfigured = app._checkIngressControllerForPathRouting("default") + + assert canConfigure is True + assert exists is True + assert isConfigured is True + assert app.getParam("mas_configure_ingress") == "" + + def test_noninteractive_path_mode_with_configure_flag_success(self): + """Test non-interactive path mode with --configure-ingress flag. + + This test validates that when CLI arguments are provided: + - --routing path + - --ingress-controller-name default + - --configure-ingress + + The system properly sets the corresponding parameters: + - mas_routing_mode = "path" + - mas_ingress_controller_name = "default" + - mas_configure_ingress = "true" + """ + # Simulate CLI arguments for non-interactive path mode with configure flag + argv = [ + "--mas-instance-id", "testinst", + "--mas-workspace-id", "testws", + "--mas-channel", "9.2.0", + "--routing", "path", + "--ingress-controller-name", "default", + "--configure-ingress", + "--accept-license", + "--no-confirm" + ] + + # Parse arguments + args = installArgParser.parse_args(args=argv) + + # Verify args are parsed correctly + assert args.mas_routing_mode == "path", "Routing mode should be 'path'" + assert args.mas_ingress_controller_name == "default", "Controller name should be 'default'" + assert args.mas_configure_ingress is True, "--configure-ingress flag should be True" + + # Create app and simulate parameter setting from args + app = create_mock_app() + app.isInteractiveMode = False + app.args = args + + # Simulate the parameter setting logic from app.py + app.setParam("mas_routing_mode", args.mas_routing_mode) + app.setParam("mas_ingress_controller_name", args.mas_ingress_controller_name) + + # Simulate lines 1809-1810 from app.py + if hasattr(args, 'mas_configure_ingress') and args.mas_configure_ingress: + app.setParam("mas_configure_ingress", "true") + + # Verify parameters are set correctly + assert app.getParam("mas_routing_mode") == "path", "mas_routing_mode parameter should be 'path'" + assert app.getParam("mas_ingress_controller_name") == "default", "mas_ingress_controller_name should be 'default'" + assert app.getParam("mas_configure_ingress") == "true", "mas_configure_ingress should be 'true'" + + # Mock controller not configured + controller = create_ingress_controller_mock(namespace_ownership="Strict") + ingress_api = MagicMock() + ingress_api.get.return_value = controller + app.dynamicClient.resources.get.return_value = ingress_api + + # Verify IngressController checks work correctly + canConfigure = app._checkIngressControllerPermissions("default") + exists, isConfigured = app._checkIngressControllerForPathRouting("default") + + assert canConfigure is True, "Should have permissions to configure" + assert exists is True, "Controller should exist" + assert isConfigured is False, "Controller should not be configured (Strict ownership)" + + def test_noninteractive_path_mode_custom_controller_name(self): + """Test non-interactive path mode with custom IngressController name.""" + # Simulate CLI arguments with custom controller + argv = [ + "--mas-instance-id", "testinst", + "--mas-workspace-id", "testws", + "--mas-channel", "9.2.0", + "--routing", "path", + "--ingress-controller-name", "custom-ingress", + "--accept-license", + "--no-confirm" + ] + + args = installArgParser.parse_args(args=argv) + assert args.mas_routing_mode == "path" + assert args.mas_ingress_controller_name == "custom-ingress" + + app = create_mock_app() + app.isInteractiveMode = False + app.args = args + app.setParam("mas_routing_mode", args.mas_routing_mode) + app.setParam("mas_ingress_controller_name", args.mas_ingress_controller_name) + + # Mock custom controller configured + controller = create_ingress_controller_mock("custom-ingress") + ingress_api = MagicMock() + ingress_api.get.return_value = controller + app.dynamicClient.resources.get.return_value = ingress_api + + exists, isConfigured = app._checkIngressControllerForPathRouting("custom-ingress") + + assert exists is True + assert isConfigured is True + assert app.getParam("mas_ingress_controller_name") == "custom-ingress" + + def test_noninteractive_path_mode_missing_controller_name_defaults_to_default(self): + """Test that missing controller name defaults to 'default'.""" + # Simulate CLI arguments without --ingress-controller-name + argv = [ + "--mas-instance-id", "testinst", + "--mas-workspace-id", "testws", + "--mas-channel", "9.2.0", + "--routing", "path", + "--accept-license", + "--no-confirm" + ] + + args = installArgParser.parse_args(args=argv) + assert args.mas_routing_mode == "path" + # ingress_controller_name should be None when not provided + assert args.mas_ingress_controller_name is None + + app = create_mock_app() + app.isInteractiveMode = False + app.args = args + app.setParam("mas_routing_mode", args.mas_routing_mode) + + # Simulate the logic from app.py lines 1866-1876 + ingressControllerName = app.getParam("mas_ingress_controller_name") + if not ingressControllerName: + ingressControllerName = "default" + app.setParam("mas_ingress_controller_name", ingressControllerName) + + assert app.getParam("mas_ingress_controller_name") == "default" + + def test_noninteractive_path_mode_no_permissions_fails_gracefully(self): + """Test non-interactive path mode fails gracefully when user lacks permissions.""" + argv = [ + "--mas-instance-id", "testinst", + "--mas-workspace-id", "testws", + "--mas-channel", "9.2.0", + "--routing", "path", + "--ingress-controller-name", "default", + "--accept-license", + "--no-confirm" + ] + + args = installArgParser.parse_args(args=argv) + + app = create_mock_app() + app.isInteractiveMode = False + app.args = args + app.setParam("mas_routing_mode", args.mas_routing_mode) + app.setParam("mas_ingress_controller_name", args.mas_ingress_controller_name) + + # Mock permission denied + ingress_api = MagicMock() + ingress_api.get.side_effect = ApiException(status=403, reason="Forbidden") + app.dynamicClient.resources.get.return_value = ingress_api + + # Check permissions should return False + result = app._checkIngressControllerPermissions() + + assert result is False + + def test_noninteractive_path_mode_with_configure_flag_no_permissions(self): + """Test non-interactive path mode with --configure-ingress but no permissions.""" + argv = [ + "--mas-instance-id", "testinst", + "--mas-workspace-id", "testws", + "--mas-channel", "9.2.0", + "--routing", "path", + "--ingress-controller-name", "default", + "--configure-ingress", + "--accept-license", + "--no-confirm" + ] + + args = installArgParser.parse_args(args=argv) + + app = create_mock_app() + app.isInteractiveMode = False + app.args = args + app.setParam("mas_routing_mode", args.mas_routing_mode) + app.setParam("mas_ingress_controller_name", args.mas_ingress_controller_name) + + if hasattr(args, 'mas_configure_ingress') and args.mas_configure_ingress: + app.setParam("mas_configure_ingress", "true") + + # Mock permission denied + ingress_api = MagicMock() + ingress_api.get.side_effect = ApiException(status=403, reason="Forbidden") + app.dynamicClient.resources.get.return_value = ingress_api + + # Check permissions should return False + result = app._checkIngressControllerPermissions() + + # Should fail gracefully - permissions check returns False + assert result is False + + def test_noninteractive_path_mode_controller_not_configured_without_flag(self): + """Test non-interactive path mode when controller not configured and no --configure-ingress flag.""" + argv = [ + "--mas-instance-id", "testinst", + "--mas-workspace-id", "testws", + "--mas-channel", "9.2.0", + "--routing", "path", + "--ingress-controller-name", "default", + # Note: --configure-ingress is NOT provided + "--accept-license", + "--no-confirm" + ] + + args = installArgParser.parse_args(args=argv) + + app = create_mock_app() + app.isInteractiveMode = False + app.args = args + app.setParam("mas_routing_mode", args.mas_routing_mode) + app.setParam("mas_ingress_controller_name", args.mas_ingress_controller_name) + # Note: mas_configure_ingress is NOT set + + # Mock controller not configured + controller = create_ingress_controller_mock(namespace_ownership="Strict") + ingress_api = MagicMock() + ingress_api.get.return_value = controller + app.dynamicClient.resources.get.return_value = ingress_api + + # Check if configured + _, is_configured = app._checkIngressControllerForPathRouting("default") + + # Should detect it's not configured + assert is_configured is False + + def test_noninteractive_path_mode_all_flags_with_permissions(self): + """Test non-interactive path mode with all flags and proper permissions.""" + argv = [ + "--mas-instance-id", "testinst", + "--mas-workspace-id", "testws", + "--mas-channel", "9.2.0", + "--routing", "path", + "--ingress-controller-name", "custom-controller", + "--configure-ingress", + "--accept-license", + "--no-confirm" + ] + + args = installArgParser.parse_args(args=argv) + + app = create_mock_app() + app.isInteractiveMode = False + app.args = args + app.setParam("mas_routing_mode", args.mas_routing_mode) + app.setParam("mas_ingress_controller_name", args.mas_ingress_controller_name) + + if hasattr(args, 'mas_configure_ingress') and args.mas_configure_ingress: + app.setParam("mas_configure_ingress", "true") + + # Mock controller with permissions + controller = create_ingress_controller_mock("custom-controller", namespace_ownership="Strict") + ingress_api = MagicMock() + ingress_api.get.return_value = controller + app.dynamicClient.resources.get.return_value = ingress_api + + # Check permissions + has_permissions = app._checkIngressControllerPermissions("custom-controller") + + # Check configuration status + _, is_configured = app._checkIngressControllerForPathRouting("custom-controller") + + # Should have permissions but not be configured + assert has_permissions is True + assert is_configured is False + + # Parameters should remain as set + assert app.getParam("mas_routing_mode") == "path" + assert app.getParam("mas_ingress_controller_name") == "custom-controller" + assert app.getParam("mas_configure_ingress") == "true" + + def test_noninteractive_path_mode_controller_already_configured(self): + """Test non-interactive path mode when controller is already properly configured.""" + argv = [ + "--mas-instance-id", "testinst", + "--mas-workspace-id", "testws", + "--mas-channel", "9.2.0", + "--routing", "path", + "--ingress-controller-name", "default", + "--accept-license", + "--no-confirm" + ] + + args = installArgParser.parse_args(args=argv) + + app = create_mock_app() + app.isInteractiveMode = False + app.args = args + app.setParam("mas_routing_mode", args.mas_routing_mode) + app.setParam("mas_ingress_controller_name", args.mas_ingress_controller_name) + + # Mock controller already configured + controller = create_ingress_controller_mock(namespace_ownership="InterNamespaceAllowed") + ingress_api = MagicMock() + ingress_api.get.return_value = controller + app.dynamicClient.resources.get.return_value = ingress_api + + # Check if configured + _, is_configured = app._checkIngressControllerForPathRouting("default") + + # Should be configured + assert is_configured is True + + # mas_configure_ingress should not be needed + assert app.getParam("mas_configure_ingress") == "" + + +# ============================================================================= +# Non-Interactive Mode - Subdomain Routing Tests +# ============================================================================= +class TestNonInteractiveSubdomainRouting: + """Test suite for non-interactive mode subdomain routing scenarios.""" + + def test_noninteractive_subdomain_mode_basic(self): + """Test basic non-interactive subdomain mode.""" + argv = [ + "--mas-instance-id", "testinst", + "--mas-workspace-id", "testws", + "--mas-channel", "9.2.0", + "--routing", "subdomain", + "--accept-license", + "--no-confirm" + ] + + args = installArgParser.parse_args(args=argv) + assert args.mas_routing_mode == "subdomain" + + app = create_mock_app() + app.isInteractiveMode = False + app.args = args + app.setParam("mas_routing_mode", args.mas_routing_mode) + + assert app.getParam("mas_routing_mode") == "subdomain" + assert app.getParam("mas_ingress_controller_name") == "" + assert app.getParam("mas_configure_ingress") == "" + + def test_noninteractive_subdomain_mode_ignores_ingress_flags(self): + """Test that subdomain mode ignores ingress-related flags.""" + argv = [ + "--mas-instance-id", "testinst", + "--mas-workspace-id", "testws", + "--mas-channel", "9.2.0", + "--routing", "subdomain", + # Even if these are provided, they should be ignored for subdomain mode + "--ingress-controller-name", "default", + "--configure-ingress", + "--accept-license", + "--no-confirm" + ] + + args = installArgParser.parse_args(args=argv) + assert args.mas_routing_mode == "subdomain" + + app = create_mock_app() + app.isInteractiveMode = False + app.args = args + app.setParam("mas_routing_mode", args.mas_routing_mode) + # These should be ignored/cleared for subdomain mode + app.setParam("mas_ingress_controller_name", "") + app.setParam("mas_configure_ingress", "") + + assert app.getParam("mas_routing_mode") == "subdomain" + assert app.getParam("mas_ingress_controller_name") == "" + assert app.getParam("mas_configure_ingress") == "" + + +# ============================================================================= +# IngressController Patch Function Integration Tests +# ============================================================================= +class TestIngressControllerPatchIntegration: + """Test suite for configureIngressForPathBasedRouting integration.""" + + @patch('mas.devops.ocp.configureIngressForPathBasedRouting') + def test_patch_function_called_with_correct_parameters(self, mock_configure): + """Test that patch function is called with correct parameters.""" + from mas.devops.ocp import configureIngressForPathBasedRouting + + mock_client = MagicMock() + mock_configure.return_value = True + + result = configureIngressForPathBasedRouting(mock_client, "default") + + assert result is True + mock_configure.assert_called_once_with(mock_client, "default") + + @patch('mas.devops.ocp.configureIngressForPathBasedRouting') + def test_patch_function_handles_failure(self, mock_configure): + """Test that patch function failure is handled correctly.""" + from mas.devops.ocp import configureIngressForPathBasedRouting + + mock_client = MagicMock() + mock_configure.return_value = False + + result = configureIngressForPathBasedRouting(mock_client, "default") + + assert result is False + + @patch('mas.devops.ocp.configureIngressForPathBasedRouting') + def test_patch_function_with_custom_controller(self, mock_configure): + """Test patch function with custom IngressController name.""" + from mas.devops.ocp import configureIngressForPathBasedRouting + + mock_client = MagicMock() + mock_configure.return_value = True + + result = configureIngressForPathBasedRouting(mock_client, "custom-ingress") + + assert result is True + mock_configure.assert_called_once_with(mock_client, "custom-ingress") + + +# ============================================================================= +# Integration Tests - Complete CLI Flow +# ============================================================================= +class TestCompleteCliFlow: + """Integration tests for complete CLI prompt flow.""" + + def test_complete_flow_path_mode_with_all_prompts(self): + """Test complete flow with all prompts for path mode.""" + app = create_mock_app() + app.isInteractiveMode = True + app.showAdvancedOptions = True + app.setParam("mas_channel", "9.2.0") + app.setParam("mas_instance_id", "test-inst") + + # Mock multiple controllers + controller1 = create_ingress_controller_mock("default", namespace_ownership="Strict") + controller2 = create_ingress_controller_mock("custom", namespace_ownership="Strict") + + ingress_api = MagicMock() + ingress_api.get.side_effect = [ + controller1, # Permission check + MagicMock(items=[controller1, controller2]), # List controllers + controller1 # Configuration check + ] + + ingress_config_api = MagicMock() + ingress_config = MagicMock() + ingress_config.spec.get.return_value = "apps.cluster.example.com" + ingress_config_api.get.return_value = ingress_config + + with patch.object(app.dynamicClient.resources, 'get') as mock_get: + def get_side_effect(api_version, kind): + if kind == "IngressController": + return ingress_api + elif kind == "Ingress": + return ingress_config_api + return MagicMock() + + mock_get.side_effect = get_side_effect + + # Mock isVersionEqualOrAfter to return True for version check + with patch('mas.cli.install.app.isVersionEqualOrAfter', return_value=True): + # Set up promptForInt to return different values for routing mode and controller selection + app.promptForInt.side_effect = [1, 1] # Path mode, first controller + app.configRoutingMode() + + # Verify all parameters are set correctly + assert app.getParam("mas_routing_mode") == "path" + assert app.getParam("mas_ingress_controller_name") == "default" + assert app.getParam("mas_configure_ingress") == "true" + + # Verify prompts were called + assert app.promptForInt.call_count == 2 + assert app.yesOrNo.called + + def test_complete_flow_version_check_skips_routing_config(self): + """Test that routing config is skipped for versions < 9.2.0.""" + app = create_mock_app() + app.isInteractiveMode = True + app.showAdvancedOptions = True + app.setParam("mas_channel", "9.1.0") # Version < 9.2.0 + + # configRoutingMode should not execute for this version + # This is tested by checking the version condition + from mas.devops.utils import isVersionEqualOrAfter + + should_configure = ( + app.showAdvancedOptions and isVersionEqualOrAfter('9.2.0', app.getParam("mas_channel")) and app.getParam("mas_channel") != '9.2.x-feature' + ) + + assert should_configure is False + + def test_complete_end_to_end_flow_with_advanced_options_and_patching(self): + """Test complete end-to-end CLI flow: advanced options → routing prompt → path selection → patching.""" + app = create_mock_app() + app.isInteractiveMode = True + app.showAdvancedOptions = True # This should trigger routing mode prompt + app.setParam("mas_channel", "9.2.0") + app.setParam("mas_instance_id", "test-inst") + + # Mock IngressController not configured (needs patching) + controller = create_ingress_controller_mock(namespace_ownership="Strict") + ingress_api = MagicMock() + ingress_api.get.side_effect = [ + controller, # For permission check + MagicMock(items=[controller]), # For listing controllers + controller # For configuration check + ] + + # Mock Ingress config + ingress_config_api = MagicMock() + ingress_config = MagicMock() + ingress_config.spec.get.return_value = "apps.cluster.example.com" + ingress_config_api.get.return_value = ingress_config + + with patch.object(app.dynamicClient.resources, 'get') as mock_get: + def get_side_effect(api_version, kind): + if kind == "IngressController": + return ingress_api + elif kind == "Ingress": + return ingress_config_api + return MagicMock() + + mock_get.side_effect = get_side_effect + + # Mock the patching function from python-devops + with patch('mas.devops.ocp.configureIngressForPathBasedRouting') as mock_patch: + mock_patch.return_value = True # Patch succeeds + + with patch('mas.cli.install.app.isVersionEqualOrAfter', return_value=True): + # User selects path mode (option 1) and agrees to configure + app.promptForInt.return_value = 1 + app.yesOrNo.return_value = True + + # Call configRoutingMode - this is what happens during CLI flow + app.configRoutingMode() + + # Verify routing mode was configured + assert app.getParam("mas_routing_mode") == "path" + assert app.getParam("mas_ingress_controller_name") == "default" + assert app.getParam("mas_configure_ingress") == "true" + + # Now simulate the actual patching that would happen in the pipeline + # This is what the ansible playbook would call + if app.getParam("mas_configure_ingress") == "true": + from mas.devops.ocp import configureIngressForPathBasedRouting + result = configureIngressForPathBasedRouting( + app.dynamicClient, + app.getParam("mas_ingress_controller_name") + ) + + # Verify the patch function was called with correct parameters + mock_patch.assert_called_once_with( + app.dynamicClient, + "default" + ) + assert result is True + + def test_complete_end_to_end_flow_advanced_options_disabled_skips_routing(self): + """Test that routing mode prompt is skipped when advanced options are disabled.""" + app = create_mock_app() + app.isInteractiveMode = True + app.showAdvancedOptions = False # Advanced options disabled + app.setParam("mas_channel", "9.2.0") + app.setParam("mas_instance_id", "test-inst") + + # Mock Ingress config (for _getMasDomainForDisplay if called) + ingress_config_api = MagicMock() + ingress_config = MagicMock() + ingress_config.spec.get.return_value = "apps.cluster.example.com" + ingress_config_api.get.return_value = ingress_config + app.dynamicClient.resources.get.return_value = ingress_config_api + + with patch('mas.cli.install.app.isVersionEqualOrAfter', return_value=True): + app.configRoutingMode() + + # Routing mode should not be set (method should exit early) + # Default is subdomain if not explicitly set + assert app.getParam("mas_routing_mode") == "" + + # promptForInt should not be called (no routing mode prompt) + assert app.promptForInt.call_count == 0 + +# ============================================================================= +# Parameter Validation Tests +# ============================================================================= + + +class TestParameterValidation: + """Test parameter validation for routing mode.""" + + def test_valid_routing_modes(self): + """Test that only valid routing modes are accepted.""" + app = create_mock_app() + + valid_modes = ["path", "subdomain"] + + for mode in valid_modes: + app.setParam("mas_routing_mode", mode) + assert app.getParam("mas_routing_mode") == mode + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) + +# Made with Bob diff --git a/python/test/utils/install_test_helper.py b/python/test/utils/install_test_helper.py index d05790c0794..c79974cd105 100644 --- a/python/test/utils/install_test_helper.py +++ b/python/test/utils/install_test_helper.py @@ -136,6 +136,7 @@ def setup_mocks(self): aiservice_tenant_api = MagicMock() aiservice_api = MagicMock() aiservice_app_api = MagicMock() + ingress_controller_api = MagicMock() # Map resource kinds to APIs resource_apis = { @@ -153,7 +154,8 @@ def setup_mocks(self): 'ClusterVersion': cluster_version_api, 'AIServiceTenant': aiservice_tenant_api, 'AIService': aiservice_api, - 'AIServiceApp': aiservice_app_api + 'AIServiceApp': aiservice_app_api, + 'IngressController': ingress_controller_api } resources.get.side_effect = lambda **kwargs: resource_apis.get(kwargs['kind'], None) @@ -188,6 +190,54 @@ def setup_mocks(self): cluster_version.status.history = [history_record] cluster_version_api.get.return_value = cluster_version + # Configure IngressController mock - NOT configured for path-based routing initially + # This will trigger the prompt to configure it + ingress_controller = MagicMock() + ingress_controller.metadata = MagicMock() + ingress_controller.metadata.name = 'default' + ingress_controller.status = MagicMock() + ingress_controller.status.domain = 'apps.cluster.example.com' + ingress_controller.status.conditions = [ + MagicMock(type='Available', status='True') + ] + ingress_controller.spec = MagicMock() + ingress_controller.spec.routeAdmission = MagicMock() + # Set to 'Strict' initially (not configured for path-based routing) + ingress_controller.spec.routeAdmission.namespaceOwnership = 'Strict' + + # Support dict-style access for _checkIngressControllerForPathRouting + # Initially returns 'Strict' (not configured) + def ingress_controller_get(key, default=None): + if key == 'spec': + spec_dict = { + 'routeAdmission': { + 'namespaceOwnership': 'Strict' # Not configured for path-based routing + } + } + return type('obj', (object,), { + 'get': lambda self, k, d=None: spec_dict.get(k, d) + })() + return default + + ingress_controller.get = ingress_controller_get + + # Configure get() to return single controller when queried by name + # and list when queried without name + def ingress_controller_api_get(**kwargs): + if 'name' in kwargs: + # Return single controller when queried by name + return ingress_controller + else: + # Return list when querying all controllers + ingress_controller_list = MagicMock() + ingress_controller_list.items = [ingress_controller] + return ingress_controller_list + + ingress_controller_api.get.side_effect = ingress_controller_api_get + + # Mock patch operation to succeed + ingress_controller_api.patch = MagicMock(return_value=ingress_controller) + return dynamic_client, resource_apis def setup_prompt_handler(self, mixins_prompt, prompt_session_instance, app_prompt): @@ -240,13 +290,15 @@ def run_install_test(self): mock.patch('mas.cli.install.app.createNamespace'), mock.patch('mas.cli.install.app.preparePipelinesNamespace'), mock.patch('mas.cli.install.app.launchInstallPipeline') as launch_install_pipeline, + mock.patch('mas.cli.install.app.configureIngressForPathBasedRouting') as configure_ingress, mock.patch('mas.cli.cli.isSNO') as is_sno, mock.patch('mas.cli.displayMixins.prompt') as mixins_prompt, mock.patch('mas.cli.displayMixins.PromptSession') as prompt_session_class, mock.patch('mas.cli.install.app.prompt') as app_prompt, mock.patch('mas.cli.install.app.getStorageClasses') as get_storage_classes, - mock.patch('mas.cli.install.app.getDefaultStorageClasses') as get_default_storage_classes + mock.patch('mas.cli.install.app.getDefaultStorageClasses') as get_default_storage_classes, ): + # Configure mock return values dynamic_client_class.return_value = dynamic_client get_nodes.return_value = [{'status': {'nodeInfo': {'architecture': self.config.architecture}}}] @@ -254,6 +306,7 @@ def run_install_test(self): get_current_catalog.return_value = self.config.current_catalog launch_install_pipeline.return_value = 'https://pipeline.test.maximo.ibm.com' is_sno.return_value = self.config.is_sno + configure_ingress.return_value = True # Configure PromptSession mock prompt_session_instance = MagicMock() @@ -301,6 +354,7 @@ def run_install_test(self): # Always verify all prompts were matched exactly once # This will fail if any prompts weren't reached (e.g., due to early SystemExit) # which is the desired behavior to ensure tests accurately reflect what prompts are shown + assert self.prompt_tracker is not None, "prompt_tracker should be initialized" self.prompt_tracker.verify_all_prompts_matched() diff --git a/rbac/install/user/clusterrole.yaml b/rbac/install/user/clusterrole.yaml index 36d4011a2e4..50dbccdaf79 100644 --- a/rbac/install/user/clusterrole.yaml +++ b/rbac/install/user/clusterrole.yaml @@ -76,6 +76,26 @@ rules: verbs: - list + # The CLI needs to inspect and optionally configure IngressController for path-based routing + - apiGroups: + - operator.openshift.io + resources: + - ingresscontrollers + verbs: + - get + - list + - patch + + # The CLI needs to read the cluster ingress configuration to determine the domain + - apiGroups: + - config.openshift.io + resources: + - ingresses + verbs: + - get + resourceNames: + - cluster + # We need to set up the "pipeline" service account that will be used by the pipelines - apiGroups: - rbac.authorization.k8s.io diff --git a/tekton/src/params/install.yml.j2 b/tekton/src/params/install.yml.j2 index 0eca9d8e899..be3ea9edb25 100644 --- a/tekton/src/params/install.yml.j2 +++ b/tekton/src/params/install.yml.j2 @@ -455,10 +455,6 @@ type: string default: "" description: Name of the IngressController to use for path-based routing -- name: mas_configure_ingress - type: string - default: "" - description: Set to 'true' to automatically configure IngressController for path-based routing - name: mas_trust_default_cas type: string default: "" diff --git a/tekton/src/pipelines/taskdefs/core/suite-install.yml.j2 b/tekton/src/pipelines/taskdefs/core/suite-install.yml.j2 index a8ead0028eb..5488b89a936 100644 --- a/tekton/src/pipelines/taskdefs/core/suite-install.yml.j2 +++ b/tekton/src/pipelines/taskdefs/core/suite-install.yml.j2 @@ -64,8 +64,6 @@ value: $(params.mas_routing_mode) - name: mas_ingress_controller_name value: $(params.mas_ingress_controller_name) - - name: mas_configure_ingress - value: $(params.mas_configure_ingress) - name: mas_manual_cert_mgmt value: $(params.mas_manual_cert_mgmt) - name: mas_trust_default_cas diff --git a/tekton/src/tasks/suite-install.yml.j2 b/tekton/src/tasks/suite-install.yml.j2 index ee9e715ccab..179ebea3dc7 100644 --- a/tekton/src/tasks/suite-install.yml.j2 +++ b/tekton/src/tasks/suite-install.yml.j2 @@ -51,10 +51,6 @@ spec: type: string description: Name of the IngressController to use for path-based routing default: "" - - name: mas_configure_ingress - type: string - description: Set to 'true' to automatically configure IngressController for path-based routing - default: "False" - name: mas_trust_default_cas type: string description: Optional boolean parameter that when set to False, disables the normal trust of well known public certificate authorities @@ -179,8 +175,6 @@ spec: value: $(params.mas_routing_mode) - name: MAS_INGRESS_CONTROLLER_NAME value: $(params.mas_ingress_controller_name) - - name: MAS_CONFIGURE_INGRESS - value: $(params.mas_configure_ingress) - name: MAS_MANUAL_CERT_MGMT value: $(params.mas_manual_cert_mgmt) - name: MAS_TRUST_DEFAULT_CAS