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