diff --git a/python/src/mas/cli/cli.py b/python/src/mas/cli/cli.py index aea9f48bda..869a55043e 100644 --- a/python/src/mas/cli/cli.py +++ b/python/src/mas/cli/cli.py @@ -32,7 +32,7 @@ from mas.devops.mas import isAirgapInstall from mas.devops.ocp import connect, isSNO, getNodes from mas.devops.utils import validateIBMEntitlementKey - +from requests.exceptions import RequestException as RequestsException from .displayMixins import PrintMixin, PromptMixin # Configure the logger @@ -565,7 +565,7 @@ def selectLocalConfigDir(self) -> None: self.localConfigDir = self.promptForDir("Select Local configuration directory") @logMethodCall - def validateEntitlementKey(self, entitlementKey: str, repository: str = "cp/mas/coreapi", timeout: int = 30) -> bool: + def validateEntitlementKey(self, entitlementKey: str, repository: str = "cp/mas/coreapi", timeout: int = 30): """ Validate IBM entitlement key using mas.devops.utils.validateIBMEntitlementKey. @@ -575,20 +575,23 @@ def validateEntitlementKey(self, entitlementKey: str, repository: str = "cp/mas/ timeout: Timeout in seconds for validation. Defaults to 30. Returns: - bool: True if valid, False if invalid + True if the key is valid, False if authentication failed or an unexpected network + error occurred, None if an SSL error prevented validation (key may still be valid). """ + logger.info(f"Validating IBM entitlement key against repository: {repository}") try: - logger.info(f"Validating IBM entitlement key against repository: {repository}") isValid = validateIBMEntitlementKey(entitlementKey, repository, timeout) - if isValid: - logger.info("IBM entitlement key validation successful") - else: - logger.warning("IBM entitlement key validation failed") - return isValid - except Exception as e: - logger.error(f"Error validating IBM entitlement key: {e}") + except RequestsException as e: + logger.error(f"Unexpected network error validating IBM entitlement key: {e}") logger.exception(e, stack_info=True) return False + if isValid is True: + logger.info("IBM entitlement key validation successful") + elif isValid is False: + logger.warning("IBM entitlement key validation failed") + else: + logger.warning("IBM entitlement key could not be validated (SSL error)") + return isValid @logMethodCall def promptForEntitlementKey(self, message: str, param: str, repository: str = "cp/mas/coreapi", timeout: int = 30) -> str: @@ -597,11 +600,11 @@ def promptForEntitlementKey(self, message: str, param: str, repository: str = "c In interactive mode: - Validates the key after user input - - If invalid, offers: 1) Try again, 2) Continue anyway, 3) Quit + - If invalid or SSL error, offers: 1) Try again, 2) Continue anyway, 3) Quit - Loops until valid key or user chooses to continue/quit In non-interactive mode with --no-confirm: - - Validates but only warns if invalid, does not block + - Validates but only warns if invalid or SSL error, does not block Args: message: Prompt message to display @@ -617,32 +620,55 @@ def promptForEntitlementKey(self, message: str, param: str, repository: str = "c entitlementKey = self.promptForString(message, param=None, isPassword=True) # Validate the key + # Returns True (valid), False (auth failed), or None (SSL error - unable to check) isValid = self.validateEntitlementKey(entitlementKey, repository, timeout) - if isValid: + if isValid is True: # Key is valid, show success message and continue self.printHighlight("✓ IBM entitlement key validated successfully") self.setParam(param, entitlementKey) return entitlementKey - # Key is invalid + # Validation could not complete (SSL) or failed (bad key) if self.noConfirm: - # Non-interactive mode with --no-confirm: warn but continue - self.printWarning("IBM entitlement key validation failed, but continuing due to --no-confirm flag") + if isValid is None: + self.printWarning( + "SSL certificate verification failed — could not reach cp.icr.io to validate the entitlement key. " + "This is likely caused by a corporate proxy or firewall using a self-signed certificate. " + "Your entitlement key may still be valid. Continuing due to --no-confirm flag." + ) + else: + self.printWarning("IBM entitlement key validation failed, but continuing due to --no-confirm flag") self.setParam(param, entitlementKey) return entitlementKey - # Interactive mode or non-interactive without --no-confirm: offer options - self.printWarning("IBM entitlement key validation failed") - print() - self.printDescription( - [ - "What would you like to do?", - " 1. Try again (re-enter the entitlement key)", - " 2. Continue anyway (skip validation)", - " 3. Quit (exit the application)", - ] - ) + # Interactive mode: build the warning and offer options + if isValid is None: + self.printWarning("SSL certificate verification failed — could not reach cp.icr.io") + print() + self.printDescription( + [ + "This is likely caused by a corporate proxy or firewall using a self-signed certificate.", + "This does NOT necessarily mean your entitlement key is wrong.", + "Your key may be perfectly valid — the CLI simply could not connect to verify it.", + "", + "What would you like to do?", + " 1. Try again (re-enter the entitlement key)", + " 2. Continue anyway (skip validation) — recommended if your key is correct", + " 3. Quit (exit the application)", + ] + ) + else: + self.printWarning("IBM entitlement key validation failed") + print() + self.printDescription( + [ + "What would you like to do?", + " 1. Try again (re-enter the entitlement key)", + " 2. Continue anyway (skip validation)", + " 3. Quit (exit the application)", + ] + ) choice = self.promptForInt("Select an option", min=1, max=3) @@ -651,10 +677,10 @@ def promptForEntitlementKey(self, message: str, param: str, repository: str = "c continue elif choice == 2: # Continue anyway - logger.warning("User chose to continue with invalid entitlement key") + logger.warning("User chose to continue with unverified entitlement key") self.setParam(param, entitlementKey) return entitlementKey else: # choice == 3 # Quit - logger.info("User chose to quit due to invalid entitlement key") + logger.info("User chose to quit due to entitlement key validation failure") exit(1) diff --git a/python/src/mas/cli/install/app.py b/python/src/mas/cli/install/app.py index d787179766..ace85f83d3 100644 --- a/python/src/mas/cli/install/app.py +++ b/python/src/mas/cli/install/app.py @@ -2230,12 +2230,37 @@ def nonInteractiveMode(self) -> None: # Special handling for ibm_entitlement_key: validate it if key == "ibm_entitlement_key": isValid = self.validateEntitlementKey(value) - if not isValid: + if isValid is None: + # SSL error — network could not reach cp.icr.io, key may still be valid + if self.noConfirm: + self.printWarning( + "SSL certificate verification failed — could not reach cp.icr.io to validate the entitlement key. " + "This is likely caused by a corporate proxy or firewall using a self-signed certificate. " + "Your entitlement key may still be valid. Continuing due to --no-confirm flag." + ) + else: + self.printWarning("SSL certificate verification failed — could not reach cp.icr.io") + print() + self.printDescription( + [ + "This is likely caused by a corporate proxy or firewall using a self-signed certificate.", + "This does NOT necessarily mean your entitlement key is wrong.", + "Your key may be perfectly valid — the CLI simply could not connect to verify it.", + "", + "What would you like to do?", + " 1. Continue anyway (skip validation) — recommended if your key is correct", + " 2. Quit (exit the application)", + ] + ) + choice = self.promptForInt("Select an option", min=1, max=2) + if choice == 2: + logger.info("User chose to quit due to SSL error during entitlement key validation") + exit(1) + elif isValid is False: + # Authentication failed — the key itself is wrong if self.noConfirm: - # Non-interactive with --no-confirm: warn but continue self.printWarning("IBM entitlement key validation failed, but continuing due to --no-confirm flag") else: - # Non-interactive without --no-confirm: offer options self.printWarning("IBM entitlement key validation failed") print() self.printDescription( diff --git a/python/tests/unit/test_entitlement_key_validation.py b/python/tests/unit/test_entitlement_key_validation.py index e1ad8c18e9..646eff76e9 100644 --- a/python/tests/unit/test_entitlement_key_validation.py +++ b/python/tests/unit/test_entitlement_key_validation.py @@ -81,15 +81,34 @@ def test_validate_with_custom_repository(self, mock_validate): assert result is True mock_validate.assert_called_once_with("key-123", "custom/repo", 60) + @patch("mas.cli.cli.validateIBMEntitlementKey") + def test_validate_with_ssl_error(self, mock_validate): + """Test validation when an SSL certificate error occurs. + + GIVEN an SSL certificate error during validation (e.g. corporate proxy) + utils.py now catches SSLError internally and returns None. + WHEN validateEntitlementKey is called + THEN it should return None to signal the key could not be checked (not that it is wrong). + """ + mock_validate.return_value = None # utils.py absorbs SSLError and returns None + app = BaseApp() + + result = app.validateEntitlementKey("key-123") + + assert result is None + @patch("mas.cli.cli.validateIBMEntitlementKey") def test_validate_with_network_error(self, mock_validate): - """Test validation when network error occurs. + """Test validation when an unexpected network error occurs. - GIVEN a network error during validation + GIVEN an unexpected network error during validation + utils.py re-raises non-SSL RequestException — cli.py catches it and returns False. WHEN validateEntitlementKey is called - THEN it should return False and log the error. + THEN it should return False (unexpected error, treat as failure). """ - mock_validate.side_effect = ConnectionError("Network error") + from requests.exceptions import ConnectionError as RequestsConnectionError + + mock_validate.side_effect = RequestsConnectionError("Network unreachable") app = BaseApp() result = app.validateEntitlementKey("key-123") @@ -98,13 +117,16 @@ def test_validate_with_network_error(self, mock_validate): @patch("mas.cli.cli.validateIBMEntitlementKey") def test_validate_with_timeout(self, mock_validate): - """Test validation when timeout occurs. + """Test validation when a timeout occurs. GIVEN a timeout during validation + utils.py re-raises non-SSL RequestException — cli.py catches it and returns False. WHEN validateEntitlementKey is called - THEN it should return False and log the error. + THEN it should return False (unexpected error, treat as failure). """ - mock_validate.side_effect = TimeoutError("Validation timeout") + from requests.exceptions import Timeout as RequestsTimeout + + mock_validate.side_effect = RequestsTimeout("Request timed out") app = BaseApp() result = app.validateEntitlementKey("key-123") @@ -219,6 +241,89 @@ def test_invalid_key_quit(self, mock_print_desc, mock_print_warn, mock_prompt_in mock_prompt_int.assert_called_once() +class TestPromptForEntitlementKeySSLError: + """Test promptForEntitlementKey when an SSL error prevents validation.""" + + @patch("mas.cli.cli.BaseApp.validateEntitlementKey") + @patch("mas.cli.cli.BaseApp.promptForString") + @patch("mas.cli.cli.BaseApp.promptForInt") + @patch("mas.cli.cli.BaseApp.printWarning") + @patch("mas.cli.cli.BaseApp.printDescription") + def test_ssl_error_continue_anyway(self, mock_print_desc, mock_print_warn, mock_prompt_int, mock_prompt_string, mock_validate): + """Test continuing when SSL error occurs and user chooses option 2. + + GIVEN an SSL error during validation + WHEN promptForEntitlementKey is called and user chooses to continue anyway + THEN it should return the key and show an SSL-specific warning message. + """ + mock_prompt_string.return_value = "valid-key-123" + mock_validate.return_value = None # None signals SSL error + mock_prompt_int.return_value = 2 # Continue anyway + + app = BaseApp() + app.noConfirm = False + + result = app.promptForEntitlementKey("IBM entitlement key", "ibm_entitlement_key") + + assert result == "valid-key-123" + assert app.getParam("ibm_entitlement_key") == "valid-key-123" + mock_validate.assert_called_once() + mock_prompt_int.assert_called_once() + # Warning should mention SSL, not "validation failed" + warn_message = mock_print_warn.call_args[0][0] + assert "SSL" in warn_message + + @patch("mas.cli.cli.BaseApp.validateEntitlementKey") + @patch("mas.cli.cli.BaseApp.promptForString") + @patch("mas.cli.cli.BaseApp.printWarning") + def test_ssl_error_with_no_confirm(self, mock_print_warn, mock_prompt_string, mock_validate): + """Test SSL error with --no-confirm flag. + + GIVEN an SSL error and --no-confirm flag is set + WHEN promptForEntitlementKey is called + THEN it should warn with an SSL-specific message and continue without prompting. + """ + mock_prompt_string.return_value = "valid-key-123" + mock_validate.return_value = None # None signals SSL error + + app = BaseApp() + app.noConfirm = True + + result = app.promptForEntitlementKey("IBM entitlement key", "ibm_entitlement_key") + + assert result == "valid-key-123" + assert app.getParam("ibm_entitlement_key") == "valid-key-123" + mock_validate.assert_called_once() + mock_print_warn.assert_called_once() + # Warning should mention SSL, not generic "validation failed" + warn_message = mock_print_warn.call_args[0][0] + assert "SSL" in warn_message + + @patch("mas.cli.cli.BaseApp.validateEntitlementKey") + @patch("mas.cli.cli.BaseApp.promptForString") + @patch("mas.cli.cli.BaseApp.promptForInt") + @patch("mas.cli.cli.BaseApp.printWarning") + @patch("mas.cli.cli.BaseApp.printDescription") + def test_ssl_error_quit(self, mock_print_desc, mock_print_warn, mock_prompt_int, mock_prompt_string, mock_validate): + """Test quitting when SSL error occurs and user chooses option 3. + + GIVEN an SSL error during validation + WHEN promptForEntitlementKey is called and user chooses to quit + THEN it should raise SystemExit. + """ + mock_prompt_string.return_value = "valid-key-123" + mock_validate.return_value = None # None signals SSL error + mock_prompt_int.return_value = 3 # Quit + + app = BaseApp() + app.noConfirm = False + + with pytest.raises(SystemExit) as exc_info: + app.promptForEntitlementKey("IBM entitlement key", "ibm_entitlement_key") + + assert exc_info.value.code == 1 + + class TestPromptForEntitlementKeyNonInteractive: """Test promptForEntitlementKey in non-interactive mode."""