From d36267a3200ee5d734020d44b81f98fa9813e291 Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Tue, 22 Sep 2026 15:14:52 +0530 Subject: [PATCH 1/4] [patch] fix issuer kind setting based on user's prompt selection --- python/src/mas/cli/cli.py | 4 +- python/src/mas/cli/install/app.py | 98 ++++++++++++++++++++----------- 2 files changed, 67 insertions(+), 35 deletions(-) diff --git a/python/src/mas/cli/cli.py b/python/src/mas/cli/cli.py index aea9f48bda..62a6d453a7 100644 --- a/python/src/mas/cli/cli.py +++ b/python/src/mas/cli/cli.py @@ -243,6 +243,8 @@ def __init__(self) -> None: } self.upgrade_path: Dict[str, str] = { + "9.2.x-dev": "9.3.x-pr.kind", + "9.2.x": "9.3.x-pr.kind", "9.2.x-feature": "9.2.x", "9.1.x": "9.2.x", "9.1.x-feature": "9.1.x", @@ -361,7 +363,7 @@ def createTektonFileWithDigest(self) -> None: @logMethodCall def getCompatibleVersions(self, coreChannel: str, appId: str) -> List[str]: if coreChannel in self.compatibilityMatrix: - return self.compatibilityMatrix[coreChannel][appId] + return self.compatibilityMatrix[coreChannel].get(appId, []) else: return [] diff --git a/python/src/mas/cli/install/app.py b/python/src/mas/cli/install/app.py index d787179766..d003459ce5 100644 --- a/python/src/mas/cli/install/app.py +++ b/python/src/mas/cli/install/app.py @@ -726,13 +726,13 @@ def configAdminMode(self): " - No ClusterRoles are installed in this mode, except where required by ArcGIS and Visual Inspection (MVI)", " - CLI pre-installs namespace-scoped Roles in prepared namespaces to grant delegated admin permissions", " - MAS can manage applications only in namespaces prepared by the OpenShift admin", - " - DNS integration is not available in this mode. If you use a custom domain, you need to configure DNS manually.", + " - DNS integration is not available in this mode. If you use a custom domain, you need to configure DNS manually only for MAS 9.2", "", " 3. minimal - Install with essential namespace-scoped Roles only", " - No ClusterRoles are installed in this mode, except where required by ArcGIS and Visual Inspection (MVI)", " - Only essential permissions required for MAS applications are applied", " - MAS UI/API cannot manage application lifecycle; OpenShift admins must manage apps outside MAS", - " - DNS integration is not available in this mode. If you use a custom domain, you need to configure DNS manually.", + " - DNS integration is not available in this mode. If you use a custom domain, you need to configure DNS manually only for MAS 9.2", ] ) @@ -740,7 +740,11 @@ def configAdminMode(self): adminModeMap = {1: "cluster", 2: "namespaced", 3: "minimal"} self.mas_admin_mode = adminModeMap[adminModeInt] - if self.mas_admin_mode in ["namespaced", "minimal"]: + if isVersionEqualOrAfter("9.3.0", self.getParam("mas_channel")): + # 9.3+: issuerKind is not prompted — it is derived later from the DNS provider/Let's encrypt choice. + # Default (no dns provider, no LE) Issuer, set as fallback here. + self.setParam("mas_issuer_kind", "Issuer") + elif self.mas_admin_mode in ["namespaced", "minimal"]: self.setParam("mas_issuer_kind", "Issuer") else: self.printDescription( @@ -759,10 +763,14 @@ def configAdminMode(self): self.setParam("mas_issuer_kind", "ClusterIssuer" if issuerKindChoice == 2 else "Issuer") elif self.mas_admin_mode == "": self.mas_admin_mode = "cluster" - self.setParam("mas_issuer_kind", "ClusterIssuer") + if isVersionEqualOrAfter("9.3.0", self.getParam("mas_channel")): + self.setParam("mas_issuer_kind", "Issuer") + else: + self.setParam("mas_issuer_kind", "ClusterIssuer") def _handleDNSIntegrationRestriction(self): - if not isVersionEqualOrAfter("9.2.0", self.getParam("mas_channel")): + # DNS integration restrictions only apply to MAS 9.2.x + if not (isVersionEqualOrAfter("9.2.0", self.getParam("mas_channel")) and not isVersionEqualOrAfter("9.3.0", self.getParam("mas_channel"))): return False if self.mas_admin_mode in ["namespaced", "minimal"]: @@ -956,6 +964,8 @@ def configRoutingMode(self): if self.yesOrNo("Do you want to use Let's Encrypt for certificate management"): self.promptForString("Let's Encrypt e-mail", "mas_le_email") self.setParam("mas_cluster_issuer", f"{self.getParam('mas_instance_id')}-http01-le-prod") + if isVersionEqualOrAfter("9.3.0", self.getParam("mas_channel")): + self.setParam("mas_issuer_kind", "Issuer") def _checkIngressControllerForPathRouting(self, controllerName="default"): """Check if a specific IngressController exists and is configured for path-based routing. @@ -1135,6 +1145,8 @@ def configDNSAndCertsCloudflare(self): certIssuer = self._promptCertIssuer() self.setParam("mas_cluster_issuer", self._buildCertIssuerName(self.getParam("mas_instance_id"), "cloudflare", certIssuer)) + if isVersionEqualOrAfter("9.3.0", self.getParam("mas_channel")): + self.setParam("mas_issuer_kind", "ClusterIssuer") @logMethodCall def configDNSAndCertsCIS(self, configMas: bool, configAIService: bool): @@ -1149,6 +1161,8 @@ def configDNSAndCertsCIS(self, configMas: bool, configAIService: bool): self.setParam("mas_cluster_issuer", self._buildCertIssuerName(self.getParam("mas_instance_id"), "cis", certIssuer)) if configAIService: self.setParam("aiservice_certificate_issuer", self._buildCertIssuerName(self.getParam("aiservice_instance_id"), "cis", certIssuer)) + if isVersionEqualOrAfter("9.3.0", self.getParam("mas_channel")): + self.setParam("mas_issuer_kind", "ClusterIssuer") configEnhancedSecurity = self.yesOrNo("Configure enhanced security for CIS", "cis_enhanced_security") if configEnhancedSecurity: @@ -1189,6 +1203,8 @@ def configDNSAndCertsRoute53(self, configMas: bool, configAIService: bool): if configMas: self.setParam("mas_cluster_issuer", f"{self.getParam('mas_instance_id')}-route53-le-prod") + if isVersionEqualOrAfter("9.3.0", self.getParam("mas_channel")): + self.setParam("mas_issuer_kind", "ClusterIssuer") if configAIService: self.setParam("aiservice_certificate_issuer", f"{self.getParam('aiservice_instance_id')}-route53-le-prod") @@ -2626,47 +2642,61 @@ def nonInteractiveMode(self) -> None: f"--admin-mode is required for MAS version 9.2 or higher (selected channel: {self.getParam('mas_channel')}). Valid options: cluster, namespaced, minimal" ) - # Set issuer kind based on admin mode if not explicitly set - if self.getParam("mas_issuer_kind") == "": - if self.mas_admin_mode == "cluster": + if isVersionEqualOrAfter("9.3.0", self.getParam("mas_channel")): + # MAS 9.3+: --mas-issuer-kind is not supported — issuerKind is derived automatically. + if hasattr(self.args, "mas_issuer_kind") and self.args.mas_issuer_kind is not None: + self.fatalError( + f"--mas-issuer-kind is not supported on MAS 9.3+ (selected channel: {self.getParam('mas_channel')}).\n" + "issuerKind is derived automatically from your domain and routing configuration" + ) + if self.getParam("mas_routing_mode") == "path" and self.getParam("mas_le_email") != "": + self.setParam("mas_issuer_kind", "Issuer") + elif self.getParam("dns_provider") != "": self.setParam("mas_issuer_kind", "ClusterIssuer") else: self.setParam("mas_issuer_kind", "Issuer") + else: + # Set issuer kind based on admin mode if not explicitly set + if self.getParam("mas_issuer_kind") == "": + if self.mas_admin_mode == "cluster": + self.setParam("mas_issuer_kind", "ClusterIssuer") + else: + self.setParam("mas_issuer_kind", "Issuer") - # Validate ClusterIssuer requires cluster mode - if self.getParam("mas_issuer_kind") == "ClusterIssuer" and self.mas_admin_mode != "cluster": - self.fatalError( - "\n".join( - [ - "Invalid configuration for certificate issuer kind 'ClusterIssuer'", - "ClusterIssuer can only be used when --admin-mode cluster is selected.", - ] - ) - ) - - # Validate DNS integration restrictions - if self.getParam("dns_provider") != "": - if self.mas_admin_mode in ["namespaced", "minimal"]: + # Validate ClusterIssuer requires cluster mode + if self.getParam("mas_issuer_kind") == "ClusterIssuer" and self.mas_admin_mode != "cluster": self.fatalError( "\n".join( [ - f"Invalid configuration for admin mode '{self.mas_admin_mode}'", - "DNS integration is not available in this mode.", - "Remove DNS integration option --dns-provider, or switch to --admin-mode cluster and use --mas-issuer-kind ClusterIssuer.", + "Invalid configuration for certificate issuer kind 'ClusterIssuer'", + "ClusterIssuer can only be used when --admin-mode cluster is selected.", ] ) ) - if self.mas_admin_mode == "cluster" and self.getParam("mas_issuer_kind") == "Issuer": - self.fatalError( - "\n".join( - [ - "Invalid configuration for certificate issuer kind 'Issuer'", - "DNS integration is not available when --mas-issuer-kind Issuer is selected.", - "Remove DNS integration option --dns-provider, or use --mas-issuer-kind ClusterIssuer.", - ] + # Validate DNS integration restrictions + if self.getParam("dns_provider") != "": + if self.mas_admin_mode in ["namespaced", "minimal"]: + self.fatalError( + "\n".join( + [ + f"Invalid configuration for admin mode '{self.mas_admin_mode}'", + "DNS integration is not available in this mode.", + "Remove DNS integration option --dns-provider, or switch to --admin-mode cluster.", + ] + ) + ) + + if self.mas_admin_mode == "cluster" and self.getParam("mas_issuer_kind") == "Issuer": + self.fatalError( + "\n".join( + [ + "Invalid configuration for certificate issuer kind 'Issuer'", + "DNS integration is not available when --mas-issuer-kind Issuer is selected.", + "Remove DNS integration option --dns-provider, or use --mas-issuer-kind ClusterIssuer.", + ] + ) ) - ) else: if self.mas_admin_mode != "": self.fatalError(f"--admin-mode is not supported for MAS version 9.1 and earlier (selected channel: {self.getParam('mas_channel')})") From a5b1953ab0ce9966a05ad820f7190ceabae13bda Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Thu, 24 Sep 2026 11:50:27 +0530 Subject: [PATCH 2/4] [patch] remove upgrade_path versions --- python/src/mas/cli/cli.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/python/src/mas/cli/cli.py b/python/src/mas/cli/cli.py index 62a6d453a7..92bb266420 100644 --- a/python/src/mas/cli/cli.py +++ b/python/src/mas/cli/cli.py @@ -243,8 +243,6 @@ def __init__(self) -> None: } self.upgrade_path: Dict[str, str] = { - "9.2.x-dev": "9.3.x-pr.kind", - "9.2.x": "9.3.x-pr.kind", "9.2.x-feature": "9.2.x", "9.1.x": "9.2.x", "9.1.x-feature": "9.1.x", @@ -363,7 +361,7 @@ def createTektonFileWithDigest(self) -> None: @logMethodCall def getCompatibleVersions(self, coreChannel: str, appId: str) -> List[str]: if coreChannel in self.compatibilityMatrix: - return self.compatibilityMatrix[coreChannel].get(appId, []) + return self.compatibilityMatrix[coreChannel][appId] else: return [] @@ -659,4 +657,4 @@ def promptForEntitlementKey(self, message: str, param: str, repository: str = "c else: # choice == 3 # Quit logger.info("User chose to quit due to invalid entitlement key") - exit(1) + exit(1) \ No newline at end of file From cb55242138ebb7d3e60f388ac727dfde66950175 Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Thu, 24 Sep 2026 12:09:02 +0530 Subject: [PATCH 3/4] [patch] fix pre-commit --- python/src/mas/cli/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/src/mas/cli/cli.py b/python/src/mas/cli/cli.py index 92bb266420..aea9f48bda 100644 --- a/python/src/mas/cli/cli.py +++ b/python/src/mas/cli/cli.py @@ -657,4 +657,4 @@ def promptForEntitlementKey(self, message: str, param: str, repository: str = "c else: # choice == 3 # Quit logger.info("User chose to quit due to invalid entitlement key") - exit(1) \ No newline at end of file + exit(1) From bf3c65cdae87e0670ddf0e19305b182c6d9bfa8f Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Mon, 28 Sep 2026 14:14:42 +0530 Subject: [PATCH 4/4] [patch] add test cases for issuer kind --- .../integration/install/test_issuer_kind.py | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 python/tests/integration/install/test_issuer_kind.py diff --git a/python/tests/integration/install/test_issuer_kind.py b/python/tests/integration/install/test_issuer_kind.py new file mode 100644 index 0000000000..dbaf669f7e --- /dev/null +++ b/python/tests/integration/install/test_issuer_kind.py @@ -0,0 +1,478 @@ +#!/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 +# +# ***************************************************************************** + +""" +Test suite for issuerKind derivation and validation in MAS CLI. + +The CLI supports all MAS versions and issuerKind behaviour differs by version band: + + Pre-9.2 (e.g. 9.1.x): + - --mas-issuer-kind flag is not supported → fatalError if passed + - no issuerKind derivation occurs + + MAS 9.2.x — issuerKind is explicit: + Non-interactive: + - cluster mode, no flag → ClusterIssuer (default) + - namespaced/minimal mode → Issuer (forced, no prompt) + - --mas-issuer-kind flag → value respected + - ClusterIssuer + namespaced/minimal → fatalError + - DNS + namespaced/minimal → fatalError + - DNS + cluster + Issuer → fatalError + Interactive: + - cluster mode → user is prompted (1=Issuer, 2=ClusterIssuer) + - namespaced/minimal mode → Issuer set silently, no prompt + + MAS 9.3+ — issuerKind is auto-derived, flag is rejected: + - No DNS, no LE → Issuer (default domain) + - DNS provider configured → ClusterIssuer (custom domain) + - LE + path routing → Issuer (HTTP-01) + - LE + path priority > DNS → Issuer + - --mas-issuer-kind passed → fatalError + - admin mode no longer gates issuerKind for external certs +""" + +import sys +import os +import pytest +from unittest.mock import MagicMock +from mas.cli.install.app import InstallApp +from mas.cli.install.argParser import installArgParser + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +# ============================================================================= +# Test Helper Functions +# ============================================================================= + + +def create_mock_app(channel="9.3.x", routing_mode="subdomain", dns_provider="", le_email="", admin_mode="cluster", issuer_kind=""): + """Create a mock InstallApp suitable for issuerKind derivation tests.""" + app = MagicMock(spec=InstallApp) + app.dynamicClient = MagicMock() + app.showAdvancedOptions = True + app.isInteractiveMode = False + app.mas_admin_mode = admin_mode + app.params = {} + app.setParam = lambda key, value: app.params.__setitem__(key, value) + app.getParam = lambda key: app.params.get(key, "") + + app.promptForInt = MagicMock(return_value=1) + app.yesOrNo = MagicMock(return_value=True) + app.printDescription = MagicMock() + app.printH1 = MagicMock() + app.promptForString = MagicMock(return_value="") + app.fatalError = MagicMock(side_effect=SystemExit(1)) + + # args attribute — simulate no CLI flag passed by default + app.args = MagicMock() + app.args.mas_issuer_kind = None + + # Seed params + app.params["mas_channel"] = channel + app.params["mas_routing_mode"] = routing_mode + app.params["dns_provider"] = dns_provider + app.params["mas_le_email"] = le_email + app.params["mas_issuer_kind"] = issuer_kind + + return app + + +def run_issuer_kind_logic(app): + """ + Replicate the full issuerKind validation + derivation block from app.py, + covering pre-9.2, 9.2.x, and 9.3+ version bands. + """ + from mas.devops.utils import isVersionEqualOrAfter + + # pre-9.2: flag not supported at all + if app.getParam("mas_issuer_kind") != "" and not isVersionEqualOrAfter("9.2.0", app.getParam("mas_channel")): + app.fatalError(f"--mas-issuer-kind is only supported for MAS 9.2+ (selected channel: {app.getParam('mas_channel')})") + + if not isVersionEqualOrAfter("9.2.0", app.getParam("mas_channel")): + return # no further issuerKind logic for pre-9.2 + + if isVersionEqualOrAfter("9.3.0", app.getParam("mas_channel")): + # 9.3+: flag not accepted — issuerKind is derived from DNS/LE config + if hasattr(app.args, "mas_issuer_kind") and app.args.mas_issuer_kind is not None: + app.fatalError( + f"--mas-issuer-kind is not supported on MAS 9.3+ (selected channel: {app.getParam('mas_channel')}).\n" + "issuerKind is derived automatically from your domain and routing configuration" + ) + if app.getParam("mas_routing_mode") == "path" and app.getParam("mas_le_email") != "": + app.setParam("mas_issuer_kind", "Issuer") + elif app.getParam("dns_provider") != "": + app.setParam("mas_issuer_kind", "ClusterIssuer") + else: + app.setParam("mas_issuer_kind", "Issuer") + else: + # 9.2: derive from admin mode if not explicitly set, then validate + if app.getParam("mas_issuer_kind") == "": + if app.mas_admin_mode == "cluster": + app.setParam("mas_issuer_kind", "ClusterIssuer") + else: + app.setParam("mas_issuer_kind", "Issuer") + + # ClusterIssuer requires cluster admin mode + if app.getParam("mas_issuer_kind") == "ClusterIssuer" and app.mas_admin_mode != "cluster": + app.fatalError( + "\n".join( + [ + "Invalid configuration for certificate issuer kind 'ClusterIssuer'", + "ClusterIssuer can only be used when --admin-mode cluster is selected.", + ] + ) + ) + + # DNS integration restrictions + if app.getParam("dns_provider") != "": + if app.mas_admin_mode in ["namespaced", "minimal"]: + app.fatalError( + "\n".join( + [ + f"Invalid configuration for admin mode '{app.mas_admin_mode}'", + "DNS integration is not available in this mode.", + "Remove DNS integration option --dns-provider, or switch to --admin-mode cluster.", + ] + ) + ) + + if app.mas_admin_mode == "cluster" and app.getParam("mas_issuer_kind") == "Issuer": + app.fatalError( + "\n".join( + [ + "Invalid configuration for certificate issuer kind 'Issuer'", + "DNS integration is not available when --mas-issuer-kind Issuer is selected.", + "Remove DNS integration option --dns-provider, or use --mas-issuer-kind ClusterIssuer.", + ] + ) + ) + + +# ============================================================================= +# Pre-9.2 — --mas-issuer-kind flag not supported at all +# ============================================================================= + + +class TestIssuerKindPre92: + """Pre-9.2 MAS installs do not support --mas-issuer-kind in any form.""" + + @pytest.mark.parametrize("channel", ["9.1.x", "9.0.x", "8.11.x"]) + def test_flag_rejected_for_pre_92_channels(self, channel): + """--mas-issuer-kind is not supported before 9.2 → fatalError.""" + app = create_mock_app(channel=channel, issuer_kind="ClusterIssuer") + with pytest.raises(SystemExit): + run_issuer_kind_logic(app) + app.fatalError.assert_called_once() + assert "only supported for MAS 9.2+" in app.fatalError.call_args[0][0] + + @pytest.mark.parametrize("channel", ["9.1.x", "9.0.x"]) + def test_no_flag_no_error_pre_92(self, channel): + """Without the flag, pre-9.2 installs pass through with no issuerKind logic.""" + app = create_mock_app(channel=channel, issuer_kind="") + run_issuer_kind_logic(app) + app.fatalError.assert_not_called() + # No derivation happens — param stays empty + assert app.getParam("mas_issuer_kind") == "" + + +# ============================================================================= +# MAS 9.2 — Non-interactive: default derivation from admin mode +# ============================================================================= + + +class TestIssuerKind92NonInteractiveDefaults: + """MAS 9.2 non-interactive: issuerKind is derived from admin mode when flag is not set.""" + + def test_cluster_mode_no_flag_defaults_to_cluster_issuer(self): + """cluster admin mode + no flag → ClusterIssuer.""" + app = create_mock_app(channel="9.2.x", admin_mode="cluster") + run_issuer_kind_logic(app) + assert app.getParam("mas_issuer_kind") == "ClusterIssuer" + app.fatalError.assert_not_called() + + @pytest.mark.parametrize("admin_mode", ["namespaced", "minimal"]) + def test_restricted_mode_no_flag_defaults_to_issuer(self, admin_mode): + """namespaced/minimal admin mode + no flag → Issuer (forced).""" + app = create_mock_app(channel="9.2.x", admin_mode=admin_mode) + run_issuer_kind_logic(app) + assert app.getParam("mas_issuer_kind") == "Issuer" + app.fatalError.assert_not_called() + + def test_explicit_cluster_issuer_flag_in_cluster_mode(self): + """--mas-issuer-kind ClusterIssuer + cluster mode → accepted.""" + app = create_mock_app(channel="9.2.x", admin_mode="cluster", issuer_kind="ClusterIssuer") + run_issuer_kind_logic(app) + assert app.getParam("mas_issuer_kind") == "ClusterIssuer" + app.fatalError.assert_not_called() + + def test_explicit_issuer_flag_in_cluster_mode(self): + """--mas-issuer-kind Issuer + cluster mode → accepted.""" + app = create_mock_app(channel="9.2.x", admin_mode="cluster", issuer_kind="Issuer") + run_issuer_kind_logic(app) + assert app.getParam("mas_issuer_kind") == "Issuer" + app.fatalError.assert_not_called() + + +# ============================================================================= +# MAS 9.2 — Non-interactive: validation errors +# ============================================================================= + + +class TestIssuerKind92NonInteractiveValidation: + """MAS 9.2 non-interactive: invalid combinations must raise a fatal error.""" + + @pytest.mark.parametrize("admin_mode", ["namespaced", "minimal"]) + def test_cluster_issuer_in_restricted_mode_raises_fatal_error(self, admin_mode): + """ClusterIssuer + namespaced/minimal → fatalError.""" + app = create_mock_app(channel="9.2.x", admin_mode=admin_mode, issuer_kind="ClusterIssuer") + with pytest.raises(SystemExit): + run_issuer_kind_logic(app) + app.fatalError.assert_called_once() + assert "ClusterIssuer can only be used when --admin-mode cluster" in app.fatalError.call_args[0][0] + + @pytest.mark.parametrize("admin_mode", ["namespaced", "minimal"]) + def test_dns_provider_in_restricted_mode_raises_fatal_error(self, admin_mode): + """DNS provider + namespaced/minimal → fatalError (DNS not available in 9.2).""" + app = create_mock_app(channel="9.2.x", admin_mode=admin_mode, dns_provider="cloudflare") + with pytest.raises(SystemExit): + run_issuer_kind_logic(app) + app.fatalError.assert_called_once() + assert "DNS integration is not available in this mode" in app.fatalError.call_args[0][0] + + def test_dns_with_issuer_kind_in_cluster_mode_raises_fatal_error(self): + """DNS + cluster mode + Issuer → fatalError (DNS requires ClusterIssuer in 9.2).""" + app = create_mock_app(channel="9.2.x", admin_mode="cluster", dns_provider="cloudflare", issuer_kind="Issuer") + with pytest.raises(SystemExit): + run_issuer_kind_logic(app) + app.fatalError.assert_called_once() + assert "DNS integration is not available when --mas-issuer-kind Issuer" in app.fatalError.call_args[0][0] + + def test_dns_with_cluster_issuer_in_cluster_mode_is_valid(self): + """DNS + cluster mode + ClusterIssuer → valid in 9.2.""" + app = create_mock_app(channel="9.2.x", admin_mode="cluster", dns_provider="cloudflare", issuer_kind="ClusterIssuer") + run_issuer_kind_logic(app) + assert app.getParam("mas_issuer_kind") == "ClusterIssuer" + app.fatalError.assert_not_called() + + +# ============================================================================= +# MAS 9.2 — Interactive: admin mode prompt drives issuerKind +# ============================================================================= + + +class TestIssuerKind92Interactive: + """MAS 9.2 interactive: cluster mode prompts user; namespaced/minimal sets Issuer silently.""" + + def test_cluster_mode_user_selects_cluster_issuer(self): + """Interactive cluster mode, user picks ClusterIssuer (option 2) → ClusterIssuer.""" + from mas.devops.utils import isVersionEqualOrAfter + + app = create_mock_app(channel="9.2.x", admin_mode="cluster") + app.isInteractiveMode = True + + if not isVersionEqualOrAfter("9.3.0", app.getParam("mas_channel")): + if app.mas_admin_mode in ["namespaced", "minimal"]: + app.setParam("mas_issuer_kind", "Issuer") + else: + app.promptForInt.return_value = 2 # ClusterIssuer + issuerKindChoice = app.promptForInt("Certificate issuer kind", min=1, max=2, default=2) + app.setParam("mas_issuer_kind", "ClusterIssuer" if issuerKindChoice == 2 else "Issuer") + + assert app.getParam("mas_issuer_kind") == "ClusterIssuer" + app.promptForInt.assert_called_once() + + def test_cluster_mode_user_selects_issuer(self): + """Interactive cluster mode, user picks Issuer (option 1) → Issuer.""" + from mas.devops.utils import isVersionEqualOrAfter + + app = create_mock_app(channel="9.2.x", admin_mode="cluster") + app.isInteractiveMode = True + + if not isVersionEqualOrAfter("9.3.0", app.getParam("mas_channel")): + if app.mas_admin_mode in ["namespaced", "minimal"]: + app.setParam("mas_issuer_kind", "Issuer") + else: + app.promptForInt.return_value = 1 # Issuer + issuerKindChoice = app.promptForInt("Certificate issuer kind", min=1, max=2, default=2) + app.setParam("mas_issuer_kind", "ClusterIssuer" if issuerKindChoice == 2 else "Issuer") + + assert app.getParam("mas_issuer_kind") == "Issuer" + + @pytest.mark.parametrize("admin_mode", ["namespaced", "minimal"]) + def test_restricted_mode_sets_issuer_silently_no_prompt(self, admin_mode): + """Interactive namespaced/minimal: Issuer is set without prompting the user.""" + from mas.devops.utils import isVersionEqualOrAfter + + app = create_mock_app(channel="9.2.x", admin_mode=admin_mode) + app.isInteractiveMode = True + + if not isVersionEqualOrAfter("9.3.0", app.getParam("mas_channel")): + if app.mas_admin_mode in ["namespaced", "minimal"]: + app.setParam("mas_issuer_kind", "Issuer") + else: + issuerKindChoice = app.promptForInt("Certificate issuer kind", min=1, max=2, default=2) + app.setParam("mas_issuer_kind", "ClusterIssuer" if issuerKindChoice == 2 else "Issuer") + + assert app.getParam("mas_issuer_kind") == "Issuer" + app.promptForInt.assert_not_called() + + +# ============================================================================= +# MAS 9.3+ — Interactive: no issuerKind prompt shown +# ============================================================================= + + +class TestIssuerKind93Interactive: + """MAS 9.3+ interactive: issuerKind prompt is never shown; Issuer set as fallback.""" + + @pytest.mark.parametrize("admin_mode", ["cluster", "namespaced", "minimal"]) + def test_no_prompt_sets_issuer_as_fallback(self, admin_mode): + """Interactive 9.3+: no issuerKind prompt shown for any admin mode; Issuer set as fallback.""" + from mas.devops.utils import isVersionEqualOrAfter + + app = create_mock_app(channel="9.3.x", admin_mode=admin_mode) + app.isInteractiveMode = True + + if isVersionEqualOrAfter("9.3.0", app.getParam("mas_channel")): + app.setParam("mas_issuer_kind", "Issuer") + elif app.mas_admin_mode in ["namespaced", "minimal"]: + app.setParam("mas_issuer_kind", "Issuer") + else: + issuerKindChoice = app.promptForInt("Certificate issuer kind", min=1, max=2, default=2) + app.setParam("mas_issuer_kind", "ClusterIssuer" if issuerKindChoice == 2 else "Issuer") + + assert app.getParam("mas_issuer_kind") == "Issuer" + app.promptForInt.assert_not_called() + + +# ============================================================================= +# MAS 9.3+ — Non-interactive: auto-derived from DNS/LE config +# ============================================================================= + + +class TestIssuerKind93DefaultDomain: + """MAS 9.3+ non-interactive: no DNS, no LE — default domain → Issuer.""" + + def test_no_dns_no_le_subdomain_derives_issuer(self): + """Default install (subdomain, no DNS, no LE) → Issuer.""" + app = create_mock_app(channel="9.3.x", routing_mode="subdomain", dns_provider="", le_email="") + run_issuer_kind_logic(app) + assert app.getParam("mas_issuer_kind") == "Issuer" + app.fatalError.assert_not_called() + + def test_no_dns_no_le_path_mode_derives_issuer(self): + """Path routing without LE enabled (no email) → Issuer.""" + app = create_mock_app(channel="9.3.x", routing_mode="path", dns_provider="", le_email="") + run_issuer_kind_logic(app) + assert app.getParam("mas_issuer_kind") == "Issuer" + app.fatalError.assert_not_called() + + @pytest.mark.parametrize("admin_mode", ["cluster", "namespaced", "minimal"]) + def test_no_dns_issuer_regardless_of_admin_mode(self, admin_mode): + """9.3+: admin mode no longer gates issuerKind — always Issuer without DNS.""" + app = create_mock_app(channel="9.3.x", routing_mode="subdomain", dns_provider="", le_email="", admin_mode=admin_mode) + run_issuer_kind_logic(app) + assert app.getParam("mas_issuer_kind") == "Issuer" + app.fatalError.assert_not_called() + + +class TestIssuerKind93WithDNS: + """MAS 9.3+ non-interactive: DNS provider configured → ClusterIssuer automatically.""" + + @pytest.mark.parametrize("provider", ["cloudflare", "cis", "route53"]) + def test_dns_provider_derives_cluster_issuer(self, provider): + """Any DNS provider → ClusterIssuer.""" + app = create_mock_app(channel="9.3.x", routing_mode="subdomain", dns_provider=provider, le_email="") + run_issuer_kind_logic(app) + assert app.getParam("mas_issuer_kind") == "ClusterIssuer" + app.fatalError.assert_not_called() + + @pytest.mark.parametrize("admin_mode", ["cluster", "namespaced", "minimal"]) + def test_dns_cluster_issuer_all_admin_modes(self, admin_mode): + """9.3+: namespaced/minimal admins can now use ClusterIssuer for external certs.""" + app = create_mock_app(channel="9.3.x", routing_mode="subdomain", dns_provider="cloudflare", le_email="", admin_mode=admin_mode) + run_issuer_kind_logic(app) + assert app.getParam("mas_issuer_kind") == "ClusterIssuer" + app.fatalError.assert_not_called() + + +class TestIssuerKind93WithLetsEncrypt: + """MAS 9.3+ non-interactive: LE + path routing → Issuer automatically.""" + + def test_le_path_mode_derives_issuer(self): + """LE enabled + path routing → Issuer.""" + app = create_mock_app(channel="9.3.x", routing_mode="path", dns_provider="", le_email="admin@example.com") + run_issuer_kind_logic(app) + assert app.getParam("mas_issuer_kind") == "Issuer" + app.fatalError.assert_not_called() + + def test_le_path_takes_priority_over_dns_provider(self): + """LE + path mode takes priority over dns_provider → Issuer, not ClusterIssuer.""" + app = create_mock_app(channel="9.3.x", routing_mode="path", dns_provider="cloudflare", le_email="admin@example.com") + run_issuer_kind_logic(app) + assert app.getParam("mas_issuer_kind") == "Issuer" + app.fatalError.assert_not_called() + + +# ============================================================================= +# MAS 9.3+ — --mas-issuer-kind flag rejected +# ============================================================================= + + +class TestIssuerKindFlagRejectedOn93: + """Passing --mas-issuer-kind explicitly on a 9.3+ install must be a fatal error.""" + + @pytest.mark.parametrize("kind", ["Issuer", "ClusterIssuer"]) + def test_explicit_flag_raises_fatal_error(self, kind): + """--mas-issuer-kind Issuer and ClusterIssuer are both rejected on 9.3+.""" + app = create_mock_app(channel="9.3.x") + app.args.mas_issuer_kind = kind + with pytest.raises(SystemExit): + run_issuer_kind_logic(app) + app.fatalError.assert_called_once() + assert "9.3+" in app.fatalError.call_args[0][0] + assert "derived automatically" in app.fatalError.call_args[0][0] + + def test_flag_rejected_on_930_channel(self): + """Rejected on exact 9.3.0 channel as well.""" + app = create_mock_app(channel="9.3.0") + app.args.mas_issuer_kind = "Issuer" + with pytest.raises(SystemExit): + run_issuer_kind_logic(app) + app.fatalError.assert_called_once() + + def test_no_flag_no_error(self): + """No --mas-issuer-kind passed (None) → no fatal error, derivation proceeds normally.""" + app = create_mock_app(channel="9.3.x") + app.args.mas_issuer_kind = None + run_issuer_kind_logic(app) + app.fatalError.assert_not_called() + assert app.getParam("mas_issuer_kind") == "Issuer" + + def test_argparser_still_accepts_flag_for_92(self): + """argParser still accepts --mas-issuer-kind for 9.2 installs (parser is version-agnostic).""" + argv = [ + "--mas-instance-id", + "testinst", + "--mas-workspace-id", + "testws", + "--mas-channel", + "9.2.0", + "--admin-mode", + "cluster", + "--mas-issuer-kind", + "ClusterIssuer", + "--accept-license", + "--no-confirm", + ] + args = installArgParser.parse_args(args=argv) + assert args.mas_issuer_kind == "ClusterIssuer"