From bac5b59824e93f60eccfd90f6f83a66f3c425ee8 Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Tue, 18 Aug 2026 16:25:23 +0530 Subject: [PATCH 1/9] [patch] add required params for pbr let's encrypt support --- .../src/pipelines/taskdefs/core/suite-dns.yml.j2 | 4 ++++ tekton/src/tasks/suite-dns.yml.j2 | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 b/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 index a764e6c05d9..3f1043b37b7 100644 --- a/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 +++ b/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 @@ -64,6 +64,10 @@ value: $(params.route53_subdomain) - name: custom_labels value: $(params.custom_labels) + - name: mas_issuer_kind + value: $(params.mas_issuer_kind) + - name: mas_ingress_controller_name + value: $(params.mas_ingress_controller_name) taskRef: kind: Task name: mas-devops-suite-dns \ No newline at end of file diff --git a/tekton/src/tasks/suite-dns.yml.j2 b/tekton/src/tasks/suite-dns.yml.j2 index cf0af0ff5ce..46329f0c447 100644 --- a/tekton/src/tasks/suite-dns.yml.j2 +++ b/tekton/src/tasks/suite-dns.yml.j2 @@ -103,6 +103,16 @@ spec: description: Optional string parameter, either path or subdomain, defines the network routing mode used for the suite. default: "" + # Certificate issuer kind — ClusterIssuer (cluster mode) or Issuer (namespaced/minimal) + - name: mas_issuer_kind + type: string + default: "" + + # IngressController name used to derive the HTTP-01 solver ingressClassName + - name: mas_ingress_controller_name + type: string + default: "" + stepTemplate: env: {{ lookup('template', task_src_dir ~ '/common/cli-env.yml.j2') | indent(6) }} @@ -165,6 +175,10 @@ spec: value: $(params.mas_manual_cert_mgmt) - name: MAS_ROUTING_MODE value: $(params.mas_routing_mode) + - name: MAS_ISSUER_KIND + value: $(params.mas_issuer_kind) + - name: MAS_INGRESS_CONTROLLER_NAME + value: $(params.mas_ingress_controller_name) steps: - name: suite-dns From 954c94645e9efb663e57586c1aa32d3dfd2dcf68 Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Fri, 21 Aug 2026 12:05:59 +0530 Subject: [PATCH 2/9] [patch] update condition for non-/interective mode --- python/src/mas/cli/install/app.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/python/src/mas/cli/install/app.py b/python/src/mas/cli/install/app.py index 4f76437ea58..9a98331f7be 100644 --- a/python/src/mas/cli/install/app.py +++ b/python/src/mas/cli/install/app.py @@ -719,18 +719,23 @@ def configAdminMode(self): " 1. cluster - Install with ClusterRoles (default)", " - MAS has cluster-level access to manage its applications and resources across the cluster", " - CLI pre-installs ClusterRoles to grant delegated admin permissions to MAS service accounts", + " - Supports ClusterIssuer or Issuer certificate kind", + " - ClusterIssuer: CLI-managed DNS integration available for both path and subdomain routing", + " - Issuer: CLI-managed DNS integration available for path-based routing only", "", " 2. namespaced - Install with namespace-scoped Roles only", " - 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.", + " - Certificate issuer kind is fixed to 'Issuer'", + " - CLI-managed DNS integration available for path-based routing only; not available in subdomain routing", "", " 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.", + " - Certificate issuer kind is fixed to 'Issuer'", + " - CLI-managed DNS integration available for path-based routing only; not available in subdomain routing", ] ) @@ -777,8 +782,8 @@ def _handleDNSIntegrationRestriction(self): self.printDescription( [ "You selected Issuer as the certificate issuer kind.", - "DNS integration is not available when the certificate issuer kind is Issuer.", - "If you use a custom domain, you need to configure DNS manually.", + "CLI-managed DNS integration with Issuer is only supported in path-based routing mode.", + "If you plan to use subdomain routing, you need to configure DNS manually.", ] ) return True @@ -2549,24 +2554,24 @@ def nonInteractiveMode(self) -> None: # Validate DNS integration restrictions if self.getParam("dns_provider") != "": - if self.mas_admin_mode in ["namespaced", "minimal"]: + if self.mas_admin_mode in ["namespaced", "minimal"] and self.getParam("mas_routing_mode") != "path": 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.", + "DNS integration is only available in path-based routing mode for this admin mode.", + "Add --routing path, or switch to --admin-mode cluster and use --mas-issuer-kind ClusterIssuer.", ] ) ) - if self.mas_admin_mode == "cluster" and self.getParam("mas_issuer_kind") == "Issuer": + if self.mas_admin_mode == "cluster" and self.getParam("mas_issuer_kind") == "Issuer" and self.getParam("mas_routing_mode") != "path": 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.", + "Invalid configuration: --admin-mode cluster with --mas-issuer-kind Issuer requires --routing path.", + "DNS integration with Issuer in cluster mode is only supported in path-based routing mode.", + "Either add --routing path, or use --mas-issuer-kind ClusterIssuer.", ] ) ) From 3265030105608b16438de89e062418f1876c9ec5 Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Mon, 31 Aug 2026 10:12:44 +0530 Subject: [PATCH 3/9] [patch] setup new prompts and flags for let's encrypt http01 --- python/src/mas/cli/install/app.py | 52 +++++++++++++++++++ python/src/mas/cli/install/argParser.py | 16 ++++++ python/src/mas/cli/install/params.py | 3 ++ python/src/mas/cli/install/summarizer.py | 6 +++ tekton/src/params/install.yml.j2 | 8 +++ .../pipelines/taskdefs/core/suite-dns.yml.j2 | 6 +++ tekton/src/tasks/suite-dns.yml.j2 | 17 ++++++ 7 files changed, 108 insertions(+) diff --git a/python/src/mas/cli/install/app.py b/python/src/mas/cli/install/app.py index 9a98331f7be..b7102f15126 100644 --- a/python/src/mas/cli/install/app.py +++ b/python/src/mas/cli/install/app.py @@ -937,6 +937,22 @@ def configRoutingMode(self): else: self.setParam("mas_routing_mode", "subdomain") + # After routing mode is confirmed as path, offer Let's Encrypt HTTP-01. + if self.getParam("mas_routing_mode") == "path": + self.printDescription( + [ + "", + "Let's Encrypt HTTP-01 can automatically issue trusted certificates for your domain", + "Note: your cluster must be publicly accessible on port 80 from the internet.", + ] + ) + if self.yesOrNo("Do you want to use Let's Encrypt for certificate management"): + self.setParam("mas_letsencrypt_http01_enabled", "true") + self.promptForString("Let's Encrypt e-mail", "mas_le_email") + self.setParam("mas_cluster_issuer", f"{self.getParam('mas_instance_id')}-http01-le-prod") + else: + self.setParam("mas_letsencrypt_http01_enabled", "false") + def _checkIngressControllerForPathRouting(self, controllerName="default"): """Check if a specific IngressController exists and is configured for path-based routing. @@ -2873,6 +2889,42 @@ def install(self, argv): else: logger.info(f"IngressController '{ingressControllerName}' is already configured for path-based routing") + # Validate Let's Encrypt HTTP-01 flags + if not self.isInteractiveMode: + leEnabled = self.getParam("mas_letsencrypt_http01_enabled") == "true" + routingMode = self.getParam("mas_routing_mode") + + if leEnabled and routingMode != "path": + self.fatalError( + "\n".join( + [ + "Let's Encrypt HTTP-01 Requires Path-Based Routing", + "========================================================================", + "--letsencrypt is only supported with path-based routing mode.", + "Remove --letsencrypt or switch to path routing:", + " mas install --routing path --letsencrypt --le-email ...", + ] + ) + ) + + if leEnabled: + leEmail = self.getParam("mas_le_email") + if not leEmail: + self.fatalError( + "\n".join( + [ + "Let's Encrypt E-mail Required", + "========================================================================", + "--le-email must be provided when --letsencrypt is set.", + " mas install --routing path --letsencrypt --le-email ...", + ] + ) + ) + if not self.getParam("mas_cluster_issuer"): + self.setParam("mas_cluster_issuer", f"{self.getParam('mas_instance_id')}-http01-le-prod") + else: + self.setParam("mas_letsencrypt_http01_enabled", "false") + # Based on the parameters set the annotations correctly self.configAnnotations() self.displayInstallSummary() diff --git a/python/src/mas/cli/install/argParser.py b/python/src/mas/cli/install/argParser.py index bc0a5c6850c..1becb530015 100644 --- a/python/src/mas/cli/install/argParser.py +++ b/python/src/mas/cli/install/argParser.py @@ -243,6 +243,22 @@ def isValidFile(parser: argparse.ArgumentParser, arg: str) -> str: help="Provide the name of the ClusterIssuer to configure MAS to issue certificates", ) +masAdvancedArgGroup.add_argument( + "--letsencrypt", + dest="mas_letsencrypt_http01_enabled", + required=False, + action="store_const", + const="true", + help="Enable Let's Encrypt HTTP-01 certificate management (path routing mode only, cluster must be publicly accessible on port 80)", +) + +masAdvancedArgGroup.add_argument( + "--le-email", + dest="mas_le_email", + required=False, + help="E-mail address to register with Let's Encrypt (required when --letsencrypt is set)", +) + masAdvancedArgGroup.add_argument( "--mas-issuer-kind", dest="mas_issuer_kind", diff --git a/python/src/mas/cli/install/params.py b/python/src/mas/cli/install/params.py index ef292b2a0e9..9f29f750206 100644 --- a/python/src/mas/cli/install/params.py +++ b/python/src/mas/cli/install/params.py @@ -75,6 +75,9 @@ "dns_provider", "mas_cluster_issuer", "ocp_ingress", + # Let's Encrypt HTTP-01 + "mas_letsencrypt_http01_enabled", + "mas_le_email", # CIS "cis_email", "cis_apikey", diff --git a/python/src/mas/cli/install/summarizer.py b/python/src/mas/cli/install/summarizer.py index 0bda7a81902..dcd40be94cb 100644 --- a/python/src/mas/cli/install/summarizer.py +++ b/python/src/mas/cli/install/summarizer.py @@ -153,6 +153,12 @@ def masSummary(self) -> None: if self.getParam("mas_routing_mode") == "path": self.printParamSummary("IngressController Name", "mas_ingress_controller_name") self.printParamSummary("Configure IngressController", "mas_configure_ingress") + if self.getParam("mas_letsencrypt_http01_enabled") == "true": + self.printSummary("Let's Encrypt HTTP-01", "Enabled") + self.printParamSummary("Let's Encrypt e-mail", "mas_le_email") + self.printParamSummary("Let's Encrypt Issuer", "mas_cluster_issuer") + else: + self.printSummary("Let's Encrypt HTTP-01", "Disabled") if self.getParam("mas_manual_route_mgmt") == "true": self.printParamSummary("Manual Routes", "mas_manual_route_mgmt") diff --git a/tekton/src/params/install.yml.j2 b/tekton/src/params/install.yml.j2 index caf2ef6cd6d..59d0d7fae3e 100644 --- a/tekton/src/params/install.yml.j2 +++ b/tekton/src/params/install.yml.j2 @@ -465,6 +465,14 @@ type: string default: "" description: Name of the IngressController to use for path-based routing +- name: mas_letsencrypt_http01_enabled + type: string + default: "false" + description: Enable Let's Encrypt HTTP-01 certificate management (path routing mode only, cluster must be publicly accessible on port 80) +- name: mas_le_email + type: string + default: "" + description: E-mail address to register with Let's Encrypt (required when mas_letsencrypt_http01_enabled is true) - name: mas_trust_default_cas type: string default: "" diff --git a/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 b/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 index 3f1043b37b7..bf45b971273 100644 --- a/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 +++ b/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 @@ -68,6 +68,12 @@ value: $(params.mas_issuer_kind) - name: mas_ingress_controller_name value: $(params.mas_ingress_controller_name) + - name: mas_letsencrypt_http01_enabled + value: $(params.mas_letsencrypt_http01_enabled) + - name: mas_le_email + value: $(params.mas_le_email) + - name: mas_cluster_issuer + value: $(params.mas_cluster_issuer) taskRef: kind: Task name: mas-devops-suite-dns \ No newline at end of file diff --git a/tekton/src/tasks/suite-dns.yml.j2 b/tekton/src/tasks/suite-dns.yml.j2 index 46329f0c447..6c5ac4e5aab 100644 --- a/tekton/src/tasks/suite-dns.yml.j2 +++ b/tekton/src/tasks/suite-dns.yml.j2 @@ -113,6 +113,17 @@ spec: type: string default: "" + # Let's Encrypt HTTP-01 + - name: mas_letsencrypt_http01_enabled + type: string + default: "false" + - name: mas_le_email + type: string + default: "" + - name: mas_cluster_issuer + type: string + default: "" + stepTemplate: env: {{ lookup('template', task_src_dir ~ '/common/cli-env.yml.j2') | indent(6) }} @@ -179,6 +190,12 @@ spec: value: $(params.mas_issuer_kind) - name: MAS_INGRESS_CONTROLLER_NAME value: $(params.mas_ingress_controller_name) + - name: MAS_LETSENCRYPT_HTTP01 + value: $(params.mas_letsencrypt_http01_enabled) + - name: MAS_LE_EMAIL + value: $(params.mas_le_email) + - name: MAS_CLUSTER_ISSUER + value: $(params.mas_cluster_issuer) steps: - name: suite-dns From 9c42d7e8ba615762e421d673a7c9d17a1b8adb24 Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Mon, 31 Aug 2026 11:22:32 +0530 Subject: [PATCH 4/9] [patch] add test cases for let's encrypt and fix breaking tests --- .../install/test_arcgis_cluster_mode.py | 6 + .../integration/install/test_dev_mode.py | 4 +- .../integration/install/test_letsencrypt.py | 464 ++++++++++++++++++ 3 files changed, 473 insertions(+), 1 deletion(-) mode change 100644 => 100755 python/tests/integration/install/test_arcgis_cluster_mode.py create mode 100644 python/tests/integration/install/test_letsencrypt.py diff --git a/python/tests/integration/install/test_arcgis_cluster_mode.py b/python/tests/integration/install/test_arcgis_cluster_mode.py old mode 100644 new mode 100755 index 26eaf628534..4e7299e756e --- a/python/tests/integration/install/test_arcgis_cluster_mode.py +++ b/python/tests/integration/install/test_arcgis_cluster_mode.py @@ -37,6 +37,8 @@ def test_install_arcgis_cluster_mode_success(tmpdir): # 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 + # 4.1. Let's Encrypt HTTP-01 - decline, use existing cert setup + ".*Do you want to use Let.*s Encrypt.*": lambda msg: "n", # 5. Service Mesh Configuration ".*Enable OpenShift Service Mesh support for MAS.*": lambda msg: "y", # 6. Configure IngressController for path-based routing @@ -200,6 +202,8 @@ def test_install_arcgis_namespace_mode_error(tmpdir, caplog): ".*Select channel.*": lambda msg: "9.2.x-dev", # 4. Routing Mode Configuration ".*Routing Mode.*": lambda msg: "1", + # 4.1. Let's Encrypt HTTP-01 - decline, use existing cert setup + ".*Do you want to use Let.*s Encrypt.*": lambda msg: "n", # 5. Service Mesh Configuration ".*Enable OpenShift Service Mesh support for MAS.*": lambda msg: "y", # 6. Configure IngressController @@ -342,6 +346,8 @@ def test_install_arcgis_minimal_mode_error(tmpdir, caplog): ".*Select channel.*": lambda msg: "9.2.x-dev", # 4. Routing Mode Configuration ".*Routing Mode.*": lambda msg: "1", + # 4.1. Let's Encrypt HTTP-01 - decline, use existing cert setup + ".*Do you want to use Let.*s Encrypt.*": lambda msg: "n", # 5. Service Mesh Configuration ".*Enable OpenShift Service Mesh support for MAS.*": lambda msg: "y", # 6. Configure IngressController diff --git a/python/tests/integration/install/test_dev_mode.py b/python/tests/integration/install/test_dev_mode.py index dc51bf7bd9d..474faa65112 100644 --- a/python/tests/integration/install/test_dev_mode.py +++ b/python/tests/integration/install/test_dev_mode.py @@ -250,7 +250,9 @@ def test_install_master_dev_mode_with_path_routing(tmpdir): # 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. Service Mesh Configuration - to use service mesg + # 4.1. Let's Encrypt HTTP-01 - decline, use existing cert setup + ".*Do you want to use Let.*s Encrypt.*": lambda msg: "n", + # 5. Service Mesh Configuration - to use service mesh ".*Enable OpenShift Service Mesh support for MAS.*": lambda msg: "y", # Select to use service mesh # 5. Configure IngressController for path-based routing ".*Configure ingress namespace ownership.*": lambda msg: "y", # Agree to configure diff --git a/python/tests/integration/install/test_letsencrypt.py b/python/tests/integration/install/test_letsencrypt.py new file mode 100644 index 00000000000..a20f3ef15d1 --- /dev/null +++ b/python/tests/integration/install/test_letsencrypt.py @@ -0,0 +1,464 @@ +#!/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 Let's Encrypt HTTP-01 certificate management in MAS CLI. + +Covers: + Interactive mode: + - User opts in → mas_letsencrypt_http01_enabled=true, email collected, mas_cluster_issuer set + - User opts out → mas_letsencrypt_http01_enabled=false, no email prompt + - Only shown when routing mode is path (subdomain skips it entirely) + + Non-interactive mode: + - --letsencrypt + --le-email + --routing path → valid, mas_cluster_issuer auto-set + - --letsencrypt without --le-email → fatalError + - --letsencrypt + --routing subdomain → fatalError + - --letsencrypt + --mas-cluster-issuer custom → custom issuer respected + - no --letsencrypt → mas_letsencrypt_http01_enabled=false +""" + +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 +from utils.install_test_helper import InstallTestConfig, run_install_test + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# ============================================================================= +# Helpers (mirrors pattern in test_routing_mode.py) +# ============================================================================= + + +def create_mock_app(instance_id="testinst"): + """Create a mock InstallApp suitable for unit-level LE tests.""" + app = MagicMock(spec=InstallApp) + app.dynamicClient = MagicMock() + app.showAdvancedOptions = True + app.isInteractiveMode = True + 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() + + # promptForString side-effect: store supplied value and return it + def _prompt_for_string(label, param, **kwargs): + # simulate user entering the label text as the value + val = f"user-{param}" + app.params[param] = val + return val + + app.promptForString = MagicMock(side_effect=_prompt_for_string) + app.fatalError = MagicMock(side_effect=SystemExit(1)) + + # Seed required params + app.params["mas_instance_id"] = instance_id + app.params["mas_routing_mode"] = "path" + + # Bind the real method under test + app.configRoutingMode = InstallApp.configRoutingMode.__get__(app, InstallApp) + app._checkIngressControllerPermissions = MagicMock(return_value=True) + app._checkIngressControllerForPathRouting = MagicMock(return_value=(True, True)) + app._promptForIngressController = MagicMock(return_value="default") + app._getMasDomainForDisplay = MagicMock(return_value="example.com") + + return app + + +# ============================================================================= +# Interactive Mode Tests +# ============================================================================= + + +class TestInteractiveLetsEncrypt: + """Interactive prompt flow for Let's Encrypt HTTP-01.""" + + def test_user_opts_in_sets_enabled_flag_email_and_cluster_issuer(self): + """When user says yes to LE, enabled=true, email collected, cluster issuer set.""" + app = create_mock_app(instance_id="inst1") + # Routing mode already confirmed as path + app.params["mas_routing_mode"] = "path" + + # Simulate: yesOrNo("Do you want to use LetsEncrypt...") → True + app.yesOrNo = MagicMock(return_value=True) + + # Run only the LE block (after routing mode is set) + # Replicate the block from configRoutingMode for isolation + if app.getParam("mas_routing_mode") == "path": + app.printDescription([]) + if app.yesOrNo("Do you want to use LetsEncrypt for certificate management"): + app.setParam("mas_letsencrypt_http01_enabled", "true") + app.promptForString("Let's Encrypt e-mail", "mas_le_email") + app.setParam("mas_cluster_issuer", f"{app.getParam('mas_instance_id')}-http01-le-prod") + else: + app.setParam("mas_letsencrypt_http01_enabled", "false") + + assert app.getParam("mas_letsencrypt_http01_enabled") == "true" + assert app.getParam("mas_le_email") == "user-mas_le_email" + assert app.getParam("mas_cluster_issuer") == "inst1-http01-le-prod" + app.promptForString.assert_called_once() + + def test_user_opts_out_sets_disabled_flag_and_no_email_prompt(self): + """When user says no to LE, enabled=false and email is never prompted.""" + app = create_mock_app(instance_id="inst1") + app.params["mas_routing_mode"] = "path" + app.yesOrNo = MagicMock(return_value=False) + + if app.getParam("mas_routing_mode") == "path": + app.printDescription([]) + if app.yesOrNo("Do you want to use LetsEncrypt for certificate management"): + app.setParam("mas_letsencrypt_http01_enabled", "true") + app.promptForString("Let's Encrypt e-mail", "mas_le_email") + app.setParam("mas_cluster_issuer", f"{app.getParam('mas_instance_id')}-http01-le-prod") + else: + app.setParam("mas_letsencrypt_http01_enabled", "false") + + assert app.getParam("mas_letsencrypt_http01_enabled") == "false" + assert app.getParam("mas_cluster_issuer") == "" + app.promptForString.assert_not_called() + + def test_le_prompt_not_shown_for_subdomain_routing(self): + """LE prompt must not appear when routing mode is subdomain.""" + app = create_mock_app() + app.params["mas_routing_mode"] = "subdomain" + app.yesOrNo = MagicMock(return_value=True) + + if app.getParam("mas_routing_mode") == "path": + app.printDescription([]) + if app.yesOrNo("Do you want to use LetsEncrypt for certificate management"): + app.setParam("mas_letsencrypt_http01_enabled", "true") + + # yesOrNo should never have been called (routing is subdomain) + app.yesOrNo.assert_not_called() + assert app.getParam("mas_letsencrypt_http01_enabled") == "" + + +# ============================================================================= +# Non-Interactive: argParser flag tests +# ============================================================================= + + +class TestArgParserLetsEncrypt: + """Verify --letsencrypt and --le-email are parsed correctly.""" + + def _base_argv(self): + return [ + "--mas-instance-id", + "testinst", + "--mas-workspace-id", + "testws", + "--mas-channel", + "9.2.0", + "--admin-mode", + "cluster", + "--routing", + "path", + "--accept-license", + "--no-confirm", + ] + + def test_letsencrypt_flag_sets_enabled_true(self): + argv = self._base_argv() + ["--letsencrypt", "--le-email", "test@ibm.com"] + args = installArgParser.parse_args(args=argv) + assert args.mas_letsencrypt_http01_enabled == "true" + assert args.mas_le_email == "test@ibm.com" + + def test_no_letsencrypt_flag_defaults_to_none(self): + """Without --letsencrypt the dest is None (not yet set to false — that happens in validation).""" + argv = self._base_argv() + args = installArgParser.parse_args(args=argv) + assert args.mas_letsencrypt_http01_enabled is None + + def test_le_email_without_letsencrypt_is_accepted_by_parser(self): + """Parser accepts --le-email on its own; validation rejects it later.""" + argv = self._base_argv() + ["--le-email", "test@ibm.com"] + args = installArgParser.parse_args(args=argv) + assert args.mas_le_email == "test@ibm.com" + assert args.mas_letsencrypt_http01_enabled is None + + def test_letsencrypt_with_subdomain_routing_is_parsed(self): + """Parser does not reject this combination; app validation does.""" + argv = [ + "--mas-instance-id", + "testinst", + "--mas-workspace-id", + "testws", + "--mas-channel", + "9.2.0", + "--admin-mode", + "cluster", + "--routing", + "subdomain", + "--letsencrypt", + "--le-email", + "test@ibm.com", + "--accept-license", + "--no-confirm", + ] + args = installArgParser.parse_args(args=argv) + assert args.mas_routing_mode == "subdomain" + assert args.mas_letsencrypt_http01_enabled == "true" + + def test_custom_cluster_issuer_preserved_with_letsencrypt(self): + """--mas-cluster-issuer is parsed alongside --letsencrypt.""" + argv = self._base_argv() + [ + "--letsencrypt", + "--le-email", + "test@ibm.com", + "--mas-cluster-issuer", + "my-custom-issuer", + ] + args = installArgParser.parse_args(args=argv) + assert args.mas_letsencrypt_http01_enabled == "true" + assert args.mas_cluster_issuer == "my-custom-issuer" + + +# ============================================================================= +# Non-Interactive: validation logic tests +# ============================================================================= + + +class TestNonInteractiveLetsEncryptValidation: + """Validate the non-interactive LE validation block in app.py.""" + + def _make_app(self, routing_mode="path", le_enabled="true", le_email="test@ibm.com", cluster_issuer="", instance_id="testinst"): + app = MagicMock(spec=InstallApp) + app.isInteractiveMode = False + app.params = { + "mas_routing_mode": routing_mode, + "mas_letsencrypt_http01_enabled": le_enabled, + "mas_le_email": le_email, + "mas_cluster_issuer": cluster_issuer, + "mas_instance_id": instance_id, + } + app.setParam = lambda key, value: app.params.__setitem__(key, value) + app.getParam = lambda key: app.params.get(key, "") + app.fatalError = MagicMock(side_effect=SystemExit(1)) + return app + + def _run_validation(self, app): + """Replicate the non-interactive LE validation block from app.py.""" + leEnabled = app.getParam("mas_letsencrypt_http01_enabled") == "true" + routingMode = app.getParam("mas_routing_mode") + + if leEnabled and routingMode != "path": + app.fatalError("Let's Encrypt HTTP-01 Requires Path-Based Routing") + + if leEnabled: + leEmail = app.getParam("mas_le_email") + if not leEmail: + app.fatalError("Let's Encrypt E-mail Required") + if not app.getParam("mas_cluster_issuer"): + app.setParam("mas_cluster_issuer", f"{app.getParam('mas_instance_id')}-http01-le-prod") + else: + app.setParam("mas_letsencrypt_http01_enabled", "false") + + def test_valid_le_path_routing_sets_cluster_issuer(self): + """Valid config: LE enabled + path routing + email → cluster issuer auto-set.""" + app = self._make_app(routing_mode="path", le_enabled="true", le_email="test@ibm.com", cluster_issuer="") + self._run_validation(app) + assert app.getParam("mas_cluster_issuer") == "testinst-http01-le-prod" + app.fatalError.assert_not_called() + + def test_valid_le_path_routing_custom_issuer_preserved(self): + """When user provides --mas-cluster-issuer, it is NOT overwritten.""" + app = self._make_app(routing_mode="path", le_enabled="true", le_email="test@ibm.com", cluster_issuer="my-custom-issuer") + self._run_validation(app) + assert app.getParam("mas_cluster_issuer") == "my-custom-issuer" + app.fatalError.assert_not_called() + + def test_le_with_subdomain_routing_raises_fatal_error(self): + """--letsencrypt + --routing subdomain must trigger a fatal error.""" + app = self._make_app(routing_mode="subdomain", le_enabled="true", le_email="test@ibm.com") + with pytest.raises(SystemExit): + self._run_validation(app) + app.fatalError.assert_called_once() + assert "Path-Based Routing" in app.fatalError.call_args[0][0] + + def test_le_without_email_raises_fatal_error(self): + """--letsencrypt without --le-email must trigger a fatal error.""" + app = self._make_app(routing_mode="path", le_enabled="true", le_email="") + with pytest.raises(SystemExit): + self._run_validation(app) + app.fatalError.assert_called_once() + assert "E-mail Required" in app.fatalError.call_args[0][0] + + def test_no_le_sets_enabled_false(self): + """Without --letsencrypt, mas_letsencrypt_http01_enabled is explicitly set to false.""" + app = self._make_app(routing_mode="path", le_enabled="false", le_email="") + self._run_validation(app) + assert app.getParam("mas_letsencrypt_http01_enabled") == "false" + assert app.getParam("mas_cluster_issuer") == "" + app.fatalError.assert_not_called() + + def test_le_cluster_issuer_name_matches_ansible_default(self): + """Issuer name must be -http01-le-prod to match mas_http01_le_prod_issuer_name.""" + app = self._make_app(routing_mode="path", le_enabled="true", le_email="test@ibm.com", cluster_issuer="", instance_id="myinstance") + self._run_validation(app) + assert app.getParam("mas_cluster_issuer") == "myinstance-http01-le-prod" + + +# ============================================================================= +# Integration: interactive flow via full install harness +# ============================================================================= + + +class TestInteractiveLetsEncryptIntegration: + """Full interactive install flow tests covering the LE prompt.""" + + def _common_handlers(self, tmpdir, le_response): + """Full prompt handler set for a 9.2.x-dev advanced install with path routing. + + Installs IoT + Monitor + Manage so that the system Db2 and Kafka prompts + are exercised — matching the same app mix as test_dev_mode path routing test. + """ + return { + # Cluster connection + ".*Proceed with this cluster?.*": lambda msg: "y", + ".*Show advanced installation options.*": lambda msg: "y", + # Catalog + ".*Select catalog.*": lambda msg: "v9-master-amd64", + ".*Select channel.*": lambda msg: "9.2.x-dev", + # Routing Mode - path + ".*Routing Mode.*": lambda msg: "1", + # Let's Encrypt prompt - controlled by caller + ".*Do you want to use Let.s Encrypt.*": lambda msg: le_response, + # Service Mesh + ".*Enable OpenShift Service Mesh support for MAS.*": lambda msg: "y", + # IngressController config + ".*Configure ingress namespace ownership.*": lambda msg: "y", + # Storage + ".*Use the auto-detected storage classes.*": lambda msg: "y", + # SLS + ".*SLS Mode.*": lambda msg: "1", + ".*SLS channel.*": lambda msg: "1.x-stable", + ".*>License file<.*": lambda msg: f"{tmpdir}/authorized_entitlement.lic", + ".*>Db2 License file<.*": lambda msg: "", + # DRO + ".*DRO.*Namespace.*": lambda msg: "", + ".*Contact e-mail address.*": lambda msg: "maximo@ibm.com", + ".*Contact first name.*": lambda msg: "Test", + ".*Contact last name.*": lambda msg: "Test", + # Credentials + ".*IBM entitlement key.*": lambda msg: "testEntitlementKey", + ".*Artifactory username.*": lambda msg: "testUsername", + ".*Artifactory token.*": lambda msg: "testToken", + # MAS instance + ".*Instance ID.*": lambda msg: "testinst", + ".*Workspace ID.*": lambda msg: "testws", + ".*Workspace.*name.*": lambda msg: "Test Workspace", + ".*Operational Mode.*": lambda msg: "1", + ".*Mas Admin Mode.*": lambda msg: "1", + ".*Certificate issuer kind.*": lambda msg: "2", + ".*Trust default CAs.*": lambda msg: "y", + ".*Cluster ingress certificate secret name.*": lambda msg: "", + ".*Configure domain.*certificate management.*": lambda msg: "n", + ".*Configure SSO properties.*": lambda msg: "n", + ".*Allow special characters.*": lambda msg: "n", + ".*Enable Guided Tour.*": lambda msg: "y", + ".*Enable feature adoption metrics.*": lambda msg: "y", + ".*Enable deployment progression metrics.*": lambda msg: "y", + ".*Enable usability metrics.*": lambda msg: "y", + # Apps — IoT + Monitor + Manage (same mix as dev_mode path routing test) + ".*Install IoT.*": lambda msg: "y", + ".*Custom channel for iot.*": lambda msg: "9.2.x-dev", + ".*Install Monitor.*": lambda msg: "y", + ".*Custom channel for monitor.*": lambda msg: "9.2.x-dev", + ".*Install Manage.*": lambda msg: "y", + ".*Custom channel for manage.*": lambda msg: "9.2.x-dev", + ".*Select a server bundle configuration.*": lambda msg: "1", + ".*Customize database settings.*": lambda msg: "n", + ".*Create demo data.*": lambda msg: "n", + ".*Manage server timezone.*": lambda msg: "GMT", + ".*Base language.*": lambda msg: "EN", + ".*Secondary language.*": lambda msg: "", + ".*Enable integration with Cognos Analytics.*": lambda msg: "n", + ".*Enable integration with Watson Studio Local.*": lambda msg: "n", + ".*Select components to enable.*": lambda msg: "n", + ".*Include customization archive.*": lambda msg: "n", + ".*Install Predict.*": 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", + ".*Do you want to configure AiCfg.*": lambda msg: "n", + ".*Install Grafana.*": lambda msg: "y", + # MongoDB + ".*MongoDb namespace.*": lambda msg: "mongoce", + ".*Create MongoDb cluster.*": lambda msg: "y", + # Db2 — system Db2 fires because IoT/Monitor are installed + ".*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", + ".*Install namespace.*": lambda msg: "db2u", + ".*Configure node affinity.*": lambda msg: "n", + ".*Configure node tolerations.*": lambda msg: "n", + ".*Customize CPU and memory request/limit.*": lambda msg: "n", + ".*Customize storage capacity.*": lambda msg: "n", + r".*Select Db2 Custom Resource\(CR\).*": lambda msg: "n", + # Kafka — fires because IoT is installed + ".*Select Kafka provider.*": lambda msg: "1", + ".*Strimzi namespace.*": lambda msg: "strimzi", + ".*Use pod templates.*": lambda msg: "n", + ".*Create system Kafka instance.*": lambda msg: "y", + ".*Kafka version.*": lambda msg: "3.8.0", + # Final + ".*Use additional configurations.*": lambda msg: "n", + ".*Proceed with these settings.*": lambda msg: "y", + } + + def test_interactive_le_enabled_path_routing(self, tmpdir): + """Interactive: select path routing, opt into LE, provide email — LE enabled.""" + handlers = self._common_handlers(tmpdir, le_response="y") + # Insert email prompt after the LE yes answer + handlers[".*Let.s Encrypt e-mail.*"] = lambda msg: "test@ibm.com" + + config = InstallTestConfig( + prompt_handlers=handlers, + current_catalog=None, + 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=60, + argv=["--dev-mode"], + ) + run_install_test(tmpdir, config) + + def test_interactive_le_disabled_path_routing(self, tmpdir): + """Interactive: select path routing, decline LE — normal DNS-01 flow continues.""" + handlers = self._common_handlers(tmpdir, le_response="n") + + config = InstallTestConfig( + prompt_handlers=handlers, + current_catalog=None, + 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=60, + argv=["--dev-mode"], + ) + run_install_test(tmpdir, config) From dea8116281d97dba944f26604a03fb029c4b7f37 Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Mon, 31 Aug 2026 15:30:10 +0530 Subject: [PATCH 5/9] [patch] fix typo --- python/src/mas/cli/install/app.py | 4 ++-- tekton/src/tasks/suite-dns.yml.j2 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/python/src/mas/cli/install/app.py b/python/src/mas/cli/install/app.py index b7102f15126..a45ed6dee8e 100644 --- a/python/src/mas/cli/install/app.py +++ b/python/src/mas/cli/install/app.py @@ -2902,7 +2902,7 @@ def install(self, argv): "========================================================================", "--letsencrypt is only supported with path-based routing mode.", "Remove --letsencrypt or switch to path routing:", - " mas install --routing path --letsencrypt --le-email ...", + " mas install --routing path --letsencrypt --le-email [email] ...", ] ) ) @@ -2916,7 +2916,7 @@ def install(self, argv): "Let's Encrypt E-mail Required", "========================================================================", "--le-email must be provided when --letsencrypt is set.", - " mas install --routing path --letsencrypt --le-email ...", + " mas install --routing path --letsencrypt --le-email [email] ...", ] ) ) diff --git a/tekton/src/tasks/suite-dns.yml.j2 b/tekton/src/tasks/suite-dns.yml.j2 index 6c5ac4e5aab..9a90ea28a5e 100644 --- a/tekton/src/tasks/suite-dns.yml.j2 +++ b/tekton/src/tasks/suite-dns.yml.j2 @@ -190,7 +190,7 @@ spec: value: $(params.mas_issuer_kind) - name: MAS_INGRESS_CONTROLLER_NAME value: $(params.mas_ingress_controller_name) - - name: MAS_LETSENCRYPT_HTTP01 + - name: MAS_LETS_ENCRYPT_HTTP01 value: $(params.mas_letsencrypt_http01_enabled) - name: MAS_LE_EMAIL value: $(params.mas_le_email) From 05fbe33fcea4254814a089de708a1f92d37a782e Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Tue, 1 Sep 2026 11:15:14 +0530 Subject: [PATCH 6/9] [patch] env var name typo --- tekton/src/tasks/suite-dns.yml.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tekton/src/tasks/suite-dns.yml.j2 b/tekton/src/tasks/suite-dns.yml.j2 index 9a90ea28a5e..a0496d7f8c8 100644 --- a/tekton/src/tasks/suite-dns.yml.j2 +++ b/tekton/src/tasks/suite-dns.yml.j2 @@ -190,7 +190,7 @@ spec: value: $(params.mas_issuer_kind) - name: MAS_INGRESS_CONTROLLER_NAME value: $(params.mas_ingress_controller_name) - - name: MAS_LETS_ENCRYPT_HTTP01 + - name: MAS_LETSENCRYPT_HTTP01_ENABLED value: $(params.mas_letsencrypt_http01_enabled) - name: MAS_LE_EMAIL value: $(params.mas_le_email) From 0479fe66d03214edebb7696f5ef0f7287b51c724 Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Tue, 1 Sep 2026 12:16:30 +0530 Subject: [PATCH 7/9] [patch] revert dns validation for path mode --- python/src/mas/cli/install/app.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/python/src/mas/cli/install/app.py b/python/src/mas/cli/install/app.py index a45ed6dee8e..0cb17c9b1f4 100644 --- a/python/src/mas/cli/install/app.py +++ b/python/src/mas/cli/install/app.py @@ -719,23 +719,18 @@ def configAdminMode(self): " 1. cluster - Install with ClusterRoles (default)", " - MAS has cluster-level access to manage its applications and resources across the cluster", " - CLI pre-installs ClusterRoles to grant delegated admin permissions to MAS service accounts", - " - Supports ClusterIssuer or Issuer certificate kind", - " - ClusterIssuer: CLI-managed DNS integration available for both path and subdomain routing", - " - Issuer: CLI-managed DNS integration available for path-based routing only", "", " 2. namespaced - Install with namespace-scoped Roles only", " - 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", - " - Certificate issuer kind is fixed to 'Issuer'", - " - CLI-managed DNS integration available for path-based routing only; not available in subdomain routing", + " - DNS integration is not available in this mode. If you use a custom domain, you need to configure DNS manually.", "", " 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", - " - Certificate issuer kind is fixed to 'Issuer'", - " - CLI-managed DNS integration available for path-based routing only; not available in subdomain routing", + " - DNS integration is not available in this mode. If you use a custom domain, you need to configure DNS manually.", ] ) @@ -782,8 +777,8 @@ def _handleDNSIntegrationRestriction(self): self.printDescription( [ "You selected Issuer as the certificate issuer kind.", - "CLI-managed DNS integration with Issuer is only supported in path-based routing mode.", - "If you plan to use subdomain routing, you need to configure DNS manually.", + "DNS integration is not available when the certificate issuer kind is Issuer.", + "If you use a custom domain, you need to configure DNS manually.", ] ) return True @@ -2570,24 +2565,24 @@ def nonInteractiveMode(self) -> None: # Validate DNS integration restrictions if self.getParam("dns_provider") != "": - if self.mas_admin_mode in ["namespaced", "minimal"] and self.getParam("mas_routing_mode") != "path": + 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 only available in path-based routing mode for this admin mode.", - "Add --routing path, or switch to --admin-mode cluster and use --mas-issuer-kind ClusterIssuer.", + "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.", ] ) ) - if self.mas_admin_mode == "cluster" and self.getParam("mas_issuer_kind") == "Issuer" and self.getParam("mas_routing_mode") != "path": + if self.mas_admin_mode == "cluster" and self.getParam("mas_issuer_kind") == "Issuer": self.fatalError( "\n".join( [ - "Invalid configuration: --admin-mode cluster with --mas-issuer-kind Issuer requires --routing path.", - "DNS integration with Issuer in cluster mode is only supported in path-based routing mode.", - "Either add --routing path, or use --mas-issuer-kind ClusterIssuer.", + "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.", ] ) ) From 940ec97470e816a45515da7a87e8c8f7615b77fa Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Tue, 1 Sep 2026 17:31:47 +0530 Subject: [PATCH 8/9] [patch] keep one flag to enable LE --- python/src/mas/cli/install/app.py | 33 +---- python/src/mas/cli/install/argParser.py | 11 +- python/src/mas/cli/install/params.py | 1 - python/src/mas/cli/install/summarizer.py | 2 +- .../integration/install/test_letsencrypt.py | 131 +++++++----------- tekton/src/params/install.yml.j2 | 6 +- .../pipelines/taskdefs/core/suite-dns.yml.j2 | 2 - tekton/src/tasks/suite-dns.yml.j2 | 5 - 8 files changed, 58 insertions(+), 133 deletions(-) diff --git a/python/src/mas/cli/install/app.py b/python/src/mas/cli/install/app.py index 0cb17c9b1f4..8366140e4a8 100644 --- a/python/src/mas/cli/install/app.py +++ b/python/src/mas/cli/install/app.py @@ -942,11 +942,8 @@ def configRoutingMode(self): ] ) if self.yesOrNo("Do you want to use Let's Encrypt for certificate management"): - self.setParam("mas_letsencrypt_http01_enabled", "true") self.promptForString("Let's Encrypt e-mail", "mas_le_email") self.setParam("mas_cluster_issuer", f"{self.getParam('mas_instance_id')}-http01-le-prod") - else: - self.setParam("mas_letsencrypt_http01_enabled", "false") def _checkIngressControllerForPathRouting(self, controllerName="default"): """Check if a specific IngressController exists and is configured for path-based routing. @@ -2886,39 +2883,23 @@ def install(self, argv): # Validate Let's Encrypt HTTP-01 flags if not self.isInteractiveMode: - leEnabled = self.getParam("mas_letsencrypt_http01_enabled") == "true" - routingMode = self.getParam("mas_routing_mode") + leEmail = self.getParam("mas_le_email") - if leEnabled and routingMode != "path": + if leEmail and self.getParam("mas_routing_mode") != "path": self.fatalError( "\n".join( [ "Let's Encrypt HTTP-01 Requires Path-Based Routing", "========================================================================", - "--letsencrypt is only supported with path-based routing mode.", - "Remove --letsencrypt or switch to path routing:", - " mas install --routing path --letsencrypt --le-email [email] ...", + "--le-email is only supported with path-based routing mode.", + "Remove --le-email or switch to path routing:", + " mas install --routing path --le-email [email] ...", ] ) ) - if leEnabled: - leEmail = self.getParam("mas_le_email") - if not leEmail: - self.fatalError( - "\n".join( - [ - "Let's Encrypt E-mail Required", - "========================================================================", - "--le-email must be provided when --letsencrypt is set.", - " mas install --routing path --letsencrypt --le-email [email] ...", - ] - ) - ) - if not self.getParam("mas_cluster_issuer"): - self.setParam("mas_cluster_issuer", f"{self.getParam('mas_instance_id')}-http01-le-prod") - else: - self.setParam("mas_letsencrypt_http01_enabled", "false") + if leEmail and not self.getParam("mas_cluster_issuer"): + self.setParam("mas_cluster_issuer", f"{self.getParam('mas_instance_id')}-http01-le-prod") # Based on the parameters set the annotations correctly self.configAnnotations() diff --git a/python/src/mas/cli/install/argParser.py b/python/src/mas/cli/install/argParser.py index 1becb530015..52eaeffc97a 100644 --- a/python/src/mas/cli/install/argParser.py +++ b/python/src/mas/cli/install/argParser.py @@ -243,20 +243,11 @@ def isValidFile(parser: argparse.ArgumentParser, arg: str) -> str: help="Provide the name of the ClusterIssuer to configure MAS to issue certificates", ) -masAdvancedArgGroup.add_argument( - "--letsencrypt", - dest="mas_letsencrypt_http01_enabled", - required=False, - action="store_const", - const="true", - help="Enable Let's Encrypt HTTP-01 certificate management (path routing mode only, cluster must be publicly accessible on port 80)", -) - masAdvancedArgGroup.add_argument( "--le-email", dest="mas_le_email", required=False, - help="E-mail address to register with Let's Encrypt (required when --letsencrypt is set)", + help="E-mail address to register with Let's Encrypt. Providing this implicitly enables Let's Encrypt HTTP-01 certificate management (path routing mode only, cluster must be publicly accessible on port 80)", ) masAdvancedArgGroup.add_argument( diff --git a/python/src/mas/cli/install/params.py b/python/src/mas/cli/install/params.py index 9f29f750206..51fab7bf3d3 100644 --- a/python/src/mas/cli/install/params.py +++ b/python/src/mas/cli/install/params.py @@ -76,7 +76,6 @@ "mas_cluster_issuer", "ocp_ingress", # Let's Encrypt HTTP-01 - "mas_letsencrypt_http01_enabled", "mas_le_email", # CIS "cis_email", diff --git a/python/src/mas/cli/install/summarizer.py b/python/src/mas/cli/install/summarizer.py index dcd40be94cb..270f85cf511 100644 --- a/python/src/mas/cli/install/summarizer.py +++ b/python/src/mas/cli/install/summarizer.py @@ -153,7 +153,7 @@ def masSummary(self) -> None: if self.getParam("mas_routing_mode") == "path": self.printParamSummary("IngressController Name", "mas_ingress_controller_name") self.printParamSummary("Configure IngressController", "mas_configure_ingress") - if self.getParam("mas_letsencrypt_http01_enabled") == "true": + if self.getParam("mas_le_email") != "": self.printSummary("Let's Encrypt HTTP-01", "Enabled") self.printParamSummary("Let's Encrypt e-mail", "mas_le_email") self.printParamSummary("Let's Encrypt Issuer", "mas_cluster_issuer") diff --git a/python/tests/integration/install/test_letsencrypt.py b/python/tests/integration/install/test_letsencrypt.py index a20f3ef15d1..6c3b63aa7d0 100644 --- a/python/tests/integration/install/test_letsencrypt.py +++ b/python/tests/integration/install/test_letsencrypt.py @@ -14,16 +14,15 @@ Covers: Interactive mode: - - User opts in → mas_letsencrypt_http01_enabled=true, email collected, mas_cluster_issuer set - - User opts out → mas_letsencrypt_http01_enabled=false, no email prompt + - User opts in → email collected, mas_cluster_issuer set + - User opts out → email stays empty, no email prompt - Only shown when routing mode is path (subdomain skips it entirely) Non-interactive mode: - - --letsencrypt + --le-email + --routing path → valid, mas_cluster_issuer auto-set - - --letsencrypt without --le-email → fatalError - - --letsencrypt + --routing subdomain → fatalError - - --letsencrypt + --mas-cluster-issuer custom → custom issuer respected - - no --letsencrypt → mas_letsencrypt_http01_enabled=false + - --le-email + --routing path → valid, mas_cluster_issuer auto-set + - --le-email + --routing subdomain → fatalError + - --le-email + --mas-cluster-issuer custom → custom issuer respected + - no --le-email → LE disabled (Ansible derives from empty email) """ import sys @@ -88,47 +87,36 @@ def _prompt_for_string(label, param, **kwargs): class TestInteractiveLetsEncrypt: """Interactive prompt flow for Let's Encrypt HTTP-01.""" - def test_user_opts_in_sets_enabled_flag_email_and_cluster_issuer(self): - """When user says yes to LE, enabled=true, email collected, cluster issuer set.""" + def test_user_opts_in_collects_email_and_sets_cluster_issuer(self): + """When user says yes to LE, email is collected and mas_cluster_issuer is set.""" app = create_mock_app(instance_id="inst1") - # Routing mode already confirmed as path app.params["mas_routing_mode"] = "path" - - # Simulate: yesOrNo("Do you want to use LetsEncrypt...") → True app.yesOrNo = MagicMock(return_value=True) - # Run only the LE block (after routing mode is set) - # Replicate the block from configRoutingMode for isolation + # Replicate the current interactive LE block from configRoutingMode if app.getParam("mas_routing_mode") == "path": app.printDescription([]) - if app.yesOrNo("Do you want to use LetsEncrypt for certificate management"): - app.setParam("mas_letsencrypt_http01_enabled", "true") + if app.yesOrNo("Do you want to use Let's Encrypt for certificate management"): app.promptForString("Let's Encrypt e-mail", "mas_le_email") app.setParam("mas_cluster_issuer", f"{app.getParam('mas_instance_id')}-http01-le-prod") - else: - app.setParam("mas_letsencrypt_http01_enabled", "false") - assert app.getParam("mas_letsencrypt_http01_enabled") == "true" assert app.getParam("mas_le_email") == "user-mas_le_email" assert app.getParam("mas_cluster_issuer") == "inst1-http01-le-prod" app.promptForString.assert_called_once() - def test_user_opts_out_sets_disabled_flag_and_no_email_prompt(self): - """When user says no to LE, enabled=false and email is never prompted.""" + def test_user_opts_out_no_email_and_no_cluster_issuer(self): + """When user says no to LE, email stays empty and cluster issuer is not set.""" app = create_mock_app(instance_id="inst1") app.params["mas_routing_mode"] = "path" app.yesOrNo = MagicMock(return_value=False) if app.getParam("mas_routing_mode") == "path": app.printDescription([]) - if app.yesOrNo("Do you want to use LetsEncrypt for certificate management"): - app.setParam("mas_letsencrypt_http01_enabled", "true") + if app.yesOrNo("Do you want to use Let's Encrypt for certificate management"): app.promptForString("Let's Encrypt e-mail", "mas_le_email") app.setParam("mas_cluster_issuer", f"{app.getParam('mas_instance_id')}-http01-le-prod") - else: - app.setParam("mas_letsencrypt_http01_enabled", "false") - assert app.getParam("mas_letsencrypt_http01_enabled") == "false" + assert app.getParam("mas_le_email") == "" assert app.getParam("mas_cluster_issuer") == "" app.promptForString.assert_not_called() @@ -140,12 +128,12 @@ def test_le_prompt_not_shown_for_subdomain_routing(self): if app.getParam("mas_routing_mode") == "path": app.printDescription([]) - if app.yesOrNo("Do you want to use LetsEncrypt for certificate management"): - app.setParam("mas_letsencrypt_http01_enabled", "true") + if app.yesOrNo("Do you want to use Let's Encrypt for certificate management"): + app.promptForString("Let's Encrypt e-mail", "mas_le_email") # yesOrNo should never have been called (routing is subdomain) app.yesOrNo.assert_not_called() - assert app.getParam("mas_letsencrypt_http01_enabled") == "" + assert app.getParam("mas_le_email") == "" # ============================================================================= @@ -154,7 +142,7 @@ def test_le_prompt_not_shown_for_subdomain_routing(self): class TestArgParserLetsEncrypt: - """Verify --letsencrypt and --le-email are parsed correctly.""" + """Verify --le-email is parsed correctly and is the sole LE enablement signal.""" def _base_argv(self): return [ @@ -172,27 +160,20 @@ def _base_argv(self): "--no-confirm", ] - def test_letsencrypt_flag_sets_enabled_true(self): - argv = self._base_argv() + ["--letsencrypt", "--le-email", "test@ibm.com"] + def test_le_email_enables_le(self): + """--le-email enables LE — it is the only flag needed.""" + argv = self._base_argv() + ["--le-email", "test@ibm.com"] args = installArgParser.parse_args(args=argv) - assert args.mas_letsencrypt_http01_enabled == "true" assert args.mas_le_email == "test@ibm.com" - def test_no_letsencrypt_flag_defaults_to_none(self): - """Without --letsencrypt the dest is None (not yet set to false — that happens in validation).""" + def test_no_le_email_means_le_disabled(self): + """Without --le-email, mas_le_email is None — Ansible derives LE disabled.""" argv = self._base_argv() args = installArgParser.parse_args(args=argv) - assert args.mas_letsencrypt_http01_enabled is None - - def test_le_email_without_letsencrypt_is_accepted_by_parser(self): - """Parser accepts --le-email on its own; validation rejects it later.""" - argv = self._base_argv() + ["--le-email", "test@ibm.com"] - args = installArgParser.parse_args(args=argv) - assert args.mas_le_email == "test@ibm.com" - assert args.mas_letsencrypt_http01_enabled is None + assert args.mas_le_email is None - def test_letsencrypt_with_subdomain_routing_is_parsed(self): - """Parser does not reject this combination; app validation does.""" + def test_le_email_with_subdomain_routing_is_parsed(self): + """Parser accepts the combination; app validation rejects it.""" argv = [ "--mas-instance-id", "testinst", @@ -204,7 +185,6 @@ def test_letsencrypt_with_subdomain_routing_is_parsed(self): "cluster", "--routing", "subdomain", - "--letsencrypt", "--le-email", "test@ibm.com", "--accept-license", @@ -212,19 +192,18 @@ def test_letsencrypt_with_subdomain_routing_is_parsed(self): ] args = installArgParser.parse_args(args=argv) assert args.mas_routing_mode == "subdomain" - assert args.mas_letsencrypt_http01_enabled == "true" + assert args.mas_le_email == "test@ibm.com" - def test_custom_cluster_issuer_preserved_with_letsencrypt(self): - """--mas-cluster-issuer is parsed alongside --letsencrypt.""" + def test_custom_cluster_issuer_preserved_with_le_email(self): + """--mas-cluster-issuer is preserved alongside --le-email.""" argv = self._base_argv() + [ - "--letsencrypt", "--le-email", "test@ibm.com", "--mas-cluster-issuer", "my-custom-issuer", ] args = installArgParser.parse_args(args=argv) - assert args.mas_letsencrypt_http01_enabled == "true" + assert args.mas_le_email == "test@ibm.com" assert args.mas_cluster_issuer == "my-custom-issuer" @@ -234,14 +213,16 @@ def test_custom_cluster_issuer_preserved_with_letsencrypt(self): class TestNonInteractiveLetsEncryptValidation: - """Validate the non-interactive LE validation block in app.py.""" + """Validate the non-interactive LE validation block in app.py. + + LE is enabled purely by the presence of mas_le_email. + """ - def _make_app(self, routing_mode="path", le_enabled="true", le_email="test@ibm.com", cluster_issuer="", instance_id="testinst"): + def _make_app(self, routing_mode="path", le_email="test@ibm.com", cluster_issuer="", instance_id="testinst"): app = MagicMock(spec=InstallApp) app.isInteractiveMode = False app.params = { "mas_routing_mode": routing_mode, - "mas_letsencrypt_http01_enabled": le_enabled, "mas_le_email": le_email, "mas_cluster_issuer": cluster_issuer, "mas_instance_id": instance_id, @@ -253,62 +234,46 @@ def _make_app(self, routing_mode="path", le_enabled="true", le_email="test@ibm.c def _run_validation(self, app): """Replicate the non-interactive LE validation block from app.py.""" - leEnabled = app.getParam("mas_letsencrypt_http01_enabled") == "true" - routingMode = app.getParam("mas_routing_mode") + leEmail = app.getParam("mas_le_email") - if leEnabled and routingMode != "path": + if leEmail and app.getParam("mas_routing_mode") != "path": app.fatalError("Let's Encrypt HTTP-01 Requires Path-Based Routing") - if leEnabled: - leEmail = app.getParam("mas_le_email") - if not leEmail: - app.fatalError("Let's Encrypt E-mail Required") - if not app.getParam("mas_cluster_issuer"): - app.setParam("mas_cluster_issuer", f"{app.getParam('mas_instance_id')}-http01-le-prod") - else: - app.setParam("mas_letsencrypt_http01_enabled", "false") + if leEmail and not app.getParam("mas_cluster_issuer"): + app.setParam("mas_cluster_issuer", f"{app.getParam('mas_instance_id')}-http01-le-prod") def test_valid_le_path_routing_sets_cluster_issuer(self): - """Valid config: LE enabled + path routing + email → cluster issuer auto-set.""" - app = self._make_app(routing_mode="path", le_enabled="true", le_email="test@ibm.com", cluster_issuer="") + """Valid config: email + path routing → cluster issuer auto-set.""" + app = self._make_app(routing_mode="path", le_email="test@ibm.com", cluster_issuer="") self._run_validation(app) assert app.getParam("mas_cluster_issuer") == "testinst-http01-le-prod" app.fatalError.assert_not_called() def test_valid_le_path_routing_custom_issuer_preserved(self): """When user provides --mas-cluster-issuer, it is NOT overwritten.""" - app = self._make_app(routing_mode="path", le_enabled="true", le_email="test@ibm.com", cluster_issuer="my-custom-issuer") + app = self._make_app(routing_mode="path", le_email="test@ibm.com", cluster_issuer="my-custom-issuer") self._run_validation(app) assert app.getParam("mas_cluster_issuer") == "my-custom-issuer" app.fatalError.assert_not_called() def test_le_with_subdomain_routing_raises_fatal_error(self): - """--letsencrypt + --routing subdomain must trigger a fatal error.""" - app = self._make_app(routing_mode="subdomain", le_enabled="true", le_email="test@ibm.com") + """--le-email + --routing subdomain must trigger a fatal error.""" + app = self._make_app(routing_mode="subdomain", le_email="test@ibm.com") with pytest.raises(SystemExit): self._run_validation(app) app.fatalError.assert_called_once() assert "Path-Based Routing" in app.fatalError.call_args[0][0] - def test_le_without_email_raises_fatal_error(self): - """--letsencrypt without --le-email must trigger a fatal error.""" - app = self._make_app(routing_mode="path", le_enabled="true", le_email="") - with pytest.raises(SystemExit): - self._run_validation(app) - app.fatalError.assert_called_once() - assert "E-mail Required" in app.fatalError.call_args[0][0] - - def test_no_le_sets_enabled_false(self): - """Without --letsencrypt, mas_letsencrypt_http01_enabled is explicitly set to false.""" - app = self._make_app(routing_mode="path", le_enabled="false", le_email="") + def test_no_le_email_no_cluster_issuer_set(self): + """Without --le-email, no cluster issuer is set and no error raised.""" + app = self._make_app(routing_mode="path", le_email="") self._run_validation(app) - assert app.getParam("mas_letsencrypt_http01_enabled") == "false" assert app.getParam("mas_cluster_issuer") == "" app.fatalError.assert_not_called() def test_le_cluster_issuer_name_matches_ansible_default(self): """Issuer name must be -http01-le-prod to match mas_http01_le_prod_issuer_name.""" - app = self._make_app(routing_mode="path", le_enabled="true", le_email="test@ibm.com", cluster_issuer="", instance_id="myinstance") + app = self._make_app(routing_mode="path", le_email="test@ibm.com", cluster_issuer="", instance_id="myinstance") self._run_validation(app) assert app.getParam("mas_cluster_issuer") == "myinstance-http01-le-prod" diff --git a/tekton/src/params/install.yml.j2 b/tekton/src/params/install.yml.j2 index 59d0d7fae3e..31b73e7d451 100644 --- a/tekton/src/params/install.yml.j2 +++ b/tekton/src/params/install.yml.j2 @@ -465,14 +465,10 @@ type: string default: "" description: Name of the IngressController to use for path-based routing -- name: mas_letsencrypt_http01_enabled - type: string - default: "false" - description: Enable Let's Encrypt HTTP-01 certificate management (path routing mode only, cluster must be publicly accessible on port 80) - name: mas_le_email type: string default: "" - description: E-mail address to register with Let's Encrypt (required when mas_letsencrypt_http01_enabled is true) + description: E-mail address to register with Let's Encrypt. When set, Let's Encrypt HTTP-01 is automatically enabled (path routing mode only, cluster must be publicly accessible on port 80) - name: mas_trust_default_cas type: string default: "" diff --git a/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 b/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 index bf45b971273..8affef13d05 100644 --- a/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 +++ b/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 @@ -68,8 +68,6 @@ value: $(params.mas_issuer_kind) - name: mas_ingress_controller_name value: $(params.mas_ingress_controller_name) - - name: mas_letsencrypt_http01_enabled - value: $(params.mas_letsencrypt_http01_enabled) - name: mas_le_email value: $(params.mas_le_email) - name: mas_cluster_issuer diff --git a/tekton/src/tasks/suite-dns.yml.j2 b/tekton/src/tasks/suite-dns.yml.j2 index a0496d7f8c8..3ace17e68e4 100644 --- a/tekton/src/tasks/suite-dns.yml.j2 +++ b/tekton/src/tasks/suite-dns.yml.j2 @@ -114,9 +114,6 @@ spec: default: "" # Let's Encrypt HTTP-01 - - name: mas_letsencrypt_http01_enabled - type: string - default: "false" - name: mas_le_email type: string default: "" @@ -190,8 +187,6 @@ spec: value: $(params.mas_issuer_kind) - name: MAS_INGRESS_CONTROLLER_NAME value: $(params.mas_ingress_controller_name) - - name: MAS_LETSENCRYPT_HTTP01_ENABLED - value: $(params.mas_letsencrypt_http01_enabled) - name: MAS_LE_EMAIL value: $(params.mas_le_email) - name: MAS_CLUSTER_ISSUER From 0f7f2925426840c5b3ff2c13c21e7f281e566e42 Mon Sep 17 00:00:00 2001 From: Prince-Raiyani1 Date: Wed, 2 Sep 2026 22:15:21 +0530 Subject: [PATCH 9/9] [patch] temporary disable interactive mode pbr test for lets encrypt --- python/tests/integration/install/test_arcgis_cluster_mode.py | 2 +- python/tests/integration/install/test_letsencrypt.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/python/tests/integration/install/test_arcgis_cluster_mode.py b/python/tests/integration/install/test_arcgis_cluster_mode.py index ac401434d7b..563d27dd436 100755 --- a/python/tests/integration/install/test_arcgis_cluster_mode.py +++ b/python/tests/integration/install/test_arcgis_cluster_mode.py @@ -39,7 +39,7 @@ def test_install_arcgis_cluster_mode_success(tmpdir): # ".*Routing Mode.*": lambda msg: "1", # Select path-based routing # Note: IngressController selection prompt does NOT appear because there's only one controller # 4.1. Let's Encrypt HTTP-01 - decline, use existing cert setup - ".*Do you want to use Let.*s Encrypt.*": lambda msg: "n", + # ".*Do you want to use Let.*s Encrypt.*": lambda msg: "n", # 5. Service Mesh Configuration ".*Enable OpenShift Service Mesh support for MAS.*": lambda msg: "y", # 6. Configure IngressController for path-based routing diff --git a/python/tests/integration/install/test_letsencrypt.py b/python/tests/integration/install/test_letsencrypt.py index 6c3b63aa7d0..0ca00a68915 100644 --- a/python/tests/integration/install/test_letsencrypt.py +++ b/python/tests/integration/install/test_letsencrypt.py @@ -388,6 +388,7 @@ def _common_handlers(self, tmpdir, le_response): ".*Proceed with these settings.*": lambda msg: "y", } + @pytest.mark.skip(reason="configRoutingMode() is temporarily disabled in app.py — re-enable when path routing is un-commented") def test_interactive_le_enabled_path_routing(self, tmpdir): """Interactive: select path routing, opt into LE, provide email — LE enabled.""" handlers = self._common_handlers(tmpdir, le_response="y") @@ -409,6 +410,7 @@ def test_interactive_le_enabled_path_routing(self, tmpdir): ) run_install_test(tmpdir, config) + @pytest.mark.skip(reason="configRoutingMode() is temporarily disabled in app.py — re-enable when path routing is un-commented") def test_interactive_le_disabled_path_routing(self, tmpdir): """Interactive: select path routing, decline LE — normal DNS-01 flow continues.""" handlers = self._common_handlers(tmpdir, le_response="n")