Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions image/cli/mascli/functions/provision_fyre
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ function provision_fyre_noninteractive() {
esac
done

INSTALL_SERVICE_MESH=true
INSTALL_KIALI=true

# Check all args have been set
[[ -z "$FYRE_USERNAME" ]] && provision_fyre_help "FYRE_USERNAME is not set"
[[ -z "$FYRE_APIKEY" ]] && provision_fyre_help "FYRE_APIKEY is not set"
Expand Down
3 changes: 3 additions & 0 deletions image/cli/mascli/functions/provision_roks
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ function provision_roks_noninteractive() {
esac
done

INSTALL_SERVICE_MESH=true
INSTALL_KIALI=true

# Check all args have been set
[[ -z "$IBMCLOUD_APIKEY" ]] && provision_roks_help "IBMCLOUD_APIKEY is not set"
[[ -z "$IBMCLOUD_RESOURCEGROUP" ]] && provision_roks_help "IBMCLOUD_RESOURCEGROUP is not set"
Expand Down
99 changes: 57 additions & 42 deletions python/src/mas/cli/install/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -663,9 +663,8 @@ def configMAS(self):
self.configCATrust()
self.configDNSAndCerts()

self.configRoutingMode()

self.configServiceMesh()
self.configRoutingMode()
self.configSSOProperties()
self.configSpecialCharacters()
self.configReportAdoptionMetricsFlag()
Expand Down Expand Up @@ -889,66 +888,71 @@ def configRoutingMode(self):
selectedMode = routingModeOptions[routingModeInt - 1]

if selectedMode == "path":
canConfigure = self._checkIngressControllerPermissions()
if not canConfigure:
self.printDescription(
[
"",
"<Yellow>Your cluster ingress currently does not support path-based routing</Yellow>",
"",
"If you wish to configure MAS with path-based routing, contact your OpenShift",
"administrator to apply the following configuration:",
"",
" <Cyan>spec:",
" routeAdmission:",
" namespaceOwnership: InterNamespaceAllowed</Cyan>",
"",
"MAS will be configured to use subdomain-based routing.",
]
)
self.setParam("mas_routing_mode", "subdomain")
if self.getParam("mas_use_service_mesh") == "true":
self.setParam("mas_routing_mode", "path")
self.setParam("mas_ingress_controller_name", "")
self.printDescription(["<Green>Path-based routing enabled with Service Mesh ingress.</Green>"])
else:
selectedController = self._promptForIngressController()
self.setParam("mas_ingress_controller_name", selectedController)

# Check if selected IngressController is configured for path-based routing
_, isConfigured = self._checkIngressControllerForPathRouting(selectedController)

if isConfigured:
self.setParam("mas_routing_mode", "path")
self.printDescription([f"<Green>IngressController '{selectedController}' is configured for path-based routing.</Green>"])
else:
canConfigure = self._checkIngressControllerPermissions()
if not canConfigure:
self.printDescription(
[
"",
"<Yellow>Your cluster ingress currently does not support path-based routing</Yellow>",
"",
"The following setting needs to be applied to the IngressController:",
"If you wish to configure MAS with path-based routing, contact your OpenShift",
"administrator to apply the following configuration:",
"",
" <Cyan>spec:",
" routeAdmission:",
" namespaceOwnership: InterNamespaceAllowed</Cyan>",
"",
"MAS will be configured to use subdomain-based routing.",
]
)
self.setParam("mas_routing_mode", "subdomain")
self.setParam("mas_ingress_controller_name", "")
else:
selectedController = self._promptForIngressController()
self.setParam("mas_ingress_controller_name", selectedController)

# Check if selected IngressController is configured for path-based routing
_, isConfigured = self._checkIngressControllerForPathRouting(selectedController)

if self.yesOrNo("Configure ingress namespace ownership policy to enable path-based routing for MAS"):
if isConfigured:
self.setParam("mas_routing_mode", "path")
self.setParam("mas_configure_ingress", "true")
self.printDescription(
[f"<Green>IngressController '{selectedController}' will be configured before MAS installation begins.</Green>"]
)
self.printDescription([f"<Green>IngressController '{selectedController}' is configured for path-based routing.</Green>"])
else:
self.printDescription(
[
"",
"<Yellow>Path-based routing requires IngressController configuration.</Yellow>",
"MAS will be configured to use subdomain-based routing.",
"<Yellow>Your cluster ingress currently does not support path-based routing</Yellow>",
"",
"The following setting needs to be applied to the IngressController:",
"",
" <Cyan>spec:",
" routeAdmission:",
" namespaceOwnership: InterNamespaceAllowed</Cyan>",
"",
]
)
self.setParam("mas_routing_mode", "subdomain")
self.setParam("mas_ingress_controller_name", "")

if self.yesOrNo("Configure ingress namespace ownership policy to enable path-based routing for MAS"):
self.setParam("mas_routing_mode", "path")
self.setParam("mas_configure_ingress", "true")
self.printDescription(
[f"<Green>IngressController '{selectedController}' will be configured before MAS installation begins.</Green>"]
)
else:
self.printDescription(
[
"",
"<Yellow>Path-based routing requires IngressController configuration.</Yellow>",
"MAS will be configured to use subdomain-based routing.",
]
)
self.setParam("mas_routing_mode", "subdomain")
self.setParam("mas_ingress_controller_name", "")
else:
self.setParam("mas_routing_mode", "subdomain")

Expand Down Expand Up @@ -2634,6 +2638,12 @@ def nonInteractiveMode(self) -> None:
if self.getParam("mas_issuer_kind") != "" and not isVersionEqualOrAfter("9.2.0", self.getParam("mas_channel")):
self.fatalError(f"--mas-issuer-kind is only supported for MAS 9.2+ (selected channel: {self.getParam('mas_channel')})")

if self.getParam("mas_use_service_mesh") == "true" and not isVersionEqualOrAfter("9.2.0", self.getParam("mas_channel")):
self.fatalError(f"--servicemesh is only supported for MAS 9.2+ (selected channel: {self.getParam('mas_channel')})")

if self.getParam("mas_manual_route_mgmt") == "true" and not isVersionEqualOrAfter("9.2.0", self.getParam("mas_channel")):
self.fatalError(f"--manual-routes is only supported for MAS 9.2+ (selected channel: {self.getParam('mas_channel')})")

# Validate admin mode based on MAS version
if isVersionEqualOrAfter("9.2.0", self.getParam("mas_channel")):
# MAS 9.2+: --admin-mode is REQUIRED
Expand Down Expand Up @@ -2946,7 +2956,8 @@ def install(self, argv):
)

# Validate IngressController configuration for path-based routing (non-interactive mode only)
if not self.isInteractiveMode and self.getParam("mas_routing_mode") == "path":
# When Service Mesh is enabled, Istio Ingress Gateway handles traffic routing rather than OpenShift IngressController
if not self.isInteractiveMode and self.getParam("mas_routing_mode") == "path" and self.getParam("mas_use_service_mesh") != "true":
ingressControllerName = None
if hasattr(self.args, "mas_ingress_controller_name") and self.args.mas_ingress_controller_name:
ingressControllerName = self.args.mas_ingress_controller_name
Expand Down Expand Up @@ -3126,7 +3137,11 @@ def install(self, argv):
)
# Note: This is non-fatal as the plugin can be enabled manually

if self.getParam("mas_routing_mode") == "path" and self.getParam("mas_configure_ingress") == "true":
if (
self.getParam("mas_routing_mode") == "path"
and self.getParam("mas_use_service_mesh") != "true"
and self.getParam("mas_configure_ingress") == "true"
):
with Halo(
text="Configuring cluster for path-based routing",
spinner=self.spinner,
Expand Down
3 changes: 2 additions & 1 deletion python/src/mas/cli/install/argParser.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,14 +166,15 @@ def isValidFile(parser: argparse.ArgumentParser, arg: str) -> str:
required=False,
help="Configure MAS to use Service Mesh networking (default: false)",
choices=["true", "false"],
default="true",
)
masAdvancedArgGroup.add_argument(
"--manual-routes",
dest="mas_manual_route_mgmt",
required=False,
action="store_const",
const="true",
default="false",
default="true",
help="Disable automatic creation of routes.",
)
masAdvancedArgGroup.add_argument(
Expand Down
14 changes: 13 additions & 1 deletion python/src/mas/cli/must_gather/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ def planCollection(self, parsedArgs, outputDir: str):
CollectionPlan: Complete plan with all collection tasks organized into groups
"""
from .collection_plan import CollectionPlan
from .dependencies import kafka, mongodb, grafana, cert_manager, db2, cp4d, rhoai
from .dependencies import kafka, mongodb, grafana, cert_manager, db2, cp4d, rhoai, servicemesh

# Type assertion: dynClient is guaranteed to be non-None by connect()
assert self.dynamicClient is not None, "Kubernetes client must be initialized before planning collection"
Expand Down Expand Up @@ -382,6 +382,18 @@ def planCollection(self, parsedArgs, outputDir: str):
else:
logger.debug("Skipping CP4D collection (not in collectors list)")

# Service Mesh
if "servicemesh" in enabledCollectors:
servicemesh.addServiceMeshToCollectionPlan(
plan=plan,
dynClient=self.dynamicClient,
outputDir=outputDir,
noLogs=parsedArgs.no_logs,
ibmCRDs=self.ibmCRDsList,
)
else:
logger.debug("Skipping ServiceMesh collection (not in collectors list)")

# Red Hat OpenShift AI
if "rhoai" in enabledCollectors:
rhoai.addRHOAIToCollectionPlan(
Expand Down
18 changes: 17 additions & 1 deletion python/src/mas/cli/must_gather/arg_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,23 @@ def _parse_args_wrapper(args=None, namespace=None):


# Define all available collectors
ALL_COLLECTORS = ["ocp", "db2", "kafka", "mongodb", "cp4d", "cert-manager", "grafana", "sls", "mas", "rhoai", "aiservice", "lic", "pipelines", "amlen"]
ALL_COLLECTORS = [
"ocp",
"db2",
"kafka",
"mongodb",
"cp4d",
"cert-manager",
"grafana",
"sls",
"mas",
"rhoai",
"aiservice",
"lic",
"pipelines",
"amlen",
"servicemesh",
]


def validateCollectors(collectorsStr: str) -> str:
Expand Down
108 changes: 108 additions & 0 deletions python/src/mas/cli/must_gather/dependencies/servicemesh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# *****************************************************************************
# 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
#
# *****************************************************************************

"""Service Mesh dependency collector."""

import logging
from typing import Set
from kubernetes.dynamic import DynamicClient
from .utils import discoverNamespacesFromCR

logger = logging.getLogger(__name__)

# ServiceMesh-specific custom resources to collect (apiVersion, kind)
SERVICEMESH_CLUSTER_RESOURCES = [
("sailoperator.io/v1", "Istio"),
("sailoperator.io/v1", "IstioCNI"),
]
SERVICEMESH_NS_RESOURCES = [
("networking.istio.io/v1", "Gateway"),
("networking.istio.io/v1", "VirtualService"),
]


def _discoverServiceMeshNamespaces(dynClient: DynamicClient) -> Set[str]:
"""Discover namespaces containing ServiceMesh resources.

Discovers namespaces by finding all ServiceMesh custom resources in the cluster.

Args:
dynClient (DynamicClient): Kubernetes Dynamic Client for API access

Returns:
set: Set of namespace names where ServiceMesh CRs exist
"""
namespaces = set()
for apiVersion, kind in SERVICEMESH_NS_RESOURCES:
namespaces.update(discoverNamespacesFromCR(dynClient=dynClient, kind=kind, apiVersion=apiVersion))

return namespaces


def addServiceMeshToCollectionPlan(plan, dynClient: DynamicClient, outputDir: str, noLogs: bool, ibmCRDs: list):
"""Add ServiceMesh collection tasks to the collection plan.

Discovers ServiceMesh namespaces and adds collection groups for each namespace
to the provided collection plan.

Args:
plan (CollectionPlan): Collection plan to add tasks to
dynClient (DynamicClient): Kubernetes Dynamic Client for API access
outputDir (str): Base output directory for collected resources
noLogs (bool): If True, skip pod log collection
ibmCRDs (list): List of IBM CRD information for collection
"""
from ..common.task_generation import generateNamespaceCollectionTasks
from ..common.resources import collectResources

# Collect cluster-scoped ServiceMesh resources first
logger.debug("Collecting cluster-scoped ServiceMesh resources")
clusterTasks = []

# Add Istio and IstioCNI (cluster-scoped)
for apiVersion, kind in SERVICEMESH_CLUSTER_RESOURCES:
# clusterTasks.append((
# lambda: collectResources(namespace=None, apiVersion=apiVersion, kind=kind, outputDir=outputDir, allNamespaces=False) # None = cluster-scoped
# )
clusterTasks.append(
(
kind,
collectResources,
None, # namespace=None for cluster-scoped
apiVersion,
kind,
outputDir,
False, # allNamespaces
)
)

if clusterTasks:
plan.addGroup("ServiceMesh (Cluster)", clusterTasks)
logger.debug(f"Added {len(clusterTasks)} cluster-scoped ServiceMesh tasks")

# Now collect namespace-scoped resources
logger.debug("Discovering ServiceMesh namespaces")
serviceMeshNamespaces = _discoverServiceMeshNamespaces(dynClient)

if serviceMeshNamespaces:
logger.info(f"Discovered {len(serviceMeshNamespaces)} ServiceMesh namespace(s): {', '.join(sorted(serviceMeshNamespaces))}")
for ns in sorted(serviceMeshNamespaces):
tasks = generateNamespaceCollectionTasks(
dynClient=dynClient,
namespace=ns,
outputDir=outputDir,
noLogs=noLogs,
customResources=SERVICEMESH_NS_RESOURCES,
ibmCRDs=ibmCRDs,
)
plan.addGroup(f"ServiceMesh ({ns})", tasks)
logger.debug(f"Added {len(tasks)} ServiceMesh collection tasks for namespace {ns}")
else:
logger.info("No ServiceMesh namespaces discovered")
13 changes: 13 additions & 0 deletions python/tests/integration/install/test_routing_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,19 @@ def test_valid_routing_modes(self):
app.setParam("mas_routing_mode", mode)
assert app.getParam("mas_routing_mode") == mode

def test_path_routing_with_service_mesh_bypasses_ingress_check(self):
"""Test that path routing with Service Mesh enabled does not require IngressController validation."""
app = create_mock_app()
app.setParam("mas_channel", "9.2.0")
app.setParam("mas_use_service_mesh", "true")

# In interactive mode, selecting path routing with service mesh enabled sets path mode directly
app.configRoutingMode()
assert app.getParam("mas_routing_mode") == "path"
assert app.getParam("mas_ingress_controller_name") == ""
# Ingress controller checks should not have been called
app.promptForInt.assert_called_once()


if __name__ == "__main__":
pytest.main([__file__, "-v"])
2 changes: 1 addition & 1 deletion python/tests/unit/must_gather/test_arg_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ def test_parser_collectors_default_all_enabled(self):
"""
parser = mustGatherArgParser
args = parser.parse_args([])
expected = "ocp,db2,kafka,mongodb,cp4d,cert-manager,grafana,sls,mas,rhoai,aiservice,lic,pipelines,amlen"
expected = "ocp,db2,kafka,mongodb,cp4d,cert-manager,grafana,sls,mas,rhoai,aiservice,lic,pipelines,amlen,servicemesh"
assert args.collectors == expected

def test_parser_collectors_single_collector(self):
Expand Down
Loading
Loading