From e6c11e2bb830dde8be4199d28f5f8b2d33e8f8a4 Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Wed, 12 Aug 2026 11:33:46 +0530 Subject: [PATCH 01/14] [patch] Add DNS Integration for AI Service install pipeline --- python/src/mas/cli/aiservice/install/app.py | 123 +++++++++++++++++- .../mas/cli/aiservice/install/argBuilder.py | 82 ++++++++---- .../mas/cli/aiservice/install/argParser.py | 78 +++++++++++ .../src/mas/cli/aiservice/install/params.py | 14 ++ 4 files changed, 274 insertions(+), 23 deletions(-) diff --git a/python/src/mas/cli/aiservice/install/app.py b/python/src/mas/cli/aiservice/install/app.py index db3fd0ac28..3a0b7d41d9 100644 --- a/python/src/mas/cli/aiservice/install/app.py +++ b/python/src/mas/cli/aiservice/install/app.py @@ -125,9 +125,9 @@ def chooseInstallFlavour(self) -> None: self.printDescription( [ "There are two flavours of the interactive install to choose from: Simplified and Advanced. The simplified option will present fewer dialogs, but you lose the ability to configure the following aspects of the installation:", - " - Configure certificate issuer", " - Enable IPv6 SingleStack networking for services", " - Customize Scheduling configuration for AI workloads(Training pipeline & Inference services) for AI Service tenant", + " - Configure a custom domain and DNS integrations", ] ) self.showAdvancedOptions = self.yesOrNo("Show advanced installation options") @@ -686,19 +686,140 @@ def aiServiceSettings(self) -> None: else: self.setParam("rhoai", "true") + # Configure DNS + self.configDNSAndCerts() + # Configure Certificate Issuer self.configCertIssuer() # Configure Network configuration for services self.configNetworking() + @logMethodCall + def configDNSAndCerts(self): + if self.showAdvancedOptions: + self.printH1("Cluster Ingress Secret Override") + self.printDescription( + [ + "In most OpenShift clusters the installation is able to automatically locate the default ingress certificate, however in some configurations it is necessary to manually configure the name of the secret", + "Unless you see an error during the ocp-verify stage indicating that the secret can not be determined you do not need to set this and can leave the response empty", + ] + ) + self.promptForString( + "Cluster ingress certificate secret name", + "ocp_ingress_tls_secret_name", + default="", + ) + + self.printH1("Configure Domain & Certificate Management") + configureDomainAndCertMgmt = self.yesOrNo("Configure domain & certificate management") + if configureDomainAndCertMgmt: + configureDomain = self.yesOrNo("Configure custom domain") + if configureDomain: + self.promptForString("AI Service domain", "aiservice_domain") + + self.printDescription( + [ + "", + "DNS Integrations:", + " 1. IBM Cloud Internet Services", + " 2. AWS Route 53", + " 3. None (I will set up DNS myself)", + ] + ) + dnsProvider = self.promptForInt("DNS Provider", min=1, max=3) + if dnsProvider == 1: + self.configDNSAndCertsCIS() + elif dnsProvider == 2: + self.configDNSAndCertsRoute53() + elif dnsProvider == 3: + # Use self-signed certificate Issuer with custom domain + self.setParam("dns_provider", "") + self.setParam("aiservice_certificate_issuer", "") + + if dnsProvider == 1: + self.printDescription( + [ + "By default, DNS CNAME records will be created pointing to the domain of the cluster ingress (ingress.config.openshift.io/cluster).", + "CIS DNS integrations support the ability to provide an alternative domain, which may be necessary if you are using OpenShift Container Platform in a non-standard networking configuration.", + ] + ) + self.promptForString("Cluster Ingress Domain Override", "ocp_ingress") + + else: + # Use self-signed certificate Issuer with default domain + self.setParam("dns_provider", "") + self.setParam("aiservice_domain", "") + self.setParam("aiservice_certificate_issuer", "") + + @logMethodCall + def configDNSAndCertsCIS(self): + self.setParam("dns_provider", "cis") + self.promptForString("CIS e-mail", "cis_email") + self.promptForString("CIS API token", "cis_apikey", isPassword=True) + self.promptForString("CIS CRN", "cis_crn") + self.promptForString("CIS subdomain", "cis_subdomain") + + self.printDescription( + [ + "Certificate Issuer:", + " 1. LetsEncrypt (Production)", + " 2. LetsEncrypt (Staging)", + " 3. Self-Signed", + ] + ) + certIssuer = self.promptForInt("Certificate issuer", min=1, max=3) + certIssuerOptions = [ + f"{self.getParam('aiservice_instance_id')}-cis-le-prod", + f"{self.getParam('aiservice_instance_id')}-cis-le-stg", + "", + ] + self.setParam("aiservice_certificate_issuer", certIssuerOptions[certIssuer - 1]) + + @logMethodCall + def configDNSAndCertsRoute53(self): + self.setParam("dns_provider", "route53") + self.printDescription( + [ + "Provide your AWS account access key ID and secret access key", + "This will be used to authenticate into the AWS account where your AWS Route 53 hosted zone instance is located", + "", + ] + ) + self.promptForString("AWS Access Key ID", "aws_access_key_id", isPassword=True) + self.promptForString("AWS Secret Access Key", "aws_secret_access_key", isPassword=True) + + self.printDescription( + [ + "Provide your AWS Route 53 hosted zone instance details", + "This information will be used to create webhook resources between your cluster and your AWS Route 53 instance (cluster issuer and cname records)", + "in order for it to be able to resolve DNS entries for all the subdomains created for your Maximo Application Suite instance", + "", + "Therefore, the AWS Route 53 subdomain + the AWS Route 53 hosted zone name defined, when combined, needs to match with the chosen AI Service domain, otherwise the DNS records won't be able to get resolved", + ] + ) + self.promptForString("AWS Route 53 hosted zone name", "route53_hosted_zone_name") + self.promptForString("AWS Route 53 hosted zone region", "route53_hosted_zone_region") + self.promptForString("AWS Route 53 subdomain", "route53_subdomain") + self.promptForString("AWS Route 53 e-mail", "route53_email") + + self.setParam("aiservice_certificate_issuer", f"{self.getParam('aiservice_instance_id')}-route53-le-prod") + @logMethodCall def configCertIssuer(self): if self.showAdvancedOptions: self.printH1("Configure Certificate Issuer") + self.printDescription([ + "Provide name of your certificate Issuer", + "This Issuer will be used to generate public certificates for AI Service", + f"The certificate Issuer must be configured in the AI Service namespace: aiservice-{self.getParam("aiservice_instance_id")}" + "When skipped, a self-signed certificate issuer will be created during installation" + ]) configureCertIssuer = self.yesOrNo("Configure certificate issuer") if configureCertIssuer: self.promptForString("Certificate issuer name", "aiservice_certificate_issuer") + else: + self.setParam("aiservice_certificate_issuer", "") @logMethodCall def configNetworking(self): diff --git a/python/src/mas/cli/aiservice/install/argBuilder.py b/python/src/mas/cli/aiservice/install/argBuilder.py index da1df2df69..a3d37cecbf 100644 --- a/python/src/mas/cli/aiservice/install/argBuilder.py +++ b/python/src/mas/cli/aiservice/install/argBuilder.py @@ -23,11 +23,31 @@ def buildCommand(self) -> str: command += "export IBMCLOUD_APIKEY=x\n" if self.getParam("aws_access_key_id") != "": command += "export AWS_ACCESS_KEY_ID=x\n" - if self.getParam("secret_access_key") != "": - command += "export SECRET_ACCESS_KEY=x\n" + if self.getParam("aws_secret_access_key") != "": + command += "export AWS_SECRET_ACCESS_KEY=x\n" if self.getParam("artifactory_username") != "": command += "export ARTIFACTORY_USERNAME=x\nexport ARTIFACTORY_TOKEN=x\n" + # Object Storage Credentials + if self.getParam("aiservice_s3_accesskey") != "": + command += "export AISERVICE_S3_ACCESSKEY=x\n" + if self.getParam("aiservice_s3_secretkey") != "": + command += "export AISERVICE_S3_SECRETKEY=x\n" + + # Watsonx Credentials + if self.getParam("aiservice_watsonxai_apikey") != "": + command += "export AISERVICE_WATSONXAI_APIKEY=x\n" + + # MinIO Credentials + if self.getParam("minio_root_user") != "": + command += "export MINIO_ROOT_USER=x\n" + if self.getParam("minio_root_password") != "": + command += "export MINIO_ROOT_PASSWORD=x\n" + + # Database password + if self.getParam("aiservice_db_password") != "": + command += "export AISERVICE_DB_PASSWORD=x\n" + command += f"mas aiservice-install --mas-catalog-version {self.getParam('mas_catalog_version')}" if self.getParam("mas_catalog_digest") != "": @@ -38,7 +58,7 @@ def buildCommand(self) -> str: # AI Service Instance Id command += f" --aiservice-instance-id \"{self.getParam('aiservice_instance_id')}\"{newline}" - # MAS Advanced Configuration + # AI Service Advanced Configuration # ----------------------------------------------------------------------------- if self.localConfigDir is not None: @@ -108,22 +128,45 @@ def buildCommand(self) -> str: if self.getParam("aiservice_certificate_issuer") != "": command += f" --aiservice-certificate-issuer \"{self.getParam('aiservice_certificate_issuer')}\"{newline}" + if self.getParam("aiservice_domain") != "": + command += f" --domain \"{self.getParam('aiservice_domain')}\"{newline}" + + if self.getParam("dns_provider") == "cis": + command += ' --dns-provider cis --cis-apikey "$CIS_APIKEY"' + command += f" --cis-subdomain \"{self.getParam('cis_subdomain')}\"" + command += f" --cis-crn \"{self.getParam('cis_crn')}\"" + command += f" --cis-email \"{self.getParam('cis_email')}\"{newline}" + + if self.getParam("dns_provider") == "route53": + command += f" --dns-provider route53{newline}" + command += f" --route53-subdomain \"{self.getParam('route53_subdomain')}\"{newline}" + command += f" --route53-email \"{self.getParam('route53_email')}\"{newline}" + command += f" --route53-hosted-zone-name \"{self.getParam('route53_hosted_zone_name')}\"{newline}" + command += f" --route53-hosted-zone-region \"{self.getParam('route53_hosted_zone_region')}\"{newline}" + # Enable IPv6 networking if self.getParam("enable_ipv6").lower() == "true": command += f" --enable-ipv6{newline}" - if self.getParam("aiservice_s3_accesskey") != "": - command += f" --s3-accesskey \"{self.getParam('aiservice_s3_accesskey')}\"{newline}" - if self.getParam("aiservice_s3_secretkey") != "": - command += f" --s3-secretkey \"{self.getParam('aiservice_s3_secretkey')}\"{newline}" - if self.getParam("aiservice_s3_host") != "": - command += f" --s3-host \"{self.getParam('aiservice_s3_host')}\"{newline}" - if self.getParam("aiservice_s3_port") != "": - command += f" --s3-port \"{self.getParam('aiservice_s3_port')}\"{newline}" - if self.getParam("aiservice_s3_ssl") != "": - command += f" --s3-ssl \"{self.getParam('aiservice_s3_ssl')}\"{newline}" - if self.getParam("aiservice_s3_region") != "": - command += f" --s3-region \"{self.getParam('aiservice_s3_region')}\"{newline}" + # Object storage + if self.getParam("minio_root_user") != "" and self.getParam("minio_root_password") != "": + command += f" --install-minio-aiservice{newline}" + command += f" --minio-root-user \"{self.getParam('minio_root_user')}\"{newline}" + command += f" --minio-root-password \"{self.getParam('minio_root_password')}\"{newline}" + else: + if self.getParam("aiservice_s3_accesskey") != "": + command += f' --s3-accesskey "$AISERVICE_S3_ACCESSKEY"{newline}' + if self.getParam("aiservice_s3_secretkey") != "": + command += f' --s3-secretkey "$AISERVICE_S3_SECRETKEY"{newline}' + if self.getParam("aiservice_s3_host") != "": + command += f" --s3-host \"{self.getParam('aiservice_s3_host')}\"{newline}" + if self.getParam("aiservice_s3_port") != "": + command += f" --s3-port \"{self.getParam('aiservice_s3_port')}\"{newline}" + if self.getParam("aiservice_s3_ssl") != "": + command += f" --s3-ssl \"{self.getParam('aiservice_s3_ssl')}\"{newline}" + if self.getParam("aiservice_s3_region") != "": + command += f" --s3-region \"{self.getParam('aiservice_s3_region')}\"{newline}" + if self.getParam("aiservice_s3_bucket_prefix") != "": command += f" --s3-bucket-prefix \"{self.getParam('aiservice_s3_bucket_prefix')}\"{newline}" if self.getParam("aiservice_s3_tenants_bucket") != "": @@ -139,7 +182,7 @@ def buildCommand(self) -> str: command += f" --rhoai{newline}" if self.getParam("aiservice_watsonxai_apikey") != "": - command += f" --watsonxai-apikey \"{self.getParam('aiservice_watsonxai_apikey')}\"{newline}" + command += f' --watsonxai-apikey "$AISERVICE_WATSONXAI_APIKEY"{newline}' if self.getParam("aiservice_watsonxai_url") != "": command += f" --watsonxai-url \"{self.getParam('aiservice_watsonxai_url')}\"{newline}" if self.getParam("aiservice_watsonxai_project_id") != "": @@ -161,11 +204,6 @@ def buildCommand(self) -> str: if self.getParam("aiservice_watsonxai_on_prem") != "": command += f" --watsonxai-onprem \"{self.getParam('aiservice_watsonxai_on_prem')}\"{newline}" - if self.getParam("minio_root_user") != "": - command += f" --minio-root-user \"{self.getParam('minio_root_user')}\"{newline}" - if self.getParam("minio_root_password") != "": - command += f" --minio-root-password \"{self.getParam('minio_root_password')}\"{newline}" - if self.getParam("tenant_entitlement_type") != "": command += f" --tenant-entitlement-type \"{self.getParam('tenant_entitlement_type')}\"{newline}" if self.getParam("tenant_entitlement_start_date") != "": @@ -191,7 +229,7 @@ def buildCommand(self) -> str: # External database (Oracle/SQL Server/DB2) command += f" --aiservice-db-jdbc-url \"{self.getParam('aiservice_db_jdbc_url')}\"{newline}" command += f" --aiservice-db-username \"{self.getParam('aiservice_db_username')}\"{newline}" - command += f" --aiservice-db-password \"{self.getParam('aiservice_db_password')}\"{newline}" + command += f' --aiservice-db-password "$AISERVICE_DB_PASSWORD"{newline}' if self.getParam("aiservice_db_ca_cert") != "": command += f" --aiservice-db-ca-cert \"{self.getParam('aiservice_db_ca_cert')}\"{newline}" diff --git a/python/src/mas/cli/aiservice/install/argParser.py b/python/src/mas/cli/aiservice/install/argParser.py index 65f9550d64..c924a99cf9 100644 --- a/python/src/mas/cli/aiservice/install/argParser.py +++ b/python/src/mas/cli/aiservice/install/argParser.py @@ -14,6 +14,9 @@ from ... import __version__ as packageVersion from ...cli import getHelpFormatter +# Constants for argument choices +DNS_PROVIDERS = ["cis", "route53"] + def isValidFile(parser, arg) -> str: if not path.exists(arg): @@ -222,7 +225,82 @@ def isValidFile(parser, arg) -> str: help="Path to the YAML file that contains the scheduling configuration for tenant", type=lambda x: isValidFile(aiServiceinstallArgParser, x), ) +aiserviceAdvancedArgGroup.add_argument( + "--domain", + dest="aiservice_domain", + required=False, + help="Configure AI Service with a custom domain", +) +aiserviceAdvancedArgGroup.add_argument( + "--dns-provider", + dest="dns_provider", + required=False, + help="Enable automatic DNS management (see DNS Configuration options)", + choices=DNS_PROVIDERS, + metavar="{cis,route53}", +) +aiserviceAdvancedArgGroup.add_argument( + "--ocp-ingress", + dest="ocp_ingress", + required=False, + help="Overwrites Ingress Domain", +) +# DNS Integration - IBM CIS +# ----------------------------------------------------------------------------- +cisArgGroup = aiServiceinstallArgParser.add_argument_group("DNS Integration - CIS") +cisArgGroup.add_argument( + "--cis-email", + dest="cis_email", + required=False, + help="Required when DNS provider is CIS and you want to use a Let's Encrypt Issuer", +) +cisArgGroup.add_argument( + "--cis-apikey", + dest="cis_apikey", + required=False, + help="Required when DNS provider is CIS", +) +cisArgGroup.add_argument( + "--cis-crn", + dest="cis_crn", + required=False, + help="Required when DNS provider is CIS", +) +cisArgGroup.add_argument( + "--cis-subdomain", + dest="cis_subdomain", + required=False, + help="Optionally setup AI Service instance as a subdomain under a multi-tenant CIS DNS record", +) + +# DNS Integration - AWS Route53 +# ----------------------------------------------------------------------------- +route53ArgGroup = aiServiceinstallArgParser.add_argument_group("DNS Integration - AWS Route53") +route53ArgGroup.add_argument( + "--route53-hosted-zone-name", + dest="route53_hosted_zone_name", + required=False, + help="Required when DNS provider is Route53", +) +route53ArgGroup.add_argument( + "--route53-hosted-zone-region", + dest="route53_hosted_zone_region", + required=False, + help="Required when DNS provider is Route53", +) +route53ArgGroup.add_argument( + "--route53-subdomain", + dest="route53_subdomain", + required=False, + help="Required when DNS provider is Route53", +) +route53ArgGroup.add_argument( + "--route53-email", + dest="route53_email", + required=False, + help="Required when DNS provider is Route53", +) # Database Configuration # ----------------------------------------------------------------------------- diff --git a/python/src/mas/cli/aiservice/install/params.py b/python/src/mas/cli/aiservice/install/params.py index bfbfb8292b..aeb0566686 100644 --- a/python/src/mas/cli/aiservice/install/params.py +++ b/python/src/mas/cli/aiservice/install/params.py @@ -109,4 +109,18 @@ # Slack "slack_token", "slack_channel", + # DNS Providers + "dns_provider", + "aiservice_domain", + "ocp_ingress", + # CIS + "cis_email", + "cis_apikey", + "cis_crn", + "cis_subdomain", + # AWS Route53 + "route53_hosted_zone_name", + "route53_hosted_zone_region", + "route53_subdomain", + "route53_email", ] From 7246bc455bb421c2aaf93df2524b544c976ce79c Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Wed, 12 Aug 2026 12:40:47 +0530 Subject: [PATCH 02/14] [patch] Add DNS integration summary --- python/src/mas/cli/aiservice/install/app.py | 19 ----------------- .../mas/cli/aiservice/install/summarizer.py | 21 ++++++++++++++++++- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/python/src/mas/cli/aiservice/install/app.py b/python/src/mas/cli/aiservice/install/app.py index 3a0b7d41d9..947c86e387 100644 --- a/python/src/mas/cli/aiservice/install/app.py +++ b/python/src/mas/cli/aiservice/install/app.py @@ -689,9 +689,6 @@ def aiServiceSettings(self) -> None: # Configure DNS self.configDNSAndCerts() - # Configure Certificate Issuer - self.configCertIssuer() - # Configure Network configuration for services self.configNetworking() @@ -805,22 +802,6 @@ def configDNSAndCertsRoute53(self): self.setParam("aiservice_certificate_issuer", f"{self.getParam('aiservice_instance_id')}-route53-le-prod") - @logMethodCall - def configCertIssuer(self): - if self.showAdvancedOptions: - self.printH1("Configure Certificate Issuer") - self.printDescription([ - "Provide name of your certificate Issuer", - "This Issuer will be used to generate public certificates for AI Service", - f"The certificate Issuer must be configured in the AI Service namespace: aiservice-{self.getParam("aiservice_instance_id")}" - "When skipped, a self-signed certificate issuer will be created during installation" - ]) - configureCertIssuer = self.yesOrNo("Configure certificate issuer") - if configureCertIssuer: - self.promptForString("Certificate issuer name", "aiservice_certificate_issuer") - else: - self.setParam("aiservice_certificate_issuer", "") - @logMethodCall def configNetworking(self): if self.showAdvancedOptions: diff --git a/python/src/mas/cli/aiservice/install/summarizer.py b/python/src/mas/cli/aiservice/install/summarizer.py index 05a1a022f7..dcaf10899b 100644 --- a/python/src/mas/cli/aiservice/install/summarizer.py +++ b/python/src/mas/cli/aiservice/install/summarizer.py @@ -48,9 +48,28 @@ def aiServiceSummary(self) -> None: self.printParamSummary("Environment Type", "environment_type") self.printSummary("AI Data Science Platform", "Red Hat OpenShift AI (RHOAI)" if self.getParam("rhoai") == "true" else "Open Data Hub (ODH)") - if "aiservice_certificate_issuer" in self.params: + if "aiservice_domain" in self.params: + print() + self.printParamSummary("Domain Name", "aiservice_domain") + self.printParamSummary("DNS Provider", "dns_provider") self.printParamSummary("Certificate Issuer", "aiservice_certificate_issuer") + if self.getParam("ocp_ingress") != "": + self.printParamSummary("OCP Ingress", "ocp_ingress") + if self.getParam("dns_provider") == "cis": + self.printParamSummary("CIS e-mail", "cis_email") + self.printParamSummary("CIS API Key", "cis_apikey") + self.printParamSummary("CIS CRN", "cis_crn") + self.printParamSummary("CIS subdomain", "cis_subdomain") + elif self.getParam("dns_provider") == "route53": + self.printParamSummary("Route 53 e-mail", "route53_email") + self.printParamSummary("Route 53 hosted zone name", "route53_hosted_zone_name") + self.printParamSummary("Route 53 hosted zone region", "route53_hosted_zone_region") + self.printParamSummary("Route 53 subdomain", "route53_subdomain") + elif self.getParam("dns_provider") == "": + pass + + self.printParamSummary("Configure AI Service to run in IPv6 mode", "enable_ipv6") self.printH2("AI Service Tenant Configuration") From 23e2ed6b775512f9356578348019a41945099465 Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Wed, 12 Aug 2026 18:38:13 +0530 Subject: [PATCH 03/14] Update tests --- python/src/mas/cli/aiservice/install/app.py | 4 + .../mas/cli/aiservice/install/summarizer.py | 3 +- .../integration/aiservice_install/test_app.py | 93 +++++++++++-------- 3 files changed, 60 insertions(+), 40 deletions(-) diff --git a/python/src/mas/cli/aiservice/install/app.py b/python/src/mas/cli/aiservice/install/app.py index 947c86e387..a4ee7ab447 100644 --- a/python/src/mas/cli/aiservice/install/app.py +++ b/python/src/mas/cli/aiservice/install/app.py @@ -266,6 +266,10 @@ def nonInteractiveMode(self) -> None: self.setParam("aiservice_s3_ssl", "false") self.setParam("aiservice_s3_region", "none") self.setParam("aiservice_s3_bucket_prefix", "s3-") + + # Set default bucket names + self.setParam("aiservice_s3_tenants_bucket", "km-tenants") + self.setParam("aiservice_s3_templates_bucket", "km-templates") else: self.fatalError(f"Unsupported value for --install-minio: {value}") diff --git a/python/src/mas/cli/aiservice/install/summarizer.py b/python/src/mas/cli/aiservice/install/summarizer.py index dcaf10899b..9b3e6bff92 100644 --- a/python/src/mas/cli/aiservice/install/summarizer.py +++ b/python/src/mas/cli/aiservice/install/summarizer.py @@ -35,7 +35,7 @@ def aiServiceSummary(self) -> None: self.printH2("Maximo Operator Catalog") self.printParamSummary("Catalog Version", "mas_catalog_version") # We only list the digest if it's specified (primary use case is when running development builds in airgap environments) - if self.getParam("mas_catalog_digest" != ""): + if self.getParam("mas_catalog_digest") != "": self.printParamSummary("Catalog Digest", "mas_catalog_digest") self.printH2("IBM Container Registry") @@ -69,7 +69,6 @@ def aiServiceSummary(self) -> None: elif self.getParam("dns_provider") == "": pass - self.printParamSummary("Configure AI Service to run in IPv6 mode", "enable_ipv6") self.printH2("AI Service Tenant Configuration") diff --git a/python/tests/integration/aiservice_install/test_app.py b/python/tests/integration/aiservice_install/test_app.py index 976f381b99..62aa8e0e30 100644 --- a/python/tests/integration/aiservice_install/test_app.py +++ b/python/tests/integration/aiservice_install/test_app.py @@ -24,6 +24,12 @@ def test_install_noninteractive(tmpdir): + """Test non-interactive AI Service install. + + GIVEN a complete set of CLI arguments for AI Service install + WHEN the install command runs in non-interactive mode + THEN the install pipeline is launched without prompting for input. + """ tmpdir.join("authorized_entitlement.lic").write("testLicense") tmpdir.join("aiservice-tenant-affinity-config.yaml").write("#") with mock.patch("mas.cli.cli.config"): @@ -101,23 +107,24 @@ def test_install_noninteractive(tmpdir): "mongoce", "--aiservice-channel", "9.1.x", + "--domain", + "aiservice.example.com", + "--dns-provider", + "cis", + "--cis-email", + "cis@example.com", + "--cis-apikey", + "testCisApiKey", + "--cis-crn", + "crn:v1:test", + "--cis-subdomain", + "aiservice", + "--ocp-ingress", + "cluster-ingress.example.com", "--aiservice-certificate-issuer", - "cert-issuer", + "testInstanceId-cis-le-prod", "--enable-ipv6", - "--s3-accesskey", - "test", - "--s3-secretkey", - "test", - "--s3-host", - "minio-service.minio.svc.cluster.local", - "--s3-port", - "9000", - "--s3-ssl", - "false", - "--s3-region", - "none", - "--s3-bucket-prefix", - "aiservice", + "--install-minio", "--s3-tenants-bucket", "km-tenants", "--s3-templates-bucket", @@ -164,6 +171,12 @@ def test_install_noninteractive(tmpdir): def test_install_interactive_advanced(tmpdir): + """Test advanced interactive AI Service install flow. + + GIVEN advanced interactive answers including custom domain and CIS DNS setup + WHEN the install command runs interactively + THEN the workflow completes using the updated DNS and certificate prompts. + """ tmpdir.join("authorized_entitlement.lic").write("testLicense") tmpdir.join("aiservice-tenant-affinity-config.yaml").write("#") tmpdir.join("mongodb-system.yaml").write("#") @@ -281,24 +294,32 @@ def set_mixin_prompt_input(**kwargs): return "password" if re.match(".*AI Data Science Platform.*", message): return "1" - if re.match(r".*Configure certificate issuer\?.*", message): + if re.match(".*Cluster ingress certificate secret name.*", message): + return "" + if re.match(".*Configure.*domain.*certificate management.*", message): return "y" - if re.match(".*Certificate issuer name.*", message): - return "cert-issuer" + if re.match(".*Configure custom domain.*", message): + return "y" + if re.match(".*AI Service domain.*", message): + return "aiservice.example.com" + if re.match(".*DNS Provider.*", message): + return "1" + if re.match(".*CIS e-mail.*", message): + return "cis@example.com" + if re.match(".*CIS API token.*", message): + return "testCisApiKey" + if re.match(".*CIS CRN.*", message): + return "crn:v1:test" + if re.match(".*CIS subdomain.*", message): + return "aiservice" + if re.match(".*Certificate issuer.*", message): + return "1" + if re.match(".*Cluster Ingress Domain Override.*", message): + return "" if re.match(".*Enable IPv6 SingleStack networking.*", message): return "y" - if re.match(".*RSL url.*", message): - return "https://rls.maximo.test.ibm.com" - if re.match(".*ORG Id of RSL.*", message): - return "rslOrgId" - if re.match(".*Token for RSL.*", message): - return "rslToken" - if re.match(".*Watsonxai machine learning url.*", message): - return "watsonxUrl" if re.match(".*Does the RSL API use a self-signed certificate.*", message): return "n" - if re.match(".*Does the Watsonxai AI use a self-signed certificate.*", message): - return "n" if re.match(".*Do you want to use an external database.*", message): return "n" if re.match(".*Create MongoDb cluster.*", message): @@ -341,6 +362,12 @@ def set_app_prompt_input(**kwargs): def test_install_interactive_simplified(tmpdir): + """Test simplified interactive AI Service install flow. + + GIVEN simplified interactive answers for the default AI Service setup + WHEN the install command runs interactively + THEN the workflow completes without the advanced DNS and certificate prompts. + """ tmpdir.join("authorized_entitlement.lic").write("testLicense") tmpdir.join("mongodb-system.yaml").write("#") tmpdir.join("cert.crt").write("#") @@ -453,18 +480,8 @@ def set_mixin_prompt_input(**kwargs): return "" if re.match(".*Watsonxai Space ID.*", message): return "" - if re.match(".*RSL url.*", message): - return "https://rls.maximo.test.ibm.com" - if re.match(".*ORG Id of RSL.*", message): - return "rslOrgId" - if re.match(".*Token for RSL.*", message): - return "rslToken" - if re.match(".*Watsonxai machine learning url.*", message): - return "watsonxUrl" if re.match(".*Does the RSL API use a self-signed certificate.*", message): return "n" - if re.match(".*Does the Watsonxai AI use a self-signed certificate.*", message): - return "n" if re.match(".*Do you want to use an external database.*", message): return "n" if re.match(".*Create MongoDb cluster.*", message): From 1b4c8aae35a8a6ac69cbf7d4cbaaea705ee2a561 Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Thu, 13 Aug 2026 15:27:50 +0530 Subject: [PATCH 04/14] Update tekton install pipeline --- .../src/mas/cli/aiservice/install/argBuilder.py | 7 +++++++ python/src/mas/cli/aiservice/install/argParser.py | 7 +++++++ python/src/mas/cli/aiservice/install/params.py | 2 ++ tekton/src/params/install-aiservice.yml.j2 | 7 +++++++ tekton/src/pipelines/mas-install.yml.j2 | 4 ---- .../pipelines/taskdefs/aiservice/aiservice.yml.j2 | 3 +++ .../src/pipelines/taskdefs/core/suite-dns.yml.j2 | 6 ++++++ tekton/src/tasks/aiservice/aiservice.yml.j2 | 8 ++++++++ tekton/src/tasks/suite-dns.yml.j2 | 15 +++++++++++++++ 9 files changed, 55 insertions(+), 4 deletions(-) diff --git a/python/src/mas/cli/aiservice/install/argBuilder.py b/python/src/mas/cli/aiservice/install/argBuilder.py index a3d37cecbf..59051b7829 100644 --- a/python/src/mas/cli/aiservice/install/argBuilder.py +++ b/python/src/mas/cli/aiservice/install/argBuilder.py @@ -121,6 +121,13 @@ def buildCommand(self) -> str: if self.getParam("service_account_name") != "": command += f" --service-account {self.getParam('service_account_name')}{newline}" + # OCP Configuration + # ----------------------------------------------------------------------------- + if self.getParam("ocp_ingress_tls_secret_name") != "": + command += f" --ocp-ingress-tls-secret-name \"{self.getParam('ocp_ingress_tls_secret_name')}\"{newline}" + if self.getParam("ocp_ingress") != "": + command += f" --ocp-ingress \"{self.getParam('ocp_ingress')}\"{newline}" + # AI Service Advanced Settings # ----------------------------------------------------------------------------- diff --git a/python/src/mas/cli/aiservice/install/argParser.py b/python/src/mas/cli/aiservice/install/argParser.py index c924a99cf9..bcc1b00db1 100644 --- a/python/src/mas/cli/aiservice/install/argParser.py +++ b/python/src/mas/cli/aiservice/install/argParser.py @@ -245,6 +245,13 @@ def isValidFile(parser, arg) -> str: required=False, help="Overwrites Ingress Domain", ) +aiserviceAdvancedArgGroup.add_argument( + "--ocp-ingress-tls-secret-name", + dest="ocp_ingress_tls_secret_name", + required=False, + default="", + help="Cluster ingress certificate secret name", +) # DNS Integration - IBM CIS # ----------------------------------------------------------------------------- diff --git a/python/src/mas/cli/aiservice/install/params.py b/python/src/mas/cli/aiservice/install/params.py index aeb0566686..2f1769ed73 100644 --- a/python/src/mas/cli/aiservice/install/params.py +++ b/python/src/mas/cli/aiservice/install/params.py @@ -123,4 +123,6 @@ "route53_hosted_zone_region", "route53_subdomain", "route53_email", + # OCP Ingress + "ocp_ingress_tls_secret_name", ] diff --git a/tekton/src/params/install-aiservice.yml.j2 b/tekton/src/params/install-aiservice.yml.j2 index f99aaa077b..4c9d3a1157 100644 --- a/tekton/src/params/install-aiservice.yml.j2 +++ b/tekton/src/params/install-aiservice.yml.j2 @@ -171,6 +171,13 @@ description: Name of the Issuer to configure AI Service to issue certificates default: "" +# MAS Application Configuration - IBM Maximo AI Service - DNS +# ----------------------------------------------------------------------------- +- name: aiservice_domain + type: string + description: Custom domain for IBM Maximo AI Service + default: "" + # MAS Application Configuration - IBM Maximo AI Service - Database # ----------------------------------------------------------------------------- - name: install_db2 diff --git a/tekton/src/pipelines/mas-install.yml.j2 b/tekton/src/pipelines/mas-install.yml.j2 index 7b90335b55..73e08e939b 100644 --- a/tekton/src/pipelines/mas-install.yml.j2 +++ b/tekton/src/pipelines/mas-install.yml.j2 @@ -231,10 +231,6 @@ spec: {{ lookup('template', pipeline_src_dir ~ '/taskdefs/core/suite-dns.yml.j2') | indent(4) }} runAfter: - cert-manager - when: - - input: "$(params.mas_instance_id)" - operator: notin - values: [""] # 5.3 Manual Certificates {{ lookup('template', pipeline_src_dir ~ '/taskdefs/core/suite-certs.yml.j2') | indent(4) }} diff --git a/tekton/src/pipelines/taskdefs/aiservice/aiservice.yml.j2 b/tekton/src/pipelines/taskdefs/aiservice/aiservice.yml.j2 index 1792cac2ec..4d20e66d40 100644 --- a/tekton/src/pipelines/taskdefs/aiservice/aiservice.yml.j2 +++ b/tekton/src/pipelines/taskdefs/aiservice/aiservice.yml.j2 @@ -95,6 +95,9 @@ - name: tenant_scheduling_cfg_file value: $(params.tenant_scheduling_cfg_file) + - name: aiservice_domain + value: $(params.aiservice_domain) + # Database Configuration - name: install_db2 value: $(params.install_db2) diff --git a/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 b/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 index a764e6c05d..af653cd430 100644 --- a/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 +++ b/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 @@ -64,6 +64,12 @@ value: $(params.route53_subdomain) - name: custom_labels value: $(params.custom_labels) + + # AI Service + - name: aiservice_domain + value: $(params.aiservice_domain) + - name: aiservice_instance_id + value: $(params.aiservice_instance_id) taskRef: kind: Task name: mas-devops-suite-dns \ No newline at end of file diff --git a/tekton/src/tasks/aiservice/aiservice.yml.j2 b/tekton/src/tasks/aiservice/aiservice.yml.j2 index 3f7ca841d1..0ab7b11427 100644 --- a/tekton/src/tasks/aiservice/aiservice.yml.j2 +++ b/tekton/src/tasks/aiservice/aiservice.yml.j2 @@ -154,6 +154,11 @@ spec: description: Optional boolean parameter that when set to True, configures services in SingleStack IPv6 networking default: "False" + - name: aiservice_domain + type: string + description: Custom domain for IBM Maximo AI Service + default: "" + # Database Configuration - name: install_db2 type: string @@ -279,6 +284,9 @@ spec: - name: OCP_ENABLE_IPV6 value: $(params.enable_ipv6) + - name: AISERVICE_DOMAIN + value: $(params.aiservice_domain) + # Database Configuration - name: INSTALL_DB2 value: $(params.install_db2) diff --git a/tekton/src/tasks/suite-dns.yml.j2 b/tekton/src/tasks/suite-dns.yml.j2 index cf0af0ff5c..f6c8e741f4 100644 --- a/tekton/src/tasks/suite-dns.yml.j2 +++ b/tekton/src/tasks/suite-dns.yml.j2 @@ -103,6 +103,15 @@ spec: description: Optional string parameter, either path or subdomain, defines the network routing mode used for the suite. default: "" + # AI Service + - name: aiservice_domain + type: string + description: Custom domain for IBM Maximo AI Service + default: "" + - name: aiservice_instance_id + type: string + description: AI Service instance id + stepTemplate: env: {{ lookup('template', task_src_dir ~ '/common/cli-env.yml.j2') | indent(6) }} @@ -166,6 +175,12 @@ spec: - name: MAS_ROUTING_MODE value: $(params.mas_routing_mode) + # AI Service + - name: AISERVICE_DOMAIN + value: $(params.aiservice_domain) + - name: AISERVICE_INSTANCE_ID + value: $(params.aiservice_instance_id) + steps: - name: suite-dns command: From 3736e03ee33522bfd5ea995c8f52094c9e9507b8 Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Fri, 14 Aug 2026 12:47:18 +0530 Subject: [PATCH 05/14] Update argBuilder --- .../mas/cli/aiservice/install/argBuilder.py | 22 +++++++++---------- .../mas/cli/aiservice/install/summarizer.py | 1 + 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/python/src/mas/cli/aiservice/install/argBuilder.py b/python/src/mas/cli/aiservice/install/argBuilder.py index 59051b7829..7008032356 100644 --- a/python/src/mas/cli/aiservice/install/argBuilder.py +++ b/python/src/mas/cli/aiservice/install/argBuilder.py @@ -29,21 +29,19 @@ def buildCommand(self) -> str: command += "export ARTIFACTORY_USERNAME=x\nexport ARTIFACTORY_TOKEN=x\n" # Object Storage Credentials - if self.getParam("aiservice_s3_accesskey") != "": - command += "export AISERVICE_S3_ACCESSKEY=x\n" - if self.getParam("aiservice_s3_secretkey") != "": - command += "export AISERVICE_S3_SECRETKEY=x\n" + if self.getParam("minio_root_user") != "" and self.getParam("minio_root_password") != "": + command += "export MINIO_ROOT_USER=x\n" + command += "export MINIO_ROOT_PASSWORD=x\n" + else: + if self.getParam("aiservice_s3_accesskey") != "": + command += "export AISERVICE_S3_ACCESSKEY=x\n" + if self.getParam("aiservice_s3_secretkey") != "": + command += "export AISERVICE_S3_SECRETKEY=x\n" # Watsonx Credentials if self.getParam("aiservice_watsonxai_apikey") != "": command += "export AISERVICE_WATSONXAI_APIKEY=x\n" - # MinIO Credentials - if self.getParam("minio_root_user") != "": - command += "export MINIO_ROOT_USER=x\n" - if self.getParam("minio_root_password") != "": - command += "export MINIO_ROOT_PASSWORD=x\n" - # Database password if self.getParam("aiservice_db_password") != "": command += "export AISERVICE_DB_PASSWORD=x\n" @@ -158,8 +156,8 @@ def buildCommand(self) -> str: # Object storage if self.getParam("minio_root_user") != "" and self.getParam("minio_root_password") != "": command += f" --install-minio-aiservice{newline}" - command += f" --minio-root-user \"{self.getParam('minio_root_user')}\"{newline}" - command += f" --minio-root-password \"{self.getParam('minio_root_password')}\"{newline}" + command += f' --minio-root-user "$MINIO_ROOT_USER"{newline}' + command += f' --minio-root-password "$MINIO_ROOT_PASSWORD"{newline}' else: if self.getParam("aiservice_s3_accesskey") != "": command += f' --s3-accesskey "$AISERVICE_S3_ACCESSKEY"{newline}' diff --git a/python/src/mas/cli/aiservice/install/summarizer.py b/python/src/mas/cli/aiservice/install/summarizer.py index 9b3e6bff92..996200bc96 100644 --- a/python/src/mas/cli/aiservice/install/summarizer.py +++ b/python/src/mas/cli/aiservice/install/summarizer.py @@ -69,6 +69,7 @@ def aiServiceSummary(self) -> None: elif self.getParam("dns_provider") == "": pass + print() self.printParamSummary("Configure AI Service to run in IPv6 mode", "enable_ipv6") self.printH2("AI Service Tenant Configuration") From c9b1973fc9b13944c155c9fec21fb0686b7674da Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Tue, 18 Aug 2026 14:39:36 +0530 Subject: [PATCH 06/14] [patch] Add CIS Enhanced security --- python/src/mas/cli/aiservice/install/app.py | 17 +++ .../mas/cli/aiservice/install/argBuilder.py | 18 ++- .../mas/cli/aiservice/install/argParser.py | 51 +++++++ .../src/mas/cli/aiservice/install/params.py | 6 + .../mas/cli/aiservice/install/summarizer.py | 7 + .../integration/aiservice_install/test_app.py | 143 ++++++++++++++++++ tekton/src/params/install.yml.j2 | 12 ++ .../pipelines/taskdefs/core/suite-dns.yml.j2 | 8 + tekton/src/tasks/suite-dns.yml.j2 | 22 ++- 9 files changed, 280 insertions(+), 4 deletions(-) diff --git a/python/src/mas/cli/aiservice/install/app.py b/python/src/mas/cli/aiservice/install/app.py index a4ee7ab447..79169ba3b9 100644 --- a/python/src/mas/cli/aiservice/install/app.py +++ b/python/src/mas/cli/aiservice/install/app.py @@ -415,6 +415,10 @@ def nonInteractiveMode(self) -> None: print(f"Unknown option: {key} {value}") self.fatalError(f"Unknown option: {key} {value}") + # For CIS DNS provider, always set cis_entries_to_add to "aiservice" (stand-alone install) + if self.getParam("dns_provider") == "cis": + self.setParam("cis_entries_to_add", "aiservice") + # Load the catalog information try: self.chosenCatalog = getCatalog(self.getParam("mas_catalog_version")) @@ -777,6 +781,19 @@ def configDNSAndCertsCIS(self): ] self.setParam("aiservice_certificate_issuer", certIssuerOptions[certIssuer - 1]) + # CIS security & behaviour options + configEnhancedSecurity = self.yesOrNo("Configure enhanced security for CIS", "cis_enhanced_security") + if configEnhancedSecurity: + self.printDescription(["Enter the name of your CIS service from IBM Cloud"]) + self.promptForString("CIS service name", "cis_service_name") + self.yesOrNo("Enable WAF (Web Application Firewall)", "cis_waf") + self.yesOrNo("Enable CIS proxy", "cis_proxy") + self.yesOrNo("Delete wildcard DNS entries in CIS", "delete_wildcards") + self.yesOrNo("Override and delete existing edge certificates in CIS instance", "override_edge_certs") + + # AI Service stand-alone install: always add only AI Service CIS entries + self.setParam("cis_entries_to_add", "aiservice") + @logMethodCall def configDNSAndCertsRoute53(self): self.setParam("dns_provider", "route53") diff --git a/python/src/mas/cli/aiservice/install/argBuilder.py b/python/src/mas/cli/aiservice/install/argBuilder.py index 7008032356..40cd8416e3 100644 --- a/python/src/mas/cli/aiservice/install/argBuilder.py +++ b/python/src/mas/cli/aiservice/install/argBuilder.py @@ -141,6 +141,18 @@ def buildCommand(self) -> str: command += f" --cis-subdomain \"{self.getParam('cis_subdomain')}\"" command += f" --cis-crn \"{self.getParam('cis_crn')}\"" command += f" --cis-email \"{self.getParam('cis_email')}\"{newline}" + if self.getParam("cis_enhanced_security") == "true": + command += f" --cis-enhanced-security{newline}" + if self.getParam("cis_service_name") != "": + command += f" --cis-service-name \"{self.getParam('cis_service_name')}\"{newline}" + if self.getParam("cis_waf") == "true": + command += f" --cis-waf{newline}" + if self.getParam("cis_proxy") == "true": + command += f" --cis-proxy{newline}" + if self.getParam("delete_wildcards") == "true": + command += f" --delete-wildcards{newline}" + if self.getParam("override_edge_certs") == "true": + command += f" --override-edge-certs{newline}" if self.getParam("dns_provider") == "route53": command += f" --dns-provider route53{newline}" @@ -155,7 +167,7 @@ def buildCommand(self) -> str: # Object storage if self.getParam("minio_root_user") != "" and self.getParam("minio_root_password") != "": - command += f" --install-minio-aiservice{newline}" + command += f" --install-minio{newline}" command += f' --minio-root-user "$MINIO_ROOT_USER"{newline}' command += f' --minio-root-password "$MINIO_ROOT_PASSWORD"{newline}' else: @@ -171,9 +183,9 @@ def buildCommand(self) -> str: command += f" --s3-ssl \"{self.getParam('aiservice_s3_ssl')}\"{newline}" if self.getParam("aiservice_s3_region") != "": command += f" --s3-region \"{self.getParam('aiservice_s3_region')}\"{newline}" + if self.getParam("aiservice_s3_bucket_prefix") != "": + command += f" --s3-bucket-prefix \"{self.getParam('aiservice_s3_bucket_prefix')}\"{newline}" - if self.getParam("aiservice_s3_bucket_prefix") != "": - command += f" --s3-bucket-prefix \"{self.getParam('aiservice_s3_bucket_prefix')}\"{newline}" if self.getParam("aiservice_s3_tenants_bucket") != "": command += f" --s3-tenants-bucket \"{self.getParam('aiservice_s3_tenants_bucket')}\"{newline}" if self.getParam("aiservice_s3_templates_bucket") != "": diff --git a/python/src/mas/cli/aiservice/install/argParser.py b/python/src/mas/cli/aiservice/install/argParser.py index bcc1b00db1..3dd90ecdf7 100644 --- a/python/src/mas/cli/aiservice/install/argParser.py +++ b/python/src/mas/cli/aiservice/install/argParser.py @@ -280,6 +280,57 @@ def isValidFile(parser, arg) -> str: required=False, help="Optionally setup AI Service instance as a subdomain under a multi-tenant CIS DNS record", ) +cisArgGroup.add_argument( + "--cis-enhanced-security", + dest="cis_enhanced_security", + required=False, + default="false", + help="Configure enhanced security for CIS (enables WAF and proxy settings)", + action="store_const", + const="true", +) +cisArgGroup.add_argument( + "--cis-service-name", + dest="cis_service_name", + required=False, + help="CIS service instance name", +) +cisArgGroup.add_argument( + "--cis-waf", + dest="cis_waf", + required=False, + default="true", + help="Enable Web Application Firewall (WAF) for CIS DNS entries", + action="store_const", + const="true", +) +cisArgGroup.add_argument( + "--cis-proxy", + dest="cis_proxy", + required=False, + default="false", + help="Enable CIS proxy for DNS entries", + action="store_const", + const="true", +) +cisArgGroup.add_argument( + "--delete-wildcards", + dest="delete_wildcards", + required=False, + default="false", + help="Force deletion of wildcard DNS entries in CIS", + action="store_const", + const="true", +) +cisArgGroup.add_argument( + "--override-edge-certs", + dest="override_edge_certs", + required=False, + default="true", + help="Override and delete existing edge certificates in CIS instance", + action="store_const", + const="true", +) # DNS Integration - AWS Route53 # ----------------------------------------------------------------------------- diff --git a/python/src/mas/cli/aiservice/install/params.py b/python/src/mas/cli/aiservice/install/params.py index 2f1769ed73..4ef4cf89f3 100644 --- a/python/src/mas/cli/aiservice/install/params.py +++ b/python/src/mas/cli/aiservice/install/params.py @@ -118,6 +118,12 @@ "cis_apikey", "cis_crn", "cis_subdomain", + "cis_service_name", + "cis_enhanced_security", + "override_edge_certs", + "cis_proxy", + "cis_waf", + "delete_wildcards", # AWS Route53 "route53_hosted_zone_name", "route53_hosted_zone_region", diff --git a/python/src/mas/cli/aiservice/install/summarizer.py b/python/src/mas/cli/aiservice/install/summarizer.py index 996200bc96..ee2d768bc5 100644 --- a/python/src/mas/cli/aiservice/install/summarizer.py +++ b/python/src/mas/cli/aiservice/install/summarizer.py @@ -61,6 +61,13 @@ def aiServiceSummary(self) -> None: self.printParamSummary("CIS API Key", "cis_apikey") self.printParamSummary("CIS CRN", "cis_crn") self.printParamSummary("CIS subdomain", "cis_subdomain") + self.printSummary("Enhanced Security", "Yes" if self.getParam("cis_enhanced_security") == "true" else "No") + if self.getParam("cis_enhanced_security") == "true": + self.printParamSummary("CIS Service Name", "cis_service_name") + self.printSummary("WAF Enabled", "Yes" if self.getParam("cis_waf") == "true" else "No") + self.printSummary("Proxy Enabled", "Yes" if self.getParam("cis_proxy") == "true" else "No") + self.printSummary("Delete Wildcard DNS Entries", "Yes" if self.getParam("delete_wildcards") == "true" else "No") + self.printSummary("Override Edge Certificates", "Yes" if self.getParam("override_edge_certs") == "true" else "No") elif self.getParam("dns_provider") == "route53": self.printParamSummary("Route 53 e-mail", "route53_email") self.printParamSummary("Route 53 hosted zone name", "route53_hosted_zone_name") diff --git a/python/tests/integration/aiservice_install/test_app.py b/python/tests/integration/aiservice_install/test_app.py index 62aa8e0e30..136440ddad 100644 --- a/python/tests/integration/aiservice_install/test_app.py +++ b/python/tests/integration/aiservice_install/test_app.py @@ -314,6 +314,18 @@ def set_mixin_prompt_input(**kwargs): return "aiservice" if re.match(".*Certificate issuer.*", message): return "1" + if re.match(".*Configure enhanced security for CIS.*", message): + return "y" + if re.match(".*CIS service name.*", message): + return "test-cis-service" + if re.match(".*Enable WAF.*", message): + return "y" + if re.match(".*Enable CIS proxy.*", message): + return "n" + if re.match(".*Delete wildcard DNS entries in CIS.*", message): + return "n" + if re.match(".*Override and delete existing edge certificates in CIS instance.*", message): + return "y" if re.match(".*Cluster Ingress Domain Override.*", message): return "" if re.match(".*Enable IPv6 SingleStack networking.*", message): @@ -521,3 +533,134 @@ def set_app_prompt_input(**kwargs): storage_class.metadata.name = "nfs-client" app = AiServiceInstallApp() app.install(argv=[]) + + +def test_install_noninteractive_cis_enhanced_security(tmpdir): + """Test non-interactive AI Service install with CIS enhanced security options. + + GIVEN a complete set of CLI arguments including CIS enhanced security flags + WHEN the install command runs in non-interactive mode + THEN the install pipeline is launched with WAF, proxy, wildcard and edge cert settings applied. + """ + tmpdir.join("authorized_entitlement.lic").write("testLicense") + with mock.patch("mas.cli.cli.config"): + dynamic_client = MagicMock(DynamicClient) + resources = MagicMock() + dynamic_client.resources = resources + routes_api = MagicMock() + catalog_api = MagicMock() + crd_api = MagicMock() + namespace_api = MagicMock() + cluster_role_binding_api = MagicMock() + pvc_api = MagicMock() + secret_api = MagicMock() + resource_apis = { + "CatalogSource": catalog_api, + "Route": routes_api, + "CustomResourceDefinition": crd_api, + "Namespace": namespace_api, + "ClusterRoleBinding": cluster_role_binding_api, + "PersistentVolumeClaim": pvc_api, + "Secret": secret_api, + } + resources.get.side_effect = lambda **kwargs: resource_apis.get(kwargs["kind"], None) + route = MagicMock() + route.spec = MagicMock() + route.spec.host = "maximo.ibm.com" + route.spec.displayName = supportedCatalogs["amd64"][1] + routes_api.get.return_value = route + catalog_api.get.side_effect = NotFoundError(ApiException(status="404")) + with ( + mock.patch("mas.cli.cli.DynamicClient") as dynamic_client_class, + mock.patch("mas.cli.cli.getNodes") as get_nodes, + mock.patch("mas.cli.cli.isAirgapInstall") as is_airgap_install, + mock.patch("mas.cli.aiservice.install.app.getCurrentCatalog") as get_current_catalog, + mock.patch("mas.cli.aiservice.install.app.installOpenShiftPipelines"), + mock.patch("mas.cli.aiservice.install.app.updateTektonDefinitions"), + mock.patch("mas.cli.aiservice.install.app.prepareAiServicePipelinesNamespace"), + mock.patch("mas.cli.aiservice.install.app.launchInstallPipeline") as launch_ai_service_install_pipeline, + ): + dynamic_client_class.return_value = dynamic_client + get_nodes.return_value = [{"status": {"nodeInfo": {"architecture": "amd64"}}}] + is_airgap_install.return_value = False + get_current_catalog.return_value = {"catalogId": supportedCatalogs["amd64"][1]} + launch_ai_service_install_pipeline.return_value = "https://pipeline.test.maximo.ibm.com" + with mock.patch("mas.cli.cli.isSNO") as is_sno: + is_sno.return_value = False + app = AiServiceInstallApp() + app.install( + [ + "--mas-catalog-version", + "v9-250828-amd64", + "--ibm-entitlement-key", + "testEntitlementKey", + "--aiservice-instance-id", + "testInstanceId", + "--storage-class-rwo", + "nfs-client", + "--storage-class-rwx", + "nfs-client", + "--storage-pipeline", + "nfs-client", + "--storage-accessmode", + "ReadWriteMany", + "--license-file", + f"{tmpdir}/authorized_entitlement.lic", + "--contact-email", + "maximo@ibm.com", + "--contact-firstname", + "Test", + "--contact-lastname", + "Test", + "--aiservice-channel", + "9.1.x", + "--domain", + "aiservice.example.com", + "--dns-provider", + "cis", + "--cis-email", + "cis@example.com", + "--cis-apikey", + "testCisApiKey", + "--cis-crn", + "crn:v1:test", + "--cis-subdomain", + "aiservice", + "--cis-service-name", + "test-cis-service", + "--cis-enhanced-security", + "--cis-waf", + "--delete-wildcards", + "--override-edge-certs", + "--aiservice-certificate-issuer", + "testInstanceId-cis-le-prod", + "--install-minio", + "--minio-root-user", + "test", + "--minio-root-password", + "test", + "--watsonxai-apikey", + "test", + "--watsonxai-url", + "https://us-south.ml.cloud.ibm.com", + "--watsonxai-project-id", + "test", + "--tenant-entitlement-type", + "standard", + "--tenant-entitlement-start-date", + "2025-08-28", + "--tenant-entitlement-end-date", + "2026-08-28", + "--accept-license", + "--no-confirm", + "--skip-pre-check", + ] + ) + + # Verify the new CIS params are correctly set in the pipeline params + assert app.getParam("cis_enhanced_security") == "true" + assert app.getParam("cis_waf") == "true" + assert app.getParam("delete_wildcards") == "true" + assert app.getParam("override_edge_certs") == "true" + assert app.getParam("cis_service_name") == "test-cis-service" + assert app.getParam("cis_entries_to_add") == "aiservice" diff --git a/tekton/src/params/install.yml.j2 b/tekton/src/params/install.yml.j2 index caf2ef6cd6..3fd63b0975 100644 --- a/tekton/src/params/install.yml.j2 +++ b/tekton/src/params/install.yml.j2 @@ -385,6 +385,18 @@ - name: cis_proxy type: string default: "false" +- name: cis_waf + type: string + default: "true" +- name: delete_wildcards + type: string + default: "false" +- name: update_dns_entries + type: string + default: "true" +- name: cis_entries_to_add + type: string + default: "all" # AWS basic info # ------------------------------------------------------------------------- diff --git a/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 b/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 index af653cd430..ec11864720 100644 --- a/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 +++ b/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 @@ -49,6 +49,14 @@ value: $(params.override_edge_certs) - name: cis_proxy value: $(params.cis_proxy) + - name: cis_waf + value: $(params.cis_waf) + - name: delete_wildcards + value: $(params.delete_wildcards) + - name: update_dns_entries + value: $(params.update_dns_entries) + - name: cis_entries_to_add + value: $(params.cis_entries_to_add) - name: aws_access_key_id value: $(params.aws_access_key_id) diff --git a/tekton/src/tasks/suite-dns.yml.j2 b/tekton/src/tasks/suite-dns.yml.j2 index f6c8e741f4..94abf5abc7 100644 --- a/tekton/src/tasks/suite-dns.yml.j2 +++ b/tekton/src/tasks/suite-dns.yml.j2 @@ -70,6 +70,18 @@ spec: - name: cis_proxy type: string default: "" + - name: cis_waf + type: string + default: "true" + - name: delete_wildcards + type: string + default: "false" + - name: update_dns_entries + type: string + default: "true" + - name: cis_entries_to_add + type: string + default: "all" # AWS Route 53 support - name: aws_access_key_id @@ -157,6 +169,14 @@ spec: value: $(params.override_edge_certs) - name: CIS_PROXY value: $(params.cis_proxy) + - name: CIS_WAF + value: $(params.cis_waf) + - name: DELETE_WILDCARDS + value: $(params.delete_wildcards) + - name: UPDATE_DNS_ENTRIES + value: $(params.update_dns_entries) + - name: CIS_ENTRIES_TO_ADD + value: $(params.cis_entries_to_add) - name: AWS_ACCESS_KEY_ID value: $(params.aws_access_key_id) @@ -169,7 +189,7 @@ spec: - name: ROUTE53_EMAIL value: $(params.route53_email) - name: ROUTE53_SUBDOMAIN - value: $(route53_subdomain) + value: $(params.route53_subdomain) - name: MAS_MANUAL_CERT_MGMT value: $(params.mas_manual_cert_mgmt) - name: MAS_ROUTING_MODE From 67c73ab6aa5e341cc2640b1388a63bc71150f276 Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Tue, 18 Aug 2026 15:12:59 +0530 Subject: [PATCH 07/14] [patch] Pre-commit changes --- python/src/mas/cli/aiservice/install/argParser.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/src/mas/cli/aiservice/install/argParser.py b/python/src/mas/cli/aiservice/install/argParser.py index a6a95f4167..30cd69cd72 100644 --- a/python/src/mas/cli/aiservice/install/argParser.py +++ b/python/src/mas/cli/aiservice/install/argParser.py @@ -227,10 +227,10 @@ def isValidFile(parser, arg) -> str: ) aiserviceAdvancedArgGroup.add_argument( "--tenant-operator-config-file", - dest="tenant_operator_config_file", - required=False, - help="Path to the YAML file that contains the tenant operator customization settings", - type=lambda x: isValidFile(aiServiceinstallArgParser, x), + dest="tenant_operator_config_file", + required=False, + help="Path to the YAML file that contains the tenant operator customization settings", + type=lambda x: isValidFile(aiServiceinstallArgParser, x), ) aiserviceAdvancedArgGroup.add_argument( "--domain", From 809bc0d40618e0dcc03332a7371a188d28cc7f21 Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Tue, 18 Aug 2026 18:05:39 +0530 Subject: [PATCH 08/14] Add new param for CIS enhanced security --- python/src/mas/cli/aiservice/install/app.py | 1 + python/src/mas/cli/aiservice/install/argBuilder.py | 2 ++ python/src/mas/cli/aiservice/install/argParser.py | 9 +++++++++ python/src/mas/cli/aiservice/install/params.py | 1 + python/src/mas/cli/aiservice/install/summarizer.py | 1 + python/tests/integration/aiservice_install/test_app.py | 4 ++++ 6 files changed, 18 insertions(+) diff --git a/python/src/mas/cli/aiservice/install/app.py b/python/src/mas/cli/aiservice/install/app.py index 4b05c914b3..8c815e2aaf 100644 --- a/python/src/mas/cli/aiservice/install/app.py +++ b/python/src/mas/cli/aiservice/install/app.py @@ -791,6 +791,7 @@ def configDNSAndCertsCIS(self): if configEnhancedSecurity: self.printDescription(["Enter the name of your CIS service from IBM Cloud"]) self.promptForString("CIS service name", "cis_service_name") + self.yesOrNo("Update existing CIS DNS entries", "update_dns_entries") self.yesOrNo("Enable WAF (Web Application Firewall)", "cis_waf") self.yesOrNo("Enable CIS proxy", "cis_proxy") self.yesOrNo("Delete wildcard DNS entries in CIS", "delete_wildcards") diff --git a/python/src/mas/cli/aiservice/install/argBuilder.py b/python/src/mas/cli/aiservice/install/argBuilder.py index 358303ee6c..48bcc39e3d 100644 --- a/python/src/mas/cli/aiservice/install/argBuilder.py +++ b/python/src/mas/cli/aiservice/install/argBuilder.py @@ -145,6 +145,8 @@ def buildCommand(self) -> str: command += f" --cis-enhanced-security{newline}" if self.getParam("cis_service_name") != "": command += f" --cis-service-name \"{self.getParam('cis_service_name')}\"{newline}" + if self.getParam("update_dns_entries") == "true": + command += f" --update-dns-entries{newline}" if self.getParam("cis_waf") == "true": command += f" --cis-waf{newline}" if self.getParam("cis_proxy") == "true": diff --git a/python/src/mas/cli/aiservice/install/argParser.py b/python/src/mas/cli/aiservice/install/argParser.py index 30cd69cd72..3e8b72f5ca 100644 --- a/python/src/mas/cli/aiservice/install/argParser.py +++ b/python/src/mas/cli/aiservice/install/argParser.py @@ -302,6 +302,15 @@ def isValidFile(parser, arg) -> str: required=False, help="CIS service instance name", ) +cisArgGroup.add_argument( + "--update-dns-entries", + dest="update_dns_entries", + required=False, + default="true", + help="Update existing DNS entries in CIS if they already exist (default: true)", + action="store_const", + const="true", +) cisArgGroup.add_argument( "--cis-waf", dest="cis_waf", diff --git a/python/src/mas/cli/aiservice/install/params.py b/python/src/mas/cli/aiservice/install/params.py index 4ef4cf89f3..ca7c6ebf19 100644 --- a/python/src/mas/cli/aiservice/install/params.py +++ b/python/src/mas/cli/aiservice/install/params.py @@ -120,6 +120,7 @@ "cis_subdomain", "cis_service_name", "cis_enhanced_security", + "update_dns_entries", "override_edge_certs", "cis_proxy", "cis_waf", diff --git a/python/src/mas/cli/aiservice/install/summarizer.py b/python/src/mas/cli/aiservice/install/summarizer.py index 7c9e7c0b6a..e482a85abe 100644 --- a/python/src/mas/cli/aiservice/install/summarizer.py +++ b/python/src/mas/cli/aiservice/install/summarizer.py @@ -64,6 +64,7 @@ def aiServiceSummary(self) -> None: self.printSummary("Enhanced Security", "Yes" if self.getParam("cis_enhanced_security") == "true" else "No") if self.getParam("cis_enhanced_security") == "true": self.printParamSummary("CIS Service Name", "cis_service_name") + self.printSummary("Update Existing DNS Entries", "Yes" if self.getParam("update_dns_entries") == "true" else "No") self.printSummary("WAF Enabled", "Yes" if self.getParam("cis_waf") == "true" else "No") self.printSummary("Proxy Enabled", "Yes" if self.getParam("cis_proxy") == "true" else "No") self.printSummary("Delete Wildcard DNS Entries", "Yes" if self.getParam("delete_wildcards") == "true" else "No") diff --git a/python/tests/integration/aiservice_install/test_app.py b/python/tests/integration/aiservice_install/test_app.py index b650b9ff20..f24339a1c4 100644 --- a/python/tests/integration/aiservice_install/test_app.py +++ b/python/tests/integration/aiservice_install/test_app.py @@ -326,6 +326,8 @@ def set_mixin_prompt_input(**kwargs): return "y" if re.match(".*CIS service name.*", message): return "test-cis-service" + if re.match(".*Update existing CIS DNS entries.*", message): + return "y" if re.match(".*Enable WAF.*", message): return "y" if re.match(".*Enable CIS proxy.*", message): @@ -637,6 +639,7 @@ def test_install_noninteractive_cis_enhanced_security(tmpdir): "--cis-service-name", "test-cis-service", "--cis-enhanced-security", + "--update-dns-entries", "--cis-waf", "--delete-wildcards", "--override-edge-certs", @@ -667,6 +670,7 @@ def test_install_noninteractive_cis_enhanced_security(tmpdir): # Verify the new CIS params are correctly set in the pipeline params assert app.getParam("cis_enhanced_security") == "true" + assert app.getParam("update_dns_entries") == "true" assert app.getParam("cis_waf") == "true" assert app.getParam("delete_wildcards") == "true" assert app.getParam("override_edge_certs") == "true" From 83e3e5db95f79c1e43641b8018ee15eba9973610 Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Tue, 18 Aug 2026 20:48:00 +0530 Subject: [PATCH 09/14] Code refactor --- python/src/mas/cli/aiservice/install/argBuilder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/src/mas/cli/aiservice/install/argBuilder.py b/python/src/mas/cli/aiservice/install/argBuilder.py index 48bcc39e3d..a9d1e31802 100644 --- a/python/src/mas/cli/aiservice/install/argBuilder.py +++ b/python/src/mas/cli/aiservice/install/argBuilder.py @@ -137,7 +137,7 @@ def buildCommand(self) -> str: command += f" --domain \"{self.getParam('aiservice_domain')}\"{newline}" if self.getParam("dns_provider") == "cis": - command += ' --dns-provider cis --cis-apikey "$CIS_APIKEY"' + command += f' --dns-provider cis --cis-apikey "$CIS_APIKEY"{newline}' command += f" --cis-subdomain \"{self.getParam('cis_subdomain')}\"" command += f" --cis-crn \"{self.getParam('cis_crn')}\"" command += f" --cis-email \"{self.getParam('cis_email')}\"{newline}" From f24765d228c1484f481382e2e5a7e43e00062528 Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Wed, 19 Aug 2026 16:47:40 +0530 Subject: [PATCH 10/14] Add missing param --- python/src/mas/cli/aiservice/install/argBuilder.py | 14 ++++++++++---- python/src/mas/cli/aiservice/install/argParser.py | 12 ++++++++++++ python/src/mas/cli/aiservice/install/params.py | 2 ++ tekton/src/pipelines/mas-install.yml.j2 | 1 + 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/python/src/mas/cli/aiservice/install/argBuilder.py b/python/src/mas/cli/aiservice/install/argBuilder.py index a9d1e31802..406d46bc61 100644 --- a/python/src/mas/cli/aiservice/install/argBuilder.py +++ b/python/src/mas/cli/aiservice/install/argBuilder.py @@ -42,6 +42,9 @@ def buildCommand(self) -> str: if self.getParam("aiservice_watsonxai_apikey") != "": command += "export AISERVICE_WATSONXAI_APIKEY=x\n" + if self.getParam("cis_apikey") != "": + command += "export CIS_APIKEY=x\n" + # Database password if self.getParam("aiservice_db_password") != "": command += "export AISERVICE_DB_PASSWORD=x\n" @@ -137,10 +140,11 @@ def buildCommand(self) -> str: command += f" --domain \"{self.getParam('aiservice_domain')}\"{newline}" if self.getParam("dns_provider") == "cis": - command += f' --dns-provider cis --cis-apikey "$CIS_APIKEY"{newline}' - command += f" --cis-subdomain \"{self.getParam('cis_subdomain')}\"" - command += f" --cis-crn \"{self.getParam('cis_crn')}\"" - command += f" --cis-email \"{self.getParam('cis_email')}\"{newline}" + command += f" --dns-provider cis{newline}" + command += f' --cis-apikey "$CIS_APIKEY"{newline}' + command += f" --cis-subdomain \"{self.getParam('cis_subdomain')}\"{newline}" + command += f" --cis-crn \"{self.getParam('cis_crn')}\"{newline}" + command += f" --cis-email \"{self.getParam('cis_email')}\"{newline}" if self.getParam("cis_enhanced_security") == "true": command += f" --cis-enhanced-security{newline}" if self.getParam("cis_service_name") != "": @@ -162,6 +166,8 @@ def buildCommand(self) -> str: command += f" --route53-email \"{self.getParam('route53_email')}\"{newline}" command += f" --route53-hosted-zone-name \"{self.getParam('route53_hosted_zone_name')}\"{newline}" command += f" --route53-hosted-zone-region \"{self.getParam('route53_hosted_zone_region')}\"{newline}" + command += f' --aws-access-key-id "$AWS_ACCESS_KEY_ID"{newline}' + command += f' --aws-secret-access-key "$AWS_SECRET_ACCESS_KEY"{newline}' # Enable IPv6 networking if self.getParam("enable_ipv6").lower() == "true": diff --git a/python/src/mas/cli/aiservice/install/argParser.py b/python/src/mas/cli/aiservice/install/argParser.py index 3e8b72f5ca..fca378a518 100644 --- a/python/src/mas/cli/aiservice/install/argParser.py +++ b/python/src/mas/cli/aiservice/install/argParser.py @@ -351,6 +351,18 @@ def isValidFile(parser, arg) -> str: # DNS Integration - AWS Route53 # ----------------------------------------------------------------------------- route53ArgGroup = aiServiceinstallArgParser.add_argument_group("DNS Integration - AWS Route53") +route53ArgGroup.add_argument( + "--aws-access-key-id", + dest="aws_access_key_id", + required=False, + help="AWS access key ID for authenticating with the AWS account (required for Route53 DNS integration)", +) +route53ArgGroup.add_argument( + "--aws-secret-access-key", + dest="aws_secret_access_key", + required=False, + help="AWS secret access key for authenticating with the AWS account (required for Route53 DNS integration)", +) route53ArgGroup.add_argument( "--route53-hosted-zone-name", dest="route53_hosted_zone_name", diff --git a/python/src/mas/cli/aiservice/install/params.py b/python/src/mas/cli/aiservice/install/params.py index ca7c6ebf19..94a997fbe4 100644 --- a/python/src/mas/cli/aiservice/install/params.py +++ b/python/src/mas/cli/aiservice/install/params.py @@ -126,6 +126,8 @@ "cis_waf", "delete_wildcards", # AWS Route53 + "aws_access_key_id", + "aws_secret_access_key", "route53_hosted_zone_name", "route53_hosted_zone_region", "route53_subdomain", diff --git a/tekton/src/pipelines/mas-install.yml.j2 b/tekton/src/pipelines/mas-install.yml.j2 index 73e08e939b..3510b8857b 100644 --- a/tekton/src/pipelines/mas-install.yml.j2 +++ b/tekton/src/pipelines/mas-install.yml.j2 @@ -437,6 +437,7 @@ spec: - minio - aiservice-odh - aiservice-rhoai + - suite-dns # 14.2 Generate AiCfg with auto-detected values # ------------------------------------------------------------------------- From 6002511dd4143c7cd05bf22e326d2609fba6ae5e Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Mon, 7 Sep 2026 17:38:34 +0530 Subject: [PATCH 11/14] [minor] Support AI Service DNS configuration with mas install --- python/src/mas/cli/install/app.py | 172 ++++-- python/src/mas/cli/install/argBuilder.py | 25 + python/src/mas/cli/install/argParser.py | 98 +++- python/src/mas/cli/install/params.py | 16 +- python/src/mas/cli/install/summarizer.py | 28 +- .../integration/aiservice_install/test_app.py | 129 +++++ .../integration/install/test_aiservice_dns.py | 507 ++++++++++++++++++ 7 files changed, 927 insertions(+), 48 deletions(-) create mode 100644 python/tests/integration/install/test_aiservice_dns.py diff --git a/python/src/mas/cli/install/app.py b/python/src/mas/cli/install/app.py index 693750f7c0..13a5441aa5 100644 --- a/python/src/mas/cli/install/app.py +++ b/python/src/mas/cli/install/app.py @@ -1050,9 +1050,9 @@ def configDNSAndCerts(self): if dnsProvider == 1: self.configDNSAndCertsCloudflare() elif dnsProvider == 2: - self.configDNSAndCertsCIS() + self.configDNSAndCertsCIS(configMas=True, configAIService=False) elif dnsProvider == 3: - self.configDNSAndCertsRoute53() + self.configDNSAndCertsRoute53(configMas=True, configAIService=False) elif dnsProvider == 4: # Use MAS default self-signed cluster issuer with a custom domain self.setParam("dns_provider", "") @@ -1083,14 +1083,16 @@ def configDNSAndCerts(self): self.manualCertsDir = None @logMethodCall - def configDNSAndCertsCloudflare(self): - # User has chosen to set up DNS integration with Cloudflare - self.setParam("dns_provider", "cloudflare") - self.promptForString("Cloudflare e-mail", "cloudflare_email") - self.promptForString("Cloudflare API token", "cloudflare_apitoken", isPassword=True) - self.promptForString("Cloudflare zone", "cloudflare_zone") - self.promptForString("Cloudflare subdomain", "cloudflare_subdomain") + def _buildCertIssuerName(self, instanceId: str, provider: str, certIssuer: int) -> str: + options = [ + f"{instanceId}-{provider}-le-prod", + f"{instanceId}-{provider}-le-stg", + "", + ] + return options[certIssuer - 1] + @logMethodCall + def _promptCertIssuer(self) -> int: self.printDescription( [ "Certificate Issuer:", @@ -1099,40 +1101,46 @@ def configDNSAndCertsCloudflare(self): " 3. Self-Signed", ] ) - certIssuer = self.promptForInt("Certificate issuer", min=1, max=3) - certIssuerOptions = [ - f"{self.getParam('mas_instance_id')}-cloudflare-le-prod", - f"{self.getParam('mas_instance_id')}-cloudflare-le-stg", - "", - ] - self.setParam("mas_cluster_issuer", certIssuerOptions[certIssuer - 1]) + return self.promptForInt("Certificate issuer", min=1, max=3) @logMethodCall - def configDNSAndCertsCIS(self): + def configDNSAndCertsCloudflare(self): + # User has chosen to set up DNS integration with Cloudflare + self.setParam("dns_provider", "cloudflare") + self.promptForString("Cloudflare e-mail", "cloudflare_email") + self.promptForString("Cloudflare API token", "cloudflare_apitoken", isPassword=True) + self.promptForString("Cloudflare zone", "cloudflare_zone") + self.promptForString("Cloudflare subdomain", "cloudflare_subdomain") + + certIssuer = self._promptCertIssuer() + self.setParam("mas_cluster_issuer", self._buildCertIssuerName(self.getParam("mas_instance_id"), "cloudflare", certIssuer)) + + @logMethodCall + def configDNSAndCertsCIS(self, configMas: bool, configAIService: bool): self.setParam("dns_provider", "cis") self.promptForString("CIS e-mail", "cis_email") self.promptForString("CIS API token", "cis_apikey", isPassword=True) self.promptForString("CIS CRN", "cis_crn") self.promptForString("CIS subdomain", "cis_subdomain") - self.printDescription( - [ - "Certificate Issuer:", - " 1. LetsEncrypt (Production)", - " 2. LetsEncrypt (Staging)", - " 3. Self-Signed", - ] - ) - certIssuer = self.promptForInt("Certificate issuer", min=1, max=3) - certIssuerOptions = [ - f"{self.getParam('mas_instance_id')}-cis-le-prod", - f"{self.getParam('mas_instance_id')}-cis-le-stg", - "", - ] - self.setParam("mas_cluster_issuer", certIssuerOptions[certIssuer - 1]) + certIssuer = self._promptCertIssuer() + if configMas: + 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)) + + configEnhancedSecurity = self.yesOrNo("Configure enhanced security for CIS", "cis_enhanced_security") + if configEnhancedSecurity: + self.printDescription(["Enter the name of your CIS service from IBM Cloud"]) + self.promptForString("CIS service name", "cis_service_name") + self.yesOrNo("Update existing CIS DNS entries", "update_dns_entries") + self.yesOrNo("Enable WAF (Web Application Firewall)", "cis_waf") + self.yesOrNo("Enable CIS proxy", "cis_proxy") + self.yesOrNo("Delete wildcard DNS entries in CIS", "delete_wildcards") + self.yesOrNo("Override and delete existing edge certificates in CIS instance", "override_edge_certs") @logMethodCall - def configDNSAndCertsRoute53(self): + def configDNSAndCertsRoute53(self, configMas: bool, configAIService: bool): self.setParam("dns_provider", "route53") self.printDescription( [ @@ -1158,7 +1166,11 @@ def configDNSAndCertsRoute53(self): self.promptForString("AWS Route 53 subdomain", "route53_subdomain") self.promptForString("AWS Route 53 e-mail", "route53_email") - self.setParam("mas_cluster_issuer", f"{self.getParam('mas_instance_id')}-route53-le-prod") + if configMas: + self.setParam("mas_cluster_issuer", f"{self.getParam('mas_instance_id')}-route53-le-prod") + + if configAIService: + self.setParam("aiservice_certificate_issuer", f"{self.getParam('aiservice_instance_id')}-route53-le-prod") @logMethodCall def configApps(self): @@ -1845,16 +1857,73 @@ def aiServiceSettings(self) -> None: "If an existing ODH installation is detected, the installer will automatically migrate it to RHOAI." ) - # Configure Certificate Issuer - self.configAIServiceCertIssuer() + # DNS configuration for AI Service + self.configAIServiceDNSAndCerts() @logMethodCall - def configAIServiceCertIssuer(self): + def configAIServiceDNSAndCerts(self): if self.showAdvancedOptions: - self.printH1("Configure Certificate Issuer") - configureCertIssuer = self.yesOrNo("Configure certificate issuer") - if configureCertIssuer: - self.promptForString("Certificate issuer name", "aiservice_certificate_issuer") + self.printH1("Configure Domain & Certificate Management for AI Service") + configAIServiceDNS = self.yesOrNo("Configure domain & certificate management for AI Service") + if configAIServiceDNS: + if self.getParam("mas_domain") != "" and self.getParam("dns_provider") != "": + if self.getParam("dns_provider") == "cloudflare": + self.printDescription( + [ + "MAS is configured to use Cloudflare as the DNS provider.", + "AI Service does not support DNS configuration with Cloudflare.", + "DNS for AI Service will therefore need to be configured manually.", + ] + ) + else: + self.printDescription( + [ + f"MAS is configured with the domain {self.getParam('mas_domain')} using the {self.getParam('dns_provider')} DNS provider.", + "The same domain and DNS provider configuration will be applied to AI Service.", + ] + ) + self.setParam("aiservice_domain", self.getParam("mas_domain")) + if self.getParam("mas_cluster_issuer") != "": + aiserviceCertIssuer = self.getParam("mas_cluster_issuer").replace( + self.getParam("mas_instance_id"), self.getParam("aiservice_instance_id") + ) + self.setParam("aiservice_certificate_issuer", aiserviceCertIssuer) + else: + self.setParam("aiservice_certificate_issuer", "") + else: + self.promptForString("AI Service domain", "aiservice_domain") + self.printDescription( + [ + "", + "DNS Integrations:", + " 1. IBM Cloud Internet Services", + " 2. AWS Route 53", + " 3. None (I will set up DNS myself)", + ] + ) + dnsProvider = self.promptForInt("DNS Provider", min=1, max=3) + + if dnsProvider == 1: + self.configDNSAndCertsCIS(configMas=False, configAIService=True) + elif dnsProvider == 2: + self.configDNSAndCertsRoute53(configMas=False, configAIService=True) + elif dnsProvider == 3: + # Use self-signed certificate Issuer with custom domain + self.setParam("dns_provider", "") + self.setParam("aiservice_certificate_issuer", "") + + if dnsProvider == 1: + self.printDescription( + [ + "By default, DNS CNAME records will be created pointing to the domain of the cluster ingress (ingress.config.openshift.io/cluster).", + "CIS DNS integrations support the ability to provide an alternative domain, which may be necessary if you are using OpenShift Container Platform in a non-standard networking configuration.", + ] + ) + self.promptForString("Cluster Ingress Domain Override", "ocp_ingress") + else: + # Use self-signed certificate Issuer with default domain + self.setParam("aiservice_domain", "") + self.setParam("aiservice_certificate_issuer", "") @logMethodCall def aiServiceTenantSettings(self) -> None: @@ -2572,6 +2641,27 @@ def nonInteractiveMode(self) -> None: 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')})") + if self.installAIService: + # Configure DNS integration for AI Service + # For non-interactive mode, MAS domain configuration will be used for AI Service. + # Interactive mode, provides an option to configure DNS for AI service when DNS integration is not configured for MAS. + if self.getParam("mas_domain") != "": + if self.getParam("dns_provider") != "cloudflare": + self.setParam("aiservice_domain", self.getParam("mas_domain")) + if self.getParam("mas_cluster_issuer") != "": + aiserviceCertIssuer = self.getParam("mas_cluster_issuer").replace( + self.getParam("mas_instance_id"), self.getParam("aiservice_instance_id") + ) + self.setParam("aiservice_certificate_issuer", aiserviceCertIssuer) + else: + # Self signed certificate issuer with custom domain + self.setParam("aiservice_certificate_issuer", "") + else: + # Cloudflare DNS integration is not supported for AI Service. + # Installation will continue without AI Service DNS configuration + self.setParam("aiservice_domain", "") + self.setParam("aiservice_certificate_issuer", "") + self.applyPreInstallMASRBAC = evaluatePreinstallRBACAccess( dynamicClient=self.dynamicClient, masChannel=self.getParam("mas_channel"), diff --git a/python/src/mas/cli/install/argBuilder.py b/python/src/mas/cli/install/argBuilder.py index ff9463d884..387eaf73ff 100644 --- a/python/src/mas/cli/install/argBuilder.py +++ b/python/src/mas/cli/install/argBuilder.py @@ -26,6 +26,8 @@ def buildCommand(self) -> str: command += "export AWS_ACCESS_KEY_ID=x\n" if self.getParam("secret_access_key") != "": command += "export SECRET_ACCESS_KEY=x\n" + if self.getParam("aws_secret_access_key") != "": + command += "export AWS_SECRET_ACCESS_KEY=x\n" if self.getParam("artifactory_username") != "": command += "export ARTIFACTORY_USERNAME=x\nexport ARTIFACTORY_TOKEN=x\n" @@ -151,6 +153,20 @@ def buildCommand(self) -> str: command += f" --cis-subdomain \"{self.getParam('cis_subdomain')}\"" command += f" --cis-crn \"{self.getParam('cis_crn')}\"" command += f" --cis-email \"{self.getParam('cis_email')}\"{newline}" + if self.getParam("cis_enhanced_security") == "true": + command += f" --cis-enhanced-security{newline}" + if self.getParam("cis_service_name") != "": + command += f" --cis-service-name \"{self.getParam('cis_service_name')}\"{newline}" + if self.getParam("update_dns_entries") == "true": + command += f" --update-dns-entries{newline}" + if self.getParam("cis_waf") == "true": + command += f" --cis-waf{newline}" + if self.getParam("cis_proxy") == "true": + command += f" --cis-proxy{newline}" + if self.getParam("delete_wildcards") == "true": + command += f" --delete-wildcards{newline}" + if self.getParam("override_edge_certs") == "true": + command += f" --override-edge-certs{newline}" if self.getParam("dns_provider") == "cloudflare": command += f' --dns-provider cloudflare --cloudflare-apitoken "$CLOUDFLARE_APITOKEN"{newline}' @@ -158,6 +174,15 @@ def buildCommand(self) -> str: command += f" --cloudflare-zone \"{self.getParam('cloudflare_zone')}\"{newline}" command += f" --cloudflare-subdomain \"{self.getParam('cloudflare_subdomain')}\"{newline}" + if self.getParam("dns_provider") == "route53": + command += f" --dns-provider route53{newline}" + command += f" --route53-hosted-zone-name \"{self.getParam('route53_hosted_zone_name')}\"{newline}" + command += f" --route53-hosted-zone-region \"{self.getParam('route53_hosted_zone_region')}\"{newline}" + command += f" --route53-subdomain \"{self.getParam('route53_subdomain')}\"{newline}" + command += f" --route53-email \"{self.getParam('route53_email')}\"{newline}" + command += f" --aws-access-key-id $AWS_ACCESS_KEY_ID{newline}" + command += f" --aws-secret-access-key $AWS_SECRET_ACCESS_KEY{newline}" + if self.getParam("mas_cluster_issuer") != "": command += f" --mas-cluster-issuer \"{self.getParam('mas_cluster_issuer')}\"{newline}" diff --git a/python/src/mas/cli/install/argParser.py b/python/src/mas/cli/install/argParser.py index bc0a5c6850..ee80b68098 100644 --- a/python/src/mas/cli/install/argParser.py +++ b/python/src/mas/cli/install/argParser.py @@ -185,7 +185,7 @@ def isValidFile(parser: argparse.ArgumentParser, arg: str) -> str: "--domain", dest="mas_domain", required=False, - help="Configure MAS with a custom domain", + help="Configure MAS with a custom domain, Same domain will be used for AI Service when AI Service is being installed.", ) masAdvancedArgGroup.add_argument( "--disable-walkme", @@ -296,6 +296,66 @@ def isValidFile(parser: argparse.ArgumentParser, arg: str) -> str: required=False, help="Optionally setup MAS instance as a subdomain under a multi-tenant CIS DNS record", ) +cisArgGroup.add_argument( + "--cis-enhanced-security", + dest="cis_enhanced_security", + required=False, + default="false", + help="Configure enhanced security for CIS (enables WAF and proxy settings)", + action="store_const", + const="true", +) +cisArgGroup.add_argument( + "--cis-service-name", + dest="cis_service_name", + required=False, + help="CIS service instance name", +) +cisArgGroup.add_argument( + "--update-dns-entries", + dest="update_dns_entries", + required=False, + default="true", + help="Update existing DNS entries in CIS if they already exist (default: true)", + action="store_const", + const="true", +) +cisArgGroup.add_argument( + "--cis-waf", + dest="cis_waf", + required=False, + default="true", + help="Enable Web Application Firewall (WAF) for CIS DNS entries", + action="store_const", + const="true", +) +cisArgGroup.add_argument( + "--cis-proxy", + dest="cis_proxy", + required=False, + default="false", + help="Enable CIS proxy for DNS entries", + action="store_const", + const="true", +) +cisArgGroup.add_argument( + "--delete-wildcards", + dest="delete_wildcards", + required=False, + default="false", + help="Force deletion of wildcard DNS entries in CIS", + action="store_const", + const="true", +) +cisArgGroup.add_argument( + "--override-edge-certs", + dest="override_edge_certs", + required=False, + default="true", + help="Override and delete existing edge certificates in CIS instance", + action="store_const", + const="true", +) # DNS Integration - CloudFlare # ----------------------------------------------------------------------------- @@ -328,6 +388,37 @@ def isValidFile(parser: argparse.ArgumentParser, arg: str) -> str: help="Required when DNS provider is Cloudflare", ) +# DNS Integration - AWS Route53 +# ----------------------------------------------------------------------------- +route53ArgGroup = installArgParser.add_argument_group( + "DNS Integration - AWS Route53", + "Configuration options for AWS Route53 DNS provider, including hosted zone, region, subdomain, and email.", +) +route53ArgGroup.add_argument( + "--route53-hosted-zone-name", + dest="route53_hosted_zone_name", + required=False, + help="Required when DNS provider is Route53", +) +route53ArgGroup.add_argument( + "--route53-hosted-zone-region", + dest="route53_hosted_zone_region", + required=False, + help="Required when DNS provider is Route53", +) +route53ArgGroup.add_argument( + "--route53-subdomain", + dest="route53_subdomain", + required=False, + help="Required when DNS provider is Route53", +) +route53ArgGroup.add_argument( + "--route53-email", + dest="route53_email", + required=False, + help="Required when DNS provider is Route53 and you want to use a Let's Encrypt ClusterIssuer", +) + # Storage # ----------------------------------------------------------------------------- storageArgGroup = installArgParser.add_argument_group( @@ -1484,6 +1575,11 @@ def isValidFile(parser: argparse.ArgumentParser, arg: str) -> str: required=False, help="Set AWS access key ID for the target AWS account", ) +cloudArgGroup.add_argument( + "--aws-secret-access-key", + required=False, + help="Set AWS secret access key for the target AWS account", +) cloudArgGroup.add_argument( "--secret-access-key", required=False, diff --git a/python/src/mas/cli/install/params.py b/python/src/mas/cli/install/params.py index ef292b2a0e..fec814f7de 100644 --- a/python/src/mas/cli/install/params.py +++ b/python/src/mas/cli/install/params.py @@ -71,15 +71,26 @@ # SLS "sls_namespace", # DNS Providers - # TODO: Route53 support "dns_provider", "mas_cluster_issuer", "ocp_ingress", + # Route53 + "route53_hosted_zone_name", + "route53_hosted_zone_region", + "route53_subdomain", + "route53_email", # CIS "cis_email", "cis_apikey", "cis_crn", "cis_subdomain", + "cis_service_name", + "cis_enhanced_security", + "update_dns_entries", + "override_edge_certs", + "cis_proxy", + "cis_waf", + "delete_wildcards", # CloudFlare "cloudflare_email", "cloudflare_apitoken", @@ -150,6 +161,7 @@ "aws_region", "aws_access_key_id", "secret_access_key", + "aws_secret_access_key", "aws_vpc_id", # Dev Mode "artifactory_username", @@ -238,6 +250,8 @@ "rsl_ca_crt", "environment_type", "configure_aiassistant", + # AI Service Domain + "aiservice_domain", # Certificate Issuer "aiservice_certificate_issuer", # Grafana diff --git a/python/src/mas/cli/install/summarizer.py b/python/src/mas/cli/install/summarizer.py index 0bda7a8190..60aece2d07 100644 --- a/python/src/mas/cli/install/summarizer.py +++ b/python/src/mas/cli/install/summarizer.py @@ -125,11 +125,15 @@ def masSummary(self) -> None: else: self.printSummary("Install Mode", "Connected Install") - if "mas_domain" in self.params: + # AI Service domain is added to the AI Service section along with other AI Service-specific configurations. + # The condition below prevents the DNS provider summary from being displayed twice when the MAS and AI Service domains are configured. + if "mas_domain" in self.params or "aiservice_domain" in self.params: print() - self.printParamSummary("Domain Name", "mas_domain") + if self.getParam("mas_domain") != "": + self.printParamSummary("Domain Name", "mas_domain") self.printParamSummary("DNS Provider", "dns_provider") - self.printParamSummary("Certificate Issuer", "mas_cluster_issuer") + if self.getParam("mas_cluster_issuer") != "": + self.printParamSummary("Certificate Issuer", "mas_cluster_issuer") if self.getParam("ocp_ingress") != "": self.printParamSummary("OCP Ingress", "ocp_ingress") @@ -143,8 +147,19 @@ def masSummary(self) -> None: self.printParamSummary("CIS API Key", "cis_apikey") self.printParamSummary("CIS CRN", "cis_crn") self.printParamSummary("CIS subdomain", "cis_subdomain") + self.printSummary("Enhanced Security", "Yes" if self.getParam("cis_enhanced_security") == "true" else "No") + if self.getParam("cis_enhanced_security") == "true": + self.printParamSummary("CIS Service Name", "cis_service_name") + self.printSummary("Update Existing DNS Entries", "Yes" if self.getParam("update_dns_entries") == "true" else "No") + self.printSummary("WAF Enabled", "Yes" if self.getParam("cis_waf") == "true" else "No") + self.printSummary("Proxy Enabled", "Yes" if self.getParam("cis_proxy") == "true" else "No") + self.printSummary("Delete Wildcard DNS Entries", "Yes" if self.getParam("delete_wildcards") == "true" else "No") + self.printSummary("Override Edge Certificates", "Yes" if self.getParam("override_edge_certs") == "true" else "No") elif self.getParam("dns_provider") == "route53": - pass + self.printParamSummary("Route53 e-mail", "route53_email") + self.printParamSummary("Route53 hosted zone name", "route53_hosted_zone_name") + self.printParamSummary("Route53 hosted zone region", "route53_hosted_zone_region") + self.printParamSummary("Route53 subdomain", "route53_subdomain") elif self.getParam("dns_provider") == "": pass @@ -370,7 +385,10 @@ def aiServiceSummary(self) -> None: self.printParamSummary("Environment Type", "environment_type") self.printSummary("AI Data Science Platform", "Red Hat OpenShift AI (RHOAI)" if self.getParam("rhoai") == "true" else "Open Data Hub (ODH)") - if "aiservice_certificate_issuer" in self.params: + if self.getParam("aiservice_domain") != "": + print() + self.printParamSummary("Domain Name", "aiservice_domain") + self.printParamSummary("DNS Provider", "dns_provider") self.printParamSummary("Certificate Issuer", "aiservice_certificate_issuer") # Database configuration - matches standalone aiservice-install pattern diff --git a/python/tests/integration/aiservice_install/test_app.py b/python/tests/integration/aiservice_install/test_app.py index 06da1a0225..0af0498548 100644 --- a/python/tests/integration/aiservice_install/test_app.py +++ b/python/tests/integration/aiservice_install/test_app.py @@ -541,6 +541,135 @@ def set_app_prompt_input(**kwargs): app.install(argv=[]) +def test_install_noninteractive_route53(tmpdir): + """Test non-interactive AI Service install with Route53 DNS integration. + + GIVEN a complete set of CLI arguments including AWS Route53 DNS provider and AWS credentials + WHEN the install command runs in non-interactive mode + THEN the install pipeline is launched with aws_access_key_id and aws_secret_access_key set. + """ + tmpdir.join("authorized_entitlement.lic").write("testLicense") + with mock.patch("mas.cli.cli.config"): + dynamic_client = MagicMock(DynamicClient) + resources = MagicMock() + dynamic_client.resources = resources + routes_api = MagicMock() + catalog_api = MagicMock() + crd_api = MagicMock() + namespace_api = MagicMock() + cluster_role_binding_api = MagicMock() + pvc_api = MagicMock() + secret_api = MagicMock() + resource_apis = { + "CatalogSource": catalog_api, + "Route": routes_api, + "CustomResourceDefinition": crd_api, + "Namespace": namespace_api, + "ClusterRoleBinding": cluster_role_binding_api, + "PersistentVolumeClaim": pvc_api, + "Secret": secret_api, + } + resources.get.side_effect = lambda **kwargs: resource_apis.get(kwargs["kind"], None) + route = MagicMock() + route.spec = MagicMock() + route.spec.host = "maximo.ibm.com" + route.spec.displayName = supportedCatalogs["amd64"][1] + routes_api.get.return_value = route + catalog_api.get.side_effect = NotFoundError(ApiException(status="404")) + with ( + mock.patch("mas.cli.cli.DynamicClient") as dynamic_client_class, + mock.patch("mas.cli.cli.getNodes") as get_nodes, + mock.patch("mas.cli.cli.isAirgapInstall") as is_airgap_install, + mock.patch("mas.cli.aiservice.install.app.getCurrentCatalog") as get_current_catalog, + mock.patch("mas.cli.aiservice.install.app.installOpenShiftPipelines"), + mock.patch("mas.cli.aiservice.install.app.updateTektonDefinitions"), + mock.patch("mas.cli.aiservice.install.app.prepareAiServicePipelinesNamespace"), + mock.patch("mas.cli.aiservice.install.app.launchInstallPipeline") as launch_ai_service_install_pipeline, + ): + dynamic_client_class.return_value = dynamic_client + get_nodes.return_value = [{"status": {"nodeInfo": {"architecture": "amd64"}}}] + is_airgap_install.return_value = False + get_current_catalog.return_value = {"catalogId": supportedCatalogs["amd64"][1]} + launch_ai_service_install_pipeline.return_value = "https://pipeline.test.maximo.ibm.com" + with mock.patch("mas.cli.cli.isSNO") as is_sno: + is_sno.return_value = False + app = AiServiceInstallApp() + app.install( + [ + "--mas-catalog-version", + "v9-250828-amd64", + "--ibm-entitlement-key", + "testEntitlementKey", + "--aiservice-instance-id", + "testInstanceId", + "--storage-class-rwo", + "nfs-client", + "--storage-class-rwx", + "nfs-client", + "--storage-pipeline", + "nfs-client", + "--storage-accessmode", + "ReadWriteMany", + "--license-file", + f"{tmpdir}/authorized_entitlement.lic", + "--contact-email", + "maximo@ibm.com", + "--contact-firstname", + "Test", + "--contact-lastname", + "Test", + "--aiservice-channel", + "9.1.x", + "--domain", + "aiservice.example.com", + "--aiservice-certificate-issuer", + "testInstanceId-route53-le-prod", + "--dns-provider", + "route53", + "--aws-access-key-id", + "testAwsAccessKeyId", + "--aws-secret-access-key", + "testAwsSecretAccessKey", + "--route53-hosted-zone-name", + "example.com", + "--route53-hosted-zone-region", + "us-east-1", + "--route53-subdomain", + "aiservice", + "--route53-email", + "route53@example.com", + "--install-minio", + "--minio-root-user", + "test", + "--minio-root-password", + "test", + "--watsonxai-apikey", + "test", + "--watsonxai-url", + "https://us-south.ml.cloud.ibm.com", + "--watsonxai-project-id", + "test", + "--tenant-entitlement-type", + "standard", + "--tenant-entitlement-start-date", + "2025-08-28", + "--tenant-entitlement-end-date", + "2026-08-28", + "--accept-license", + "--no-confirm", + "--skip-pre-check", + ] + ) + + assert app.getParam("aws_access_key_id") == "testAwsAccessKeyId" + assert app.getParam("aws_secret_access_key") == "testAwsSecretAccessKey" + assert app.getParam("dns_provider") == "route53" + assert app.getParam("route53_hosted_zone_name") == "example.com" + assert app.getParam("route53_hosted_zone_region") == "us-east-1" + assert app.getParam("route53_subdomain") == "aiservice" + assert app.getParam("route53_email") == "route53@example.com" + + def test_install_noninteractive_cis_enhanced_security(tmpdir): """Test non-interactive AI Service install with CIS enhanced security options. diff --git a/python/tests/integration/install/test_aiservice_dns.py b/python/tests/integration/install/test_aiservice_dns.py new file mode 100644 index 0000000000..99dd3bac62 --- /dev/null +++ b/python/tests/integration/install/test_aiservice_dns.py @@ -0,0 +1,507 @@ +#!/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 +# +# ***************************************************************************** + +from unittest import mock +from unittest.mock import MagicMock +from kubernetes.client.rest import ApiException +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.exceptions import NotFoundError +from mas.cli.install.catalogs import supportedCatalogs +from mas.cli.install.app import InstallApp + + +def test_install_noninteractive_cis_enhanced_security(tmpdir): + """Test non-interactive integrated install with CIS enhanced security options. + + GIVEN a complete set of CLI arguments for the MAS+AI Service integrated install + including CIS enhanced security flags + WHEN the install command runs in non-interactive mode + THEN all CIS enhanced security parameters are stored correctly in the pipeline params. + """ + tmpdir.join("authorized_entitlement.lic").write("testLicense") + with mock.patch("mas.cli.cli.config"): + dynamic_client = MagicMock(DynamicClient) + resources = MagicMock() + dynamic_client.resources = resources + dynamic_client.client = MagicMock() + + routes_api = MagicMock() + catalog_api = MagicMock() + crd_api = MagicMock() + namespace_api = MagicMock() + cluster_role_binding_api = MagicMock() + pvc_api = MagicMock() + configmap_api = MagicMock() + secret_api = MagicMock() + storage_class_api = MagicMock() + service_api = MagicMock() + cluster_version_api = MagicMock() + ingress_controller_api = MagicMock() + + resource_apis = { + "CatalogSource": catalog_api, + "Route": routes_api, + "CustomResourceDefinition": crd_api, + "Namespace": namespace_api, + "ClusterRoleBinding": cluster_role_binding_api, + "PersistentVolumeClaim": pvc_api, + "ConfigMap": configmap_api, + "Secret": secret_api, + "StorageClass": storage_class_api, + "Service": service_api, + "ClusterVersion": cluster_version_api, + "IngressController": ingress_controller_api, + } + resources.get.side_effect = lambda **kwargs: resource_apis.get(kwargs["kind"], None) + + route = MagicMock() + route.spec = MagicMock() + route.spec.host = "maximo.ibm.com" + route.spec.displayName = supportedCatalogs["amd64"][1] + routes_api.get.return_value = route + catalog_api.get.side_effect = NotFoundError(ApiException(status="404")) + + image_registry_service = MagicMock() + image_registry_service.metadata = MagicMock() + image_registry_service.metadata.name = "image-registry" + service_api.get.return_value = image_registry_service + + cluster_version = MagicMock() + cluster_version.status = MagicMock() + history_record = MagicMock() + history_record.state = "Completed" + history_record.version = "4.18.0" + cluster_version.status.history = [history_record] + cluster_version_api.get.return_value = cluster_version + + ingress_controller = MagicMock() + ingress_controller.metadata = MagicMock() + ingress_controller.metadata.name = "default" + ingress_controller.status = MagicMock() + ingress_controller.status.domain = "apps.cluster.example.com" + ingress_controller.status.conditions = [MagicMock(type="Available", status="True")] + ingress_controller.spec = MagicMock() + ingress_controller.spec.routeAdmission = MagicMock() + ingress_controller.spec.routeAdmission.namespaceOwnership = "Strict" + ingress_controller_api.get.return_value = ingress_controller + ingress_controller_api.patch = MagicMock(return_value=ingress_controller) + + with ( + mock.patch("mas.cli.cli.DynamicClient") as dynamic_client_class, + mock.patch("mas.cli.cli.getNodes") as get_nodes, + mock.patch("mas.cli.cli.isAirgapInstall") as is_airgap_install, + mock.patch("mas.cli.install.app.getCurrentCatalog") as get_current_catalog, + mock.patch("mas.cli.install.app.installOpenShiftPipelines"), + mock.patch("mas.cli.install.app.updateTektonDefinitions"), + mock.patch("mas.cli.install.app.preparePipelinesNamespace"), + mock.patch("mas.cli.install.app.createNamespace"), + mock.patch("mas.cli.install.app.configureIngressForPathBasedRouting"), + mock.patch("mas.cli.install.app.launchInstallPipeline") as launch_install_pipeline, + mock.patch("mas.cli.cli.isSNO") as is_sno, + ): + dynamic_client_class.return_value = dynamic_client + get_nodes.return_value = [{"status": {"nodeInfo": {"architecture": "amd64"}}}] + is_airgap_install.return_value = False + get_current_catalog.return_value = {"catalogId": supportedCatalogs["amd64"][1]} + launch_install_pipeline.return_value = "https://pipeline.test.maximo.ibm.com" + is_sno.return_value = False + + app = InstallApp() + app.install( + [ + "--mas-catalog-version", + "v9-250828-amd64", + "--ibm-entitlement-key", + "testEntitlementKey", + "--mas-instance-id", + "testinst", + "--mas-workspace-id", + "testws", + "--mas-workspace-name", + "Test Workspace", + "--mas-channel", + "9.1.x", + "--storage-class-rwo", + "nfs-client", + "--storage-class-rwx", + "nfs-client", + "--storage-pipeline", + "nfs-client", + "--storage-accessmode", + "ReadWriteMany", + "--license-file", + f"{tmpdir}/authorized_entitlement.lic", + "--contact-email", + "maximo@ibm.com", + "--contact-firstname", + "Test", + "--contact-lastname", + "Test", + "--domain", + "mas.example.com", + "--dns-provider", + "cis", + "--cis-email", + "cis@example.com", + "--cis-apikey", + "testCisApiKey", + "--cis-crn", + "crn:v1:test", + "--cis-subdomain", + "mas", + "--cis-service-name", + "test-cis-service", + "--cis-enhanced-security", + "--update-dns-entries", + "--cis-waf", + "--delete-wildcards", + "--override-edge-certs", + "--accept-license", + "--no-confirm", + "--skip-pre-check", + ] + ) + + assert app.getParam("dns_provider") == "cis" + assert app.getParam("cis_enhanced_security") == "true" + assert app.getParam("cis_service_name") == "test-cis-service" + assert app.getParam("update_dns_entries") == "true" + assert app.getParam("cis_waf") == "true" + assert app.getParam("cis_proxy") == "false" + assert app.getParam("delete_wildcards") == "true" + assert app.getParam("override_edge_certs") == "true" + + +def _make_install_app_mocks(tmpdir, dynamic_client): + """Create the standard set of mock API objects for InstallApp tests.""" + resources = MagicMock() + dynamic_client.resources = resources + dynamic_client.client = MagicMock() + + routes_api = MagicMock() + catalog_api = MagicMock() + crd_api = MagicMock() + namespace_api = MagicMock() + cluster_role_binding_api = MagicMock() + pvc_api = MagicMock() + configmap_api = MagicMock() + secret_api = MagicMock() + storage_class_api = MagicMock() + service_api = MagicMock() + cluster_version_api = MagicMock() + ingress_controller_api = MagicMock() + + resource_apis = { + "CatalogSource": catalog_api, + "Route": routes_api, + "CustomResourceDefinition": crd_api, + "Namespace": namespace_api, + "ClusterRoleBinding": cluster_role_binding_api, + "PersistentVolumeClaim": pvc_api, + "ConfigMap": configmap_api, + "Secret": secret_api, + "StorageClass": storage_class_api, + "Service": service_api, + "ClusterVersion": cluster_version_api, + "IngressController": ingress_controller_api, + } + resources.get.side_effect = lambda **kwargs: resource_apis.get(kwargs["kind"], None) + + route = MagicMock() + route.spec = MagicMock() + route.spec.host = "maximo.ibm.com" + route.spec.displayName = supportedCatalogs["amd64"][1] + routes_api.get.return_value = route + catalog_api.get.side_effect = NotFoundError(ApiException(status="404")) + + image_registry_service = MagicMock() + image_registry_service.metadata = MagicMock() + image_registry_service.metadata.name = "image-registry" + service_api.get.return_value = image_registry_service + + cluster_version = MagicMock() + cluster_version.status = MagicMock() + history_record = MagicMock() + history_record.state = "Completed" + history_record.version = "4.18.0" + cluster_version.status.history = [history_record] + cluster_version_api.get.return_value = cluster_version + + ingress_controller = MagicMock() + ingress_controller.metadata = MagicMock() + ingress_controller.metadata.name = "default" + ingress_controller.status = MagicMock() + ingress_controller.status.domain = "apps.cluster.example.com" + ingress_controller.status.conditions = [MagicMock(type="Available", status="True")] + ingress_controller.spec = MagicMock() + ingress_controller.spec.routeAdmission = MagicMock() + ingress_controller.spec.routeAdmission.namespaceOwnership = "Strict" + ingress_controller_api.get.return_value = ingress_controller + ingress_controller_api.patch = MagicMock(return_value=ingress_controller) + + tmpdir.join("authorized_entitlement.lic").write("testLicense") + + +_BASE_MAS_AISERVICE_ARGS = [ + "--mas-catalog-version", + "v9-250828-amd64", + "--ibm-entitlement-key", + "testEntitlementKey", + "--mas-instance-id", + "inst1", + "--mas-workspace-id", + "testws", + "--mas-workspace-name", + "Test Workspace", + "--mas-channel", + "9.1.x", + "--aiservice-instance-id", + "inst2", + "--aiservice-channel", + "9.1.x", + "--storage-class-rwo", + "nfs-client", + "--storage-class-rwx", + "nfs-client", + "--storage-pipeline", + "nfs-client", + "--storage-accessmode", + "ReadWriteMany", + "--contact-email", + "maximo@ibm.com", + "--contact-firstname", + "Test", + "--contact-lastname", + "Test", + "--install-minio", + "--minio-root-user", + "minioadmin", + "--minio-root-password", + "minioadmin", + "--watsonxai-apikey", + "testKey", + "--watsonxai-url", + "https://us-south.ml.cloud.ibm.com", + "--watsonxai-project-id", + "testProjectId", + "--tenant-entitlement-type", + "standard", + "--tenant-entitlement-start-date", + "2025-08-28", + "--tenant-entitlement-end-date", + "2026-08-28", + "--accept-license", + "--no-confirm", + "--skip-pre-check", +] + + +def _run_install_app(tmpdir, extra_args): + """Run InstallApp.install with standard mocks and return the app instance.""" + with mock.patch("mas.cli.cli.config"): + dynamic_client = MagicMock(DynamicClient) + _make_install_app_mocks(tmpdir, dynamic_client) + with ( + mock.patch("mas.cli.cli.DynamicClient") as dynamic_client_class, + mock.patch("mas.cli.cli.getNodes") as get_nodes, + mock.patch("mas.cli.cli.isAirgapInstall") as is_airgap_install, + mock.patch("mas.cli.install.app.getCurrentCatalog") as get_current_catalog, + mock.patch("mas.cli.install.app.installOpenShiftPipelines"), + mock.patch("mas.cli.install.app.updateTektonDefinitions"), + mock.patch("mas.cli.install.app.preparePipelinesNamespace"), + mock.patch("mas.cli.install.app.createNamespace"), + mock.patch("mas.cli.install.app.configureIngressForPathBasedRouting"), + mock.patch("mas.cli.install.app.launchInstallPipeline") as launch_install_pipeline, + mock.patch("mas.cli.cli.isSNO") as is_sno, + ): + dynamic_client_class.return_value = dynamic_client + get_nodes.return_value = [{"status": {"nodeInfo": {"architecture": "amd64"}}}] + is_airgap_install.return_value = False + get_current_catalog.return_value = {"catalogId": supportedCatalogs["amd64"][1]} + launch_install_pipeline.return_value = "https://pipeline.test.maximo.ibm.com" + is_sno.return_value = False + + app = InstallApp() + license_args = ["--license-file", f"{tmpdir}/authorized_entitlement.lic"] + app.install(_BASE_MAS_AISERVICE_ARGS + license_args + extra_args) + return app + + +def test_install_noninteractive_aiservice_cis_dns(tmpdir): + """Test that AI Service inherits CIS DNS config and derives its certificate issuer from MAS. + + GIVEN --domain and --dns-provider cis are set for MAS, --mas-cluster-issuer is inst1-cis-le-prod, + and AI Service instance ID is inst2 + WHEN the install command runs in non-interactive mode + THEN AI Service domain equals the MAS domain and the AI Service certificate issuer is + derived from mas_cluster_issuer by replacing the MAS instance ID with the AI Service instance ID. + """ + app = _run_install_app( + tmpdir, + [ + "--domain", + "mas.example.com", + "--dns-provider", + "cis", + "--cis-email", + "cis@example.com", + "--cis-apikey", + "testCisApiKey", + "--cis-crn", + "crn:v1:test", + "--cis-subdomain", + "mas", + "--mas-cluster-issuer", + "inst1-cis-le-prod", + ], + ) + + assert app.getParam("dns_provider") == "cis" + assert app.getParam("mas_domain") == "mas.example.com" + assert app.getParam("aiservice_domain") == "mas.example.com" + assert app.getParam("mas_cluster_issuer") == "inst1-cis-le-prod" + assert app.getParam("aiservice_certificate_issuer") == "inst2-cis-le-prod" + + +def test_install_noninteractive_aiservice_route53_dns(tmpdir): + """Test that AI Service inherits Route53 DNS config and derives its certificate issuer from MAS. + + GIVEN --domain and --dns-provider route53 are set for MAS, --mas-cluster-issuer is inst1-route53-le-prod, + and AI Service instance ID is inst2 + WHEN the install command runs in non-interactive mode + THEN AI Service domain equals the MAS domain and the AI Service certificate issuer is + derived from mas_cluster_issuer by replacing the MAS instance ID with the AI Service instance ID. + """ + app = _run_install_app( + tmpdir, + [ + "--domain", + "mas.example.com", + "--dns-provider", + "route53", + "--aws-access-key-id", + "testAwsKeyId", + "--aws-secret-access-key", + "testAwsSecretKey", + "--route53-hosted-zone-name", + "example.com", + "--route53-hosted-zone-region", + "us-east-1", + "--route53-subdomain", + "mas", + "--route53-email", + "route53@example.com", + "--mas-cluster-issuer", + "inst1-route53-le-prod", + ], + ) + + assert app.getParam("dns_provider") == "route53" + assert app.getParam("mas_domain") == "mas.example.com" + assert app.getParam("aiservice_domain") == "mas.example.com" + assert app.getParam("mas_cluster_issuer") == "inst1-route53-le-prod" + assert app.getParam("aiservice_certificate_issuer") == "inst2-route53-le-prod" + + +def test_install_noninteractive_aiservice_cloudflare_dns_skipped(tmpdir): + """Test that AI Service DNS configuration is skipped when MAS uses Cloudflare. + + GIVEN --domain and --dns-provider cloudflare are set for MAS and AI Service instance ID is inst2 + WHEN the install command runs in non-interactive mode + THEN AI Service domain and certificate issuer are cleared because Cloudflare is not supported + for AI Service DNS configuration. + """ + app = _run_install_app( + tmpdir, + [ + "--domain", + "mas.example.com", + "--dns-provider", + "cloudflare", + "--cloudflare-email", + "cf@example.com", + "--cloudflare-apitoken", + "testCfToken", + "--cloudflare-zone", + "example.com", + "--cloudflare-subdomain", + "mas", + "--mas-cluster-issuer", + "inst1-cloudflare-le-prod", + ], + ) + + assert app.getParam("dns_provider") == "cloudflare" + assert app.getParam("mas_domain") == "mas.example.com" + assert app.getParam("aiservice_domain") == "" + assert app.getParam("aiservice_certificate_issuer") == "" + + +def test_install_noninteractive_domain_shared_with_aiservice(tmpdir): + """Test that --domain is used for both MAS and AI Service in non-interactive mode. + + GIVEN --domain mas.example.com and CIS DNS provider are set, and AI Service is being installed + WHEN the install command runs in non-interactive mode + THEN both MAS and AI Service are configured with the same domain. + """ + app = _run_install_app( + tmpdir, + [ + "--domain", + "mas.example.com", + "--dns-provider", + "cis", + "--cis-email", + "cis@example.com", + "--cis-apikey", + "testCisApiKey", + "--cis-crn", + "crn:v1:test", + "--cis-subdomain", + "mas", + "--mas-cluster-issuer", + "inst1-cis-le-prod", + ], + ) + + assert app.getParam("mas_domain") == "mas.example.com" + assert app.getParam("aiservice_domain") == app.getParam("mas_domain") + + +def test_install_noninteractive_aiservice_cloudflare_no_dns_args(tmpdir): + """Test that AI Service DNS is skipped in non-interactive mode when Cloudflare is configured. + + GIVEN --dns-provider cloudflare is set for MAS (no --mas-cluster-issuer provided) + and AI Service instance ID is inst2 + WHEN the install command runs in non-interactive mode + THEN AI Service domain and certificate issuer are both empty. + """ + app = _run_install_app( + tmpdir, + [ + "--domain", + "mas.example.com", + "--dns-provider", + "cloudflare", + "--cloudflare-email", + "cf@example.com", + "--cloudflare-apitoken", + "testCfToken", + "--cloudflare-zone", + "example.com", + "--cloudflare-subdomain", + "mas", + ], + ) + + assert app.getParam("dns_provider") == "cloudflare" + assert app.getParam("aiservice_domain") == "" + assert app.getParam("aiservice_certificate_issuer") == "" From 2c124b780fc2eb92568f3050063ee889d40526ee Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Wed, 9 Sep 2026 11:58:05 +0530 Subject: [PATCH 12/14] [patch] Add tests for interactive mode --- .../install/test_aiservice_interactive_dns.py | 261 ++++++++++++++++++ .../integration/utils/install_test_helper.py | 12 +- .../tests/integration/utils/prompt_tracker.py | 32 ++- 3 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 python/tests/integration/install/test_aiservice_interactive_dns.py diff --git a/python/tests/integration/install/test_aiservice_interactive_dns.py b/python/tests/integration/install/test_aiservice_interactive_dns.py new file mode 100644 index 0000000000..0c1c7e3c5e --- /dev/null +++ b/python/tests/integration/install/test_aiservice_interactive_dns.py @@ -0,0 +1,261 @@ +#!/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 +# +# ***************************************************************************** + +""" +Interactive install tests for AI Service DNS configuration scenarios. + +Three scenarios are covered: + 1. MAS is configured with a non-Cloudflare DNS provider (CIS) — AI Service inherits the + same domain and derives its certificate issuer automatically. + 2. MAS is configured with Cloudflare — AI Service DNS config is skipped (description + printed, no DNS prompts presented). + 3. MAS has no custom domain — AI Service is configured independently with CIS DNS. +""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from mas.cli.install.catalogs import supportedCatalogs +from utils import InstallTestConfig, run_install_test + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def _base_prompts(tmpdir): + """Return the prompt handlers common to all three interactive DNS tests. + + These cover every step that runs before and after the DNS/AI Service DNS + sections, so each test only needs to add the DNS-specific overrides. + """ + return { + ".*Proceed with this cluster.*": lambda msg: "y", + ".*Show advanced installation options.*": lambda msg: "y", + ".*Select catalog source.*": lambda msg: "v9-master-amd64", + ".*Select channel.*": lambda msg: "9.2.x", + ".*Use the auto-detected storage classes.*": lambda msg: "y", + ".*SLS Mode.*": lambda msg: "1", + ".*SLS channel.*": lambda msg: "3.x", + ".*>License file<.*": lambda msg: f"{tmpdir}/authorized_entitlement.lic", + ".*Contact e-mail address.*": lambda msg: "maximo@ibm.com", + ".*Contact first name.*": lambda msg: "Test", + ".*Contact last name.*": lambda msg: "Test", + r".*IBM Data Reporter Operator \(DRO\) Namespace.*": lambda msg: "redhat-marketplace", + ".*IBM entitlement key.*": lambda msg: "testEntitlementKey", + ".*Artifactory username.*": lambda msg: "artiuser", + ".*Artifactory token.*": lambda msg: "artipass", + ".*Mas Admin Mode.*": lambda msg: "1", + ".*Certificate issuer kind.*": lambda msg: "2", + # Fires twice: once for MAS instance ID, once for AI Service instance ID + ".*Instance ID.*": (lambda msg: "inst1", 2), + ".*Workspace ID.*": lambda msg: "testws", + ".*Workspace.*name.*": lambda msg: "Test Workspace", + ".*Operational Mode.*": lambda msg: "1", + ".*Trust default CAs.*": lambda msg: "y", + ".*Cluster ingress certificate secret name.*": lambda msg: "", + ".*Configure manual certificates.*": lambda msg: "n", + ".*Routing Mode.*": lambda msg: "1", + ".*Configure ingress namespace ownership policy to enable path-based routing for MAS.*": lambda msg: "y", + ".*Enable OpenShift Service Mesh support for MAS.*": lambda msg: "n", + ".*Configure SSO properties.*": lambda msg: "n", + ".*Allow special characters for user IDs.*": lambda msg: "n", + ".*Enable feature adoption metrics.*": lambda msg: "n", + ".*Enable deployment progression metrics.*": lambda msg: "n", + ".*Enable usability metrics.*": lambda msg: "n", + ".*Enable Guided Tour.*": lambda msg: "n", + ".*Install IoT.*": lambda msg: "n", + ".*Install Monitor.*": lambda msg: "n", + ".*Install Manage.*": 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: "y", + ".*Custom channel for AI Service.*": lambda msg: "9.2.x", + ".*Customize database settings.*": lambda msg: "n", + ".*Enter AI Service Tenant ID to bind with Manage:.*": lambda msg: "user", + ".*Manage foundation server timezone.*": lambda msg: "GMT", + ".*Base language.*": lambda msg: "EN", + ".*Secondary language.*": lambda msg: "ES", + ".*Install Minio.*": lambda msg: "y", + ".*minio root username.*": lambda msg: "minioadmin", + ".*minio root password.*": lambda msg: "minioadmin", + ".*Entitlement end date.*": lambda msg: "2027-08-28", + ".*Configure Scheduling policies for AI Service tenant.*": lambda msg: "n", + ".*Customize the AI Service tenant operator deployment.*": lambda msg: "n", + ".*Watsonxai api key.*": lambda msg: "testWxApiKey", + ".*Watsonxai machine learning url.*": lambda msg: "https://us-south.ml.cloud.ibm.com", + ".*Watsonxai project id.*": lambda msg: "testProjectId", + ".*Does the Watsonxai AI use a self-signed certificate.*": lambda msg: "n", + ".*Watsonxai Deployment ID.*": lambda msg: "", + ".*Watsonxai Space ID.*": lambda msg: "", + ".*Does the RSL API use a self-signed certificate.*": lambda msg: "n", + ".*Create MongoDb cluster.*": lambda msg: "y", + ".*MongoDb namespace.*": lambda msg: "mongoce", + ".*Create Manage foundation dedicated Db2 instance using the IBM Db2 Universal Operator.*": lambda msg: "y", + ".*Select the Manage foundation dedicated DB2 instance type.*": lambda msg: "1", + # Fires twice: once for Manage Db2, once for AI Service Db2 + ".*Db2 License file.*": (lambda msg: "", 2), + ".*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", + ".*Do you want to use an external database.*": lambda msg: "n", + ".*Do you want to configure AiCfg.*": lambda msg: "n", + ".*Install Grafana.*": lambda msg: "n", + ".*Use additional configurations.*": lambda msg: "n", + ".*Use pod templates.*": lambda msg: "n", + ".*Proceed with these settings.*": lambda msg: "y", + } + + +def _config(prompt_handlers, tmpdir): + """Return an InstallTestConfig for an advanced interactive install.""" + return InstallTestConfig( + prompt_handlers=prompt_handlers, + current_catalog={"catalogId": supportedCatalogs["amd64"][1]}, + architecture="amd64", + is_sno=False, + is_airgap=False, + storage_class_name="nfs-client", + storage_provider="nfs", + storage_provider_name="NFS Client", + ocp_version="4.18.0", + timeout_seconds=30, + argv=['--dev-mode'] + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_install_interactive_aiservice_inherits_cis_dns(tmpdir): + """Test that AI Service inherits MAS CIS DNS config in interactive mode. + + GIVEN MAS is configured with a custom domain and CIS as the DNS provider, + and the user confirms AI Service DNS configuration + WHEN the install command runs interactively in advanced mode + THEN AI Service domain is set to the MAS domain and the AI Service certificate + issuer is derived from the MAS cluster issuer by replacing the MAS instance + ID with the AI Service instance ID. + """ + prompts = _base_prompts(tmpdir) + + # 13. MAS DNS section: custom domain + CIS + prompts.update( + { + # Matches only the MAS DNS prompt — ends with "management?" not "management for AI Service?" + ".*Configure domain.*certificate management\?.*": lambda msg: "y", + ".*Configure custom domain.*": lambda msg: "y", + ".*MAS top-level domain.*": lambda msg: "mas.example.com", + ".*DNS Provider.*": lambda msg: "2", # IBM Cloud Internet Services + ".*CIS e-mail.*": lambda msg: "cis@example.com", + ".*CIS API token.*": lambda msg: "testCisApiKey", + ".*CIS CRN.*": lambda msg: "crn:v1:test", + ".*CIS subdomain.*": lambda msg: "mas", + ".*Certificate issuer.*": lambda msg: "1", # LetsEncrypt Production + ".*Configure enhanced security for CIS.*": lambda msg: "n", + ".*Cluster Ingress Domain Override.*": lambda msg: "", + # 18. AI Service DNS: user opts in; MAS domain/CIS already set → inherited automatically + ".*Configure.*domain.*certificate management for AI Service.*": lambda msg: "y", + } + ) + + app = run_install_test(tmpdir, _config(prompts, tmpdir)) + + assert app.getParam("dns_provider") == "cis" + assert app.getParam("mas_domain") == "mas.example.com" + assert app.getParam("aiservice_domain") == "mas.example.com" + # cert issuer: inst1-cis-le-prod → inst2 not set so replace inst1 with aiservice_instance_id + assert app.getParam("mas_cluster_issuer") == "inst1-cis-le-prod" + assert app.getParam("aiservice_certificate_issuer") == "inst1-cis-le-prod" + + +def test_install_interactive_aiservice_cloudflare_dns_skipped(tmpdir): + """Test that AI Service DNS configuration is skipped when MAS uses Cloudflare. + + GIVEN MAS is configured with Cloudflare as the DNS provider, + and the user confirms AI Service DNS configuration + WHEN the install command runs interactively in advanced mode + THEN the CLI prints a description explaining Cloudflare is unsupported for AI Service + and leaves aiservice_domain and aiservice_certificate_issuer empty. + """ + prompts = _base_prompts(tmpdir) + + # 13. MAS DNS section: custom domain + Cloudflare + prompts.update( + { + # Matches only the MAS DNS prompt — ends with "management?" not "management for AI Service?" + ".*Configure domain.*certificate management\?.*": lambda msg: "y", + ".*Configure custom domain.*": lambda msg: "y", + ".*MAS top-level domain.*": lambda msg: "mas.example.com", + ".*DNS Provider.*": lambda msg: "1", # Cloudflare + ".*Cloudflare e-mail.*": lambda msg: "cf@example.com", + ".*Cloudflare API token.*": lambda msg: "testCfToken", + ".*Cloudflare zone.*": lambda msg: "example.com", + ".*Cloudflare subdomain.*": lambda msg: "mas", + ".*Certificate issuer.*": lambda msg: "1", # LetsEncrypt Production + ".*Cluster Ingress Domain Override.*": lambda msg: "", + # 18. AI Service DNS: user opts in; MAS provider is cloudflare → description printed, no DNS sub-prompts + ".*Configure.*domain.*certificate management for AI Service.*": lambda msg: "y", + } + ) + + app = run_install_test(tmpdir, _config(prompts, tmpdir)) + + assert app.getParam("dns_provider") == "cloudflare" + assert app.getParam("mas_domain") == "mas.example.com" + assert app.getParam("aiservice_domain") == "" + assert app.getParam("aiservice_certificate_issuer") == "" + + +def test_install_interactive_aiservice_cis_dns_no_mas_domain(tmpdir): + """Test that AI Service can configure CIS DNS independently when MAS has no custom domain. + + GIVEN MAS is NOT configured with a custom domain (user answers n to Configure custom domain), + and the user opts in to AI Service DNS configuration + WHEN the install command runs interactively in advanced mode + THEN the CLI prompts for the AI Service domain and DNS provider independently, + and the AI Service certificate issuer is set from the CIS LetsEncrypt issuer. + """ + prompts = _base_prompts(tmpdir) + + # 13. MAS DNS section: no custom domain + prompts.update( + { + # Matches only the MAS DNS prompt — ends with "management?" not "management for AI Service?" + ".*Configure domain.*certificate management\?.*": lambda msg: "y", + ".*Configure custom domain.*": lambda msg: "n", + # 18. AI Service DNS: MAS has no domain → full independent DNS flow + ".*Configure.*domain.*certificate management for AI Service.*": lambda msg: "y", + ".*AI Service domain.*": lambda msg: "aiservice.example.com", + ".*DNS Provider.*": lambda msg: "1", # IBM Cloud Internet Services (first in the AI Service list) + ".*CIS e-mail.*": lambda msg: "cis@example.com", + ".*CIS API token.*": lambda msg: "testCisApiKey", + ".*CIS CRN.*": lambda msg: "crn:v1:test", + ".*CIS subdomain.*": lambda msg: "aiservice", + ".*Certificate issuer.*": lambda msg: "1", # LetsEncrypt Production + ".*Configure enhanced security for CIS.*": lambda msg: "n", + ".*Cluster Ingress Domain Override.*": lambda msg: "", + } + ) + + app = run_install_test(tmpdir, _config(prompts, tmpdir)) + + assert app.getParam("dns_provider") == "cis" + assert app.getParam("mas_domain") == "" + assert app.getParam("aiservice_domain") == "aiservice.example.com" + assert app.getParam("aiservice_certificate_issuer") == f"{app.getParam('aiservice_instance_id')}-cis-le-prod" diff --git a/python/tests/integration/utils/install_test_helper.py b/python/tests/integration/utils/install_test_helper.py index 69273d2d2e..b32bff77ad 100644 --- a/python/tests/integration/utils/install_test_helper.py +++ b/python/tests/integration/utils/install_test_helper.py @@ -368,6 +368,8 @@ def run_install_test(self): assert self.prompt_tracker is not None, "prompt_tracker should be initialized" self.prompt_tracker.verify_all_prompts_matched() + return app + def run_install_test(tmpdir, config: InstallTestConfig, install_type: str = "mas"): """ @@ -378,12 +380,15 @@ def run_install_test(tmpdir, config: InstallTestConfig, install_type: str = "mas config: Test configuration install_type: Type of installation - 'mas' or 'aiservice' (default: 'mas') + Returns: + The app instance after install completes, for asserting params. + Raises: TimeoutError: If test times out AssertionError: If prompt verification fails """ helper = InstallTestHelper(tmpdir, config, install_type) - helper.run_install_test() + return helper.run_install_test() def run_aiservice_install_test(tmpdir, config: InstallTestConfig): @@ -394,8 +399,11 @@ def run_aiservice_install_test(tmpdir, config: InstallTestConfig): tmpdir: pytest tmpdir fixture config: Test configuration + Returns: + The app instance after install completes, for asserting params. + Raises: TimeoutError: If test times out AssertionError: If prompt verification fails """ - run_install_test(tmpdir, config, install_type="aiservice") + return run_install_test(tmpdir, config, install_type="aiservice") diff --git a/python/tests/integration/utils/prompt_tracker.py b/python/tests/integration/utils/prompt_tracker.py index ab1da74b9b..3780fdbbb4 100644 --- a/python/tests/integration/utils/prompt_tracker.py +++ b/python/tests/integration/utils/prompt_tracker.py @@ -16,7 +16,12 @@ class PromptTracker: """ Utility class to track which prompts were matched during tests. - Ensures each expected prompt is matched exactly once. + + By default every registered pattern must be matched exactly once. Wrap the + handler in a tuple ``(handler, expected_count)`` to declare a different + expected hit count, e.g. ``(lambda msg: "y", 2)`` for a prompt that fires + twice. Use ``None`` as the count to skip the upper-bound check entirely. + When only handler is provided the expected match count defaults to 1. """ def __init__(self, prompt_handlers: Dict[str, Callable[[str], str]]): @@ -24,11 +29,19 @@ def __init__(self, prompt_handlers: Dict[str, Callable[[str], str]]): Initialize the prompt tracker. Args: - prompt_handlers: Dictionary mapping regex patterns to handler functions. - Each handler receives the full message and returns the response. + prompt_handlers: Dictionary mapping regex patterns to handler functions + or ``(handler, expected_count)`` tuples. ``expected_count`` may be + an integer (exact match) or ``None`` (at-least-once, no upper bound). """ - self.prompt_handlers = prompt_handlers - self.match_counts = {pattern: 0 for pattern in prompt_handlers.keys()} + # Normalise every entry to (callable, expected_count) + self.prompt_handlers: Dict[str, tuple] = {} + for pattern, value in prompt_handlers.items(): + if isinstance(value, tuple): + handler, expected_count = value + else: + handler, expected_count = value, 1 + self.prompt_handlers[pattern] = (handler, expected_count) + self.match_counts = {pattern: 0 for pattern in self.prompt_handlers.keys()} def handle_prompt(self, *args, **kwargs) -> str: """ @@ -53,7 +66,7 @@ def handle_prompt(self, *args, **kwargs) -> str: raise AssertionError(f"No message found in prompt call. Args: {args}, Kwargs: {kwargs}") # Try to match against all registered patterns - for pattern, handler in self.prompt_handlers.items(): + for pattern, (handler, _) in self.prompt_handlers.items(): if re.match(pattern, message): self.match_counts[pattern] += 1 return handler(message) @@ -74,11 +87,14 @@ def verify_all_prompts_matched(self, allow_unmatched: bool = False): """ errors = [] for pattern, count in self.match_counts.items(): + _, expected_count = self.prompt_handlers[pattern] if count == 0: if not allow_unmatched: errors.append(f"Prompt pattern never matched: {pattern}") - elif count > 1: - errors.append(f"Prompt pattern matched {count} times (expected 1): {pattern}") + elif expected_count is None: + pass # no upper-bound, matches indefinitely. + elif count != expected_count: + errors.append(f"Prompt pattern matched {count} times (expected {expected_count}): {pattern}") if len(errors) > 0: error_message = " | ".join(errors) From 5cdd9e43af40200bb2874c6fb997517c89a590e2 Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Wed, 9 Sep 2026 12:30:46 +0530 Subject: [PATCH 13/14] Fixes for flake8 violations --- .../install/test_aiservice_interactive_dns.py | 16 ++++++---------- python/tests/integration/utils/prompt_tracker.py | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/python/tests/integration/install/test_aiservice_interactive_dns.py b/python/tests/integration/install/test_aiservice_interactive_dns.py index 0c1c7e3c5e..5de1c506e7 100644 --- a/python/tests/integration/install/test_aiservice_interactive_dns.py +++ b/python/tests/integration/install/test_aiservice_interactive_dns.py @@ -20,19 +20,14 @@ 3. MAS has no custom domain — AI Service is configured independently with CIS DNS. """ -import sys -import os - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - from mas.cli.install.catalogs import supportedCatalogs from utils import InstallTestConfig, run_install_test - # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- + def _base_prompts(tmpdir): """Return the prompt handlers common to all three interactive DNS tests. @@ -134,7 +129,7 @@ def _config(prompt_handlers, tmpdir): storage_provider_name="NFS Client", ocp_version="4.18.0", timeout_seconds=30, - argv=['--dev-mode'] + argv=["--dev-mode"], ) @@ -142,6 +137,7 @@ def _config(prompt_handlers, tmpdir): # Tests # --------------------------------------------------------------------------- + def test_install_interactive_aiservice_inherits_cis_dns(tmpdir): """Test that AI Service inherits MAS CIS DNS config in interactive mode. @@ -158,7 +154,7 @@ def test_install_interactive_aiservice_inherits_cis_dns(tmpdir): prompts.update( { # Matches only the MAS DNS prompt — ends with "management?" not "management for AI Service?" - ".*Configure domain.*certificate management\?.*": lambda msg: "y", + r".*Configure domain.*certificate management\?.*": lambda msg: "y", ".*Configure custom domain.*": lambda msg: "y", ".*MAS top-level domain.*": lambda msg: "mas.example.com", ".*DNS Provider.*": lambda msg: "2", # IBM Cloud Internet Services @@ -199,7 +195,7 @@ def test_install_interactive_aiservice_cloudflare_dns_skipped(tmpdir): prompts.update( { # Matches only the MAS DNS prompt — ends with "management?" not "management for AI Service?" - ".*Configure domain.*certificate management\?.*": lambda msg: "y", + r".*Configure domain.*certificate management\?.*": lambda msg: "y", ".*Configure custom domain.*": lambda msg: "y", ".*MAS top-level domain.*": lambda msg: "mas.example.com", ".*DNS Provider.*": lambda msg: "1", # Cloudflare @@ -237,7 +233,7 @@ def test_install_interactive_aiservice_cis_dns_no_mas_domain(tmpdir): prompts.update( { # Matches only the MAS DNS prompt — ends with "management?" not "management for AI Service?" - ".*Configure domain.*certificate management\?.*": lambda msg: "y", + r".*Configure domain.*certificate management\?.*": lambda msg: "y", ".*Configure custom domain.*": lambda msg: "n", # 18. AI Service DNS: MAS has no domain → full independent DNS flow ".*Configure.*domain.*certificate management for AI Service.*": lambda msg: "y", diff --git a/python/tests/integration/utils/prompt_tracker.py b/python/tests/integration/utils/prompt_tracker.py index 3780fdbbb4..6541ee9b4a 100644 --- a/python/tests/integration/utils/prompt_tracker.py +++ b/python/tests/integration/utils/prompt_tracker.py @@ -92,7 +92,7 @@ def verify_all_prompts_matched(self, allow_unmatched: bool = False): if not allow_unmatched: errors.append(f"Prompt pattern never matched: {pattern}") elif expected_count is None: - pass # no upper-bound, matches indefinitely. + pass # no upper-bound, matches indefinitely. elif count != expected_count: errors.append(f"Prompt pattern matched {count} times (expected {expected_count}): {pattern}") From 2d5415a3d584b46591b4bc1c22d6e8e8b7cc3843 Mon Sep 17 00:00:00 2001 From: Jasmin Makwana Date: Fri, 11 Sep 2026 18:24:16 +0530 Subject: [PATCH 14/14] [patch] Fix tests --- python/src/mas/cli/install/app.py | 2 ++ .../tests/integration/install/test_aiservice_interactive_dns.py | 1 + 2 files changed, 3 insertions(+) diff --git a/python/src/mas/cli/install/app.py b/python/src/mas/cli/install/app.py index a07aa165bd..cbec2ba2ed 100644 --- a/python/src/mas/cli/install/app.py +++ b/python/src/mas/cli/install/app.py @@ -1896,6 +1896,8 @@ def configAIServiceDNSAndCerts(self): "DNS for AI Service will therefore need to be configured manually.", ] ) + self.setParam("aiservice_domain", "") + self.setParam("aiservice_certificate_issuer", "") else: self.printDescription( [ diff --git a/python/tests/integration/install/test_aiservice_interactive_dns.py b/python/tests/integration/install/test_aiservice_interactive_dns.py index 5de1c506e7..472eef0d31 100644 --- a/python/tests/integration/install/test_aiservice_interactive_dns.py +++ b/python/tests/integration/install/test_aiservice_interactive_dns.py @@ -61,6 +61,7 @@ def _base_prompts(tmpdir): ".*Cluster ingress certificate secret name.*": lambda msg: "", ".*Configure manual certificates.*": lambda msg: "n", ".*Routing Mode.*": lambda msg: "1", + ".*Do you want to use Let's Encrypt for certificate management.*": lambda msg: "n", ".*Configure ingress namespace ownership policy to enable path-based routing for MAS.*": lambda msg: "y", ".*Enable OpenShift Service Mesh support for MAS.*": lambda msg: "n", ".*Configure SSO properties.*": lambda msg: "n",