diff --git a/python/src/mas/cli/aiservice/install/app.py b/python/src/mas/cli/aiservice/install/app.py
index 10ed33fea5..3d119ae902 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")
@@ -267,6 +267,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}")
@@ -416,6 +420,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"))
@@ -681,19 +689,135 @@ def aiServiceSettings(self) -> None:
"If an existing ODH installation is detected, the installer will automatically migrate it to RHOAI."
)
- # Configure Certificate Issuer
- self.configCertIssuer()
+ # Configure DNS
+ self.configDNSAndCerts()
# Configure Network configuration for services
self.configNetworking()
@logMethodCall
- def configCertIssuer(self):
+ def configDNSAndCerts(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("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])
+
+ # 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("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")
+
+ # 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")
+ 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 configNetworking(self):
diff --git a/python/src/mas/cli/aiservice/install/argBuilder.py b/python/src/mas/cli/aiservice/install/argBuilder.py
index 1cf4854681..406d46bc61 100644
--- a/python/src/mas/cli/aiservice/install/argBuilder.py
+++ b/python/src/mas/cli/aiservice/install/argBuilder.py
@@ -23,11 +23,32 @@ 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("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"
+
+ 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"
+
command += f"mas aiservice-install --mas-catalog-version {self.getParam('mas_catalog_version')}"
if self.getParam("mas_catalog_digest") != "":
@@ -38,7 +59,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:
@@ -101,6 +122,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
# -----------------------------------------------------------------------------
@@ -108,24 +136,64 @@ 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 += 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") != "":
+ 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") == "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}"
+ 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":
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}"
- if self.getParam("aiservice_s3_bucket_prefix") != "":
- command += f" --s3-bucket-prefix \"{self.getParam('aiservice_s3_bucket_prefix')}\"{newline}"
+ # Object storage
+ if self.getParam("minio_root_user") != "" and self.getParam("minio_root_password") != "":
+ command += f" --install-minio{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}'
+ 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") != "":
command += f" --s3-tenants-bucket \"{self.getParam('aiservice_s3_tenants_bucket')}\"{newline}"
if self.getParam("aiservice_s3_templates_bucket") != "":
@@ -139,7 +207,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 +229,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") != "":
@@ -193,7 +256,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 3935d8f194..fca378a518 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):
@@ -229,7 +232,161 @@ def isValidFile(parser, arg) -> str:
help="Path to the YAML file that contains the tenant operator customization settings",
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",
+)
+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
+# -----------------------------------------------------------------------------
+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",
+)
+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 - 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",
+ 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..94a997fbe4 100644
--- a/python/src/mas/cli/aiservice/install/params.py
+++ b/python/src/mas/cli/aiservice/install/params.py
@@ -109,4 +109,29 @@
# Slack
"slack_token",
"slack_channel",
+ # DNS Providers
+ "dns_provider",
+ "aiservice_domain",
+ "ocp_ingress",
+ # 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",
+ # AWS Route53
+ "aws_access_key_id",
+ "aws_secret_access_key",
+ "route53_hosted_zone_name",
+ "route53_hosted_zone_region",
+ "route53_subdomain",
+ "route53_email",
+ # OCP Ingress
+ "ocp_ingress_tls_secret_name",
]
diff --git a/python/src/mas/cli/aiservice/install/summarizer.py b/python/src/mas/cli/aiservice/install/summarizer.py
index a4cefa1f64..e482a85abe 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")
@@ -48,9 +48,36 @@ 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")
+ 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":
+ 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
+
+ print()
self.printParamSummary("Configure AI Service to run in IPv6 mode", "enable_ipv6")
self.printH2("AI Service Tenant Configuration")
diff --git a/python/src/mas/cli/install/app.py b/python/src/mas/cli/install/app.py
index 242a49e1ba..cbec2ba2ed 100644
--- a/python/src/mas/cli/install/app.py
+++ b/python/src/mas/cli/install/app.py
@@ -1071,9 +1071,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", "")
@@ -1104,14 +1104,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:",
@@ -1120,40 +1122,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(
[
@@ -1179,7 +1187,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):
@@ -1866,16 +1878,75 @@ 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.",
+ ]
+ )
+ self.setParam("aiservice_domain", "")
+ self.setParam("aiservice_certificate_issuer", "")
+ 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:
@@ -2593,6 +2664,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 52eaeffc97..7ac4e0477f 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",
@@ -303,6 +303,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
# -----------------------------------------------------------------------------
@@ -335,6 +395,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(
@@ -1491,6 +1582,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 51fab7bf3d..a833949428 100644
--- a/python/src/mas/cli/install/params.py
+++ b/python/src/mas/cli/install/params.py
@@ -71,10 +71,14 @@
# 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",
# Let's Encrypt HTTP-01
"mas_le_email",
# CIS
@@ -82,6 +86,13 @@
"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",
@@ -152,6 +163,7 @@
"aws_region",
"aws_access_key_id",
"secret_access_key",
+ "aws_secret_access_key",
"aws_vpc_id",
# Dev Mode
"artifactory_username",
@@ -240,6 +252,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 270f85cf51..bb630221bc 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
@@ -376,7 +391,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 a0ed47d25f..0af0498548 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("#")
tmpdir.join("aiservice-tenant-operator-config.yaml").write("#")
@@ -102,23 +108,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",
@@ -167,6 +174,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("aiservice-tenant-operator-config.yaml").write("#")
@@ -287,24 +300,46 @@ def set_mixin_prompt_input(**kwargs):
return "username"
if re.match(".*minio root password.*", message):
return "password"
- 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(".*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(".*Configure enhanced security for CIS.*", message):
+ 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):
+ 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(".*Certificate issuer name.*", message):
- return "cert-issuer"
+ 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):
@@ -347,6 +382,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("#")
@@ -457,18 +498,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):
@@ -508,3 +539,265 @@ def set_app_prompt_input(**kwargs):
storage_class.metadata.name = "nfs-client"
app = AiServiceInstallApp()
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.
+
+ 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",
+ "--update-dns-entries",
+ "--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("update_dns_entries") == "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/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") == ""
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..472eef0d31
--- /dev/null
+++ b/python/tests/integration/install/test_aiservice_interactive_dns.py
@@ -0,0 +1,258 @@
+#!/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.
+"""
+
+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",
+ ".*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",
+ ".*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?"
+ 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
+ ".*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?"
+ 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
+ ".*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?"
+ 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",
+ ".*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..6541ee9b4a 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)
diff --git a/tekton/src/params/install-aiservice.yml.j2 b/tekton/src/params/install-aiservice.yml.j2
index b1d8eb65be..3b396c86f9 100644
--- a/tekton/src/params/install-aiservice.yml.j2
+++ b/tekton/src/params/install-aiservice.yml.j2
@@ -175,6 +175,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/params/install.yml.j2 b/tekton/src/params/install.yml.j2
index 31b73e7d45..ae17c11b2d 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/mas-install.yml.j2 b/tekton/src/pipelines/mas-install.yml.j2
index 7b90335b55..3510b8857b 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) }}
@@ -441,6 +437,7 @@ spec:
- minio
- aiservice-odh
- aiservice-rhoai
+ - suite-dns
# 14.2 Generate AiCfg with auto-detected values
# -------------------------------------------------------------------------
diff --git a/tekton/src/pipelines/taskdefs/aiservice/aiservice.yml.j2 b/tekton/src/pipelines/taskdefs/aiservice/aiservice.yml.j2
index c4f1d79634..ef0c89ecd7 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)
+
- name: tenant_operator_cfg_file
value: $(params.tenant_operator_cfg_file)
diff --git a/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2 b/tekton/src/pipelines/taskdefs/core/suite-dns.yml.j2
index 8affef13d0..f27e13f5f9 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)
@@ -64,6 +72,13 @@
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)
+
- name: mas_issuer_kind
value: $(params.mas_issuer_kind)
- name: mas_ingress_controller_name
@@ -72,6 +87,7 @@
value: $(params.mas_le_email)
- name: mas_cluster_issuer
value: $(params.mas_cluster_issuer)
+
taskRef:
kind: Task
name: mas-devops-suite-dns
\ No newline at end of file
diff --git a/tekton/src/tasks/aiservice/aiservice.yml.j2 b/tekton/src/tasks/aiservice/aiservice.yml.j2
index 1901701e30..d23900f26a 100644
--- a/tekton/src/tasks/aiservice/aiservice.yml.j2
+++ b/tekton/src/tasks/aiservice/aiservice.yml.j2
@@ -157,6 +157,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
@@ -285,6 +290,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 3ace17e68e..5b04dd49b3 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
@@ -103,6 +115,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
+
# Certificate issuer kind — ClusterIssuer (cluster mode) or Issuer (namespaced/minimal)
- name: mas_issuer_kind
type: string
@@ -121,6 +142,7 @@ spec:
type: string
default: ""
+
stepTemplate:
env:
{{ lookup('template', task_src_dir ~ '/common/cli-env.yml.j2') | indent(6) }}
@@ -166,6 +188,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)
@@ -178,7 +208,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
@@ -192,6 +222,12 @@ spec:
- name: MAS_CLUSTER_ISSUER
value: $(params.mas_cluster_issuer)
+ # AI Service
+ - name: AISERVICE_DOMAIN
+ value: $(params.aiservice_domain)
+ - name: AISERVICE_INSTANCE_ID
+ value: $(params.aiservice_instance_id)
+
steps:
- name: suite-dns
command: