Skip to content
Open
86 changes: 56 additions & 30 deletions python/src/mas/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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)

Expand All @@ -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)
31 changes: 28 additions & 3 deletions python/src/mas/cli/install/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
119 changes: 112 additions & 7 deletions python/tests/unit/test_entitlement_key_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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."""

Expand Down
Loading