From 06ca37dcae4ea70a5b1d5ad64cb8eaeada8ee550 Mon Sep 17 00:00:00 2001 From: terc1997 <64480693+terc1997@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:07:26 -0300 Subject: [PATCH 1/4] [patch] add support for storage class in upgrade --- .secrets.baseline | 2 +- python/src/mas/cli/upgrade/app.py | 67 ++++- python/src/mas/cli/upgrade/argParser.py | 16 +- .../upgrade/test_config_storage_classes.py | 254 ++++++++++++++++++ 4 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 python/tests/integration/upgrade/test_config_storage_classes.py diff --git a/.secrets.baseline b/.secrets.baseline index a637da4a06..4322b5438d 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "build/bin/config/oscap/ssg-rhel9-ds.xml|^.secrets.baseline$|^docs/catalogs/", "lines": null }, - "generated_at": "2026-09-18T10:00:02Z", + "generated_at": "2026-09-25T20:06:58Z", "plugins_used": [ { "name": "AWSKeyDetector" diff --git a/python/src/mas/cli/upgrade/app.py b/python/src/mas/cli/upgrade/app.py index d04e7c1cc8..cf306e0e11 100644 --- a/python/src/mas/cli/upgrade/app.py +++ b/python/src/mas/cli/upgrade/app.py @@ -129,6 +129,65 @@ def configPipelineStorageClass(self, instanceId: str) -> None: self.pipelineStorageClass = suggestedStorageClass self.pipelineStorageAccessMode = suggestedAccessMode + def configStorageClasses(self) -> None: + """ + Determine storage_class_rwo and storage_class_rwx for Manage Foundation Db2/PVC provisioning. + Only called when Manage Foundation is being installed during upgrade (9.0 → 9.1). + + Priority: + 1. CLI args (--storage-class-rwo / --storage-class-rwx) — already set in self.params + 2. Auto-detected from getDefaultStorageClasses() + 3. Interactive prompt (falls back to fatalError in --no-confirm mode) + """ + # 1. Both already provided via CLI args — validate and return + if self.getParam("storage_class_rwo") and self.getParam("storage_class_rwx"): + if getStorageClass(self.dynamicClient, self.getParam("storage_class_rwo")) is None: + self.fatalError(f"Storage class '{self.getParam('storage_class_rwo')}' specified via --storage-class-rwo is not available on this cluster.") + if getStorageClass(self.dynamicClient, self.getParam("storage_class_rwx")) is None: + self.fatalError(f"Storage class '{self.getParam('storage_class_rwx')}' specified via --storage-class-rwx is not available on this cluster.") + logger.debug(f"Using storage classes from CLI args: RWO={self.getParam('storage_class_rwo')} RWX={self.getParam('storage_class_rwx')}") + return + + # 2. Try auto-detection + defaultStorageClasses = getDefaultStorageClasses(self.dynamicClient) + if defaultStorageClasses.provider is not None: + print_formatted_text(HTML(f"Storage provider auto-detected: {defaultStorageClasses.providerName}")) + print_formatted_text(HTML(f" - Storage class (ReadWriteOnce): {defaultStorageClasses.rwo}")) + print_formatted_text(HTML(f" - Storage class (ReadWriteMany): {defaultStorageClasses.rwx}")) + + useDetected = True + if not self.noConfirm: + useDetected = self.yesOrNo("Use the auto-detected storage classes") + + if useDetected: + self.params["storage_class_rwo"] = defaultStorageClasses.rwo + self.params["storage_class_rwx"] = defaultStorageClasses.rwx + logger.debug(f"Using auto-detected storage classes: RWO={defaultStorageClasses.rwo} RWX={defaultStorageClasses.rwx}") + return + + # 3. No provider detected or user declined — fail in non-interactive mode + if self.noConfirm: + self.fatalError( + "No storage classes could be detected for Manage Foundation Db2 instances and --no-confirm is set. " + "Please re-run with --storage-class-rwo and --storage-class-rwx." + ) + + self.printDescription(["Select the ReadWriteOnce and ReadWriteMany storage classes to use from the list below:"]) + for storageClass in getStorageClasses(self.dynamicClient): + print_formatted_text(HTML(f" - {storageClass.metadata.name}")) + print() + + self.params["storage_class_rwo"] = prompt( + HTML("ReadWriteOnce (RWO) storage class "), + validator=StorageClassValidator(), + validate_while_typing=False, + ) + self.params["storage_class_rwx"] = prompt( + HTML("ReadWriteMany (RWX) storage class "), + validator=StorageClassValidator(), + validate_while_typing=False, + ) + def computeMonitorInstallOrderForUpgrade(self, instanceId): """ Determine the installation order for Monitor relative to IoT based on TARGET Monitor version. @@ -245,6 +304,12 @@ def upgrade(self, argv): self.pipelineStorageClass = args.storage_pipeline or None self.pipelineStorageAccessMode = args.storage_accessmode or None + # Pre-populate RWO/RWX storage classes from CLI args (used for Manage Foundation Db2 instances) + if args.storage_class_rwo: + self.params["storage_class_rwo"] = args.storage_class_rwo + if args.storage_class_rwx: + self.params["storage_class_rwx"] = args.storage_class_rwx + # Set image_pull_policy if provided if args.image_pull_policy and args.image_pull_policy != "": self.setParam("image_pull_policy", args.image_pull_policy) @@ -430,7 +495,7 @@ def channelOrder(ch): self.setParam("mas_app_settings_aio_flag", "false") self.setParam("mas_app_channel_manage", self.nextChannel) self.setParam("mas_workspace_id", getWorkspaceId(self.dynamicClient, instanceId)) - + self.configStorageClasses() self.configDb2(silentMode=True) # Compute Monitor install order for upgrade diff --git a/python/src/mas/cli/upgrade/argParser.py b/python/src/mas/cli/upgrade/argParser.py index 491534a404..48117ac8f7 100644 --- a/python/src/mas/cli/upgrade/argParser.py +++ b/python/src/mas/cli/upgrade/argParser.py @@ -59,7 +59,9 @@ help="Configure upgrade for development mode", ) masArgGroup.add_argument("--next-channel", required=False, default="", help="The Target Mas channel to Upgrade on") -storageArgGroup = upgradeArgParser.add_argument_group("Storage", "Storage class configuration for the upgrade pipeline PVC.") +storageArgGroup = upgradeArgParser.add_argument_group( + "Storage", "Storage class configuration for the upgrade pipeline PVC and Manage Foundation Db2 instances." +) storageArgGroup.add_argument( "--storage-pipeline", required=False, @@ -74,6 +76,18 @@ metavar="{ReadWriteMany,ReadWriteOnce}", help="Pipeline storage class access mode (ReadWriteMany or ReadWriteOnce). " "Auto-detected from the existing config-pvc when omitted.", ) +storageArgGroup.add_argument( + "--storage-class-rwo", + required=False, + dest="storage_class_rwo", + help="ReadWriteOnce storage class used for Db2 and Manage Foundation PVCs when installing Manage Foundation during upgrade (9.0 to 9.1). Auto-detected when omitted.", +) +storageArgGroup.add_argument( + "--storage-class-rwx", + required=False, + dest="storage_class_rwx", + help="ReadWriteMany storage class used for Db2 and Manage Foundation PVCs when installing Manage Foundation during upgrade (9.0 to 9.1). Auto-detected when omitted.", +) otherArgGroup.add_argument("--slack-token", required=False, help="Slack bot token for sending pipeline status notifications") otherArgGroup.add_argument("--slack-channel", required=False, help="Slack channel(s) for pipeline notifications (comma-separated for multiple channels)") otherArgGroup.add_argument( diff --git a/python/tests/integration/upgrade/test_config_storage_classes.py b/python/tests/integration/upgrade/test_config_storage_classes.py new file mode 100644 index 0000000000..3c3517366c --- /dev/null +++ b/python/tests/integration/upgrade/test_config_storage_classes.py @@ -0,0 +1,254 @@ +# ***************************************************************************** +# 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 +# +# ***************************************************************************** + +""" +Tests for UpgradeApp.configStorageClasses() + +Covers all resolution paths: + 1. CLI args supplied (--storage-class-rwo / --storage-class-rwx) — both valid on cluster + 2. CLI args supplied but one is invalid → fatalError + 3. Auto-detected provider, --no-confirm accepts automatically + 4. Auto-detected provider, interactive user accepts + 5. Auto-detected provider, interactive user declines → falls through to manual prompt + 6. No provider detected, --no-confirm → fatalError + 7. No provider detected, interactive → manual prompt +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from mas.cli.upgrade.app import UpgradeApp + + +def _make_app(no_confirm=True): + """Create a minimal UpgradeApp with a mock dynamic client.""" + with patch("mas.cli.cli.which", return_value="/usr/bin/kubectl"): + app = UpgradeApp() + app._dynClient = MagicMock() + app.noConfirm = no_confirm + app.params = {} + app.fatalError = Mock(side_effect=SystemExit(1)) + return app + + +def _make_storage_classes(provider="ibmcloud", provider_name="IBM Cloud", rwx="ibmc-file-gold-gid", rwo="ibmc-block-gold"): + sc = MagicMock() + sc.provider = provider + sc.providerName = provider_name + sc.rwx = rwx + sc.rwo = rwo + return sc + + +class TestConfigStorageClassesFromCLIArgs: + """Path 1 — both --storage-class-rwo and --storage-class-rwx already in self.params.""" + + def test_uses_cli_args_without_calling_auto_detect(self): + app = _make_app() + app.params["storage_class_rwo"] = "my-block-class" + app.params["storage_class_rwx"] = "my-file-class" + + with patch("mas.cli.upgrade.app.getStorageClass", return_value=Mock()): + with patch("mas.cli.upgrade.app.getDefaultStorageClasses") as mock_detect: + app.configStorageClasses() + + mock_detect.assert_not_called() + assert app.params["storage_class_rwo"] == "my-block-class" + assert app.params["storage_class_rwx"] == "my-file-class" + + def test_validates_both_classes_exist_on_cluster(self): + app = _make_app() + app.params["storage_class_rwo"] = "my-block-class" + app.params["storage_class_rwx"] = "my-file-class" + + with patch("mas.cli.upgrade.app.getStorageClass", return_value=Mock()) as mock_sc: + app.configStorageClasses() + + assert mock_sc.call_count == 2 + mock_sc.assert_any_call(app.dynamicClient, "my-block-class") + mock_sc.assert_any_call(app.dynamicClient, "my-file-class") + + +class TestConfigStorageClassesFromCLIArgsInvalid: + """Path 2 — CLI args provided but a class does not exist on the cluster.""" + + def test_fatal_error_when_rwo_not_found(self): + app = _make_app() + app.params["storage_class_rwo"] = "bad-rwo" + app.params["storage_class_rwx"] = "my-file-class" + + def _sc_lookup(client, name): + return None if name == "bad-rwo" else Mock() + + with patch("mas.cli.upgrade.app.getStorageClass", side_effect=_sc_lookup): + with pytest.raises(SystemExit): + app.configStorageClasses() + + app.fatalError.assert_called_once() + assert "--storage-class-rwo" in app.fatalError.call_args[0][0] + + def test_fatal_error_when_rwx_not_found(self): + app = _make_app() + app.params["storage_class_rwo"] = "my-block-class" + app.params["storage_class_rwx"] = "bad-rwx" + + def _sc_lookup(client, name): + return None if name == "bad-rwx" else Mock() + + with patch("mas.cli.upgrade.app.getStorageClass", side_effect=_sc_lookup): + with pytest.raises(SystemExit): + app.configStorageClasses() + + app.fatalError.assert_called_once() + assert "--storage-class-rwx" in app.fatalError.call_args[0][0] + + +class TestConfigStorageClassesAutoDetectNoConfirm: + """Path 3 — auto-detected provider, --no-confirm accepts automatically.""" + + def test_sets_rwo_and_rwx_from_auto_detected_provider(self): + app = _make_app(no_confirm=True) + sc = _make_storage_classes() + + with patch("mas.cli.upgrade.app.getDefaultStorageClasses", return_value=sc): + with patch("mas.cli.upgrade.app.print_formatted_text"): + app.configStorageClasses() + + assert app.params["storage_class_rwo"] == "ibmc-block-gold" + assert app.params["storage_class_rwx"] == "ibmc-file-gold-gid" + + def test_does_not_prompt_in_no_confirm_mode(self): + app = _make_app(no_confirm=True) + sc = _make_storage_classes() + app.yesOrNo = Mock() + + with patch("mas.cli.upgrade.app.getDefaultStorageClasses", return_value=sc): + with patch("mas.cli.upgrade.app.print_formatted_text"): + app.configStorageClasses() + + app.yesOrNo.assert_not_called() + + +class TestConfigStorageClassesAutoDetectInteractiveAccept: + """Path 4 — auto-detected provider, interactive user accepts.""" + + def test_sets_params_when_user_accepts_detected_classes(self): + app = _make_app(no_confirm=False) + sc = _make_storage_classes() + app.yesOrNo = Mock(return_value=True) + + with patch("mas.cli.upgrade.app.getDefaultStorageClasses", return_value=sc): + with patch("mas.cli.upgrade.app.print_formatted_text"): + app.configStorageClasses() + + app.yesOrNo.assert_called_once_with("Use the auto-detected storage classes") + assert app.params["storage_class_rwo"] == "ibmc-block-gold" + assert app.params["storage_class_rwx"] == "ibmc-file-gold-gid" + + +class TestConfigStorageClassesAutoDetectInteractiveDecline: + """Path 5 — auto-detected provider, interactive user declines → manual prompt.""" + + def test_falls_through_to_manual_prompt_when_user_declines(self): + app = _make_app(no_confirm=False) + sc = _make_storage_classes() + app.yesOrNo = Mock(return_value=False) + app.printDescription = Mock() + + mock_sc_entry = MagicMock() + mock_sc_entry.metadata.name = "thin" + + with patch("mas.cli.upgrade.app.getDefaultStorageClasses", return_value=sc): + with patch("mas.cli.upgrade.app.getStorageClasses", return_value=[mock_sc_entry]): + with patch("mas.cli.upgrade.app.print_formatted_text"): + with patch("mas.cli.upgrade.app.prompt", side_effect=["custom-rwo", "custom-rwx"]): + app.configStorageClasses() + + assert app.params["storage_class_rwo"] == "custom-rwo" + assert app.params["storage_class_rwx"] == "custom-rwx" + + +class TestConfigStorageClassesNoDetectionNoConfirm: + """Path 6 — no provider detected, --no-confirm → fatalError.""" + + def test_fatal_error_when_no_provider_and_no_confirm(self): + app = _make_app(no_confirm=True) + sc = MagicMock() + sc.provider = None + + with patch("mas.cli.upgrade.app.getDefaultStorageClasses", return_value=sc): + with pytest.raises(SystemExit): + app.configStorageClasses() + + app.fatalError.assert_called_once() + assert "--storage-class-rwo" in app.fatalError.call_args[0][0] + assert "--storage-class-rwx" in app.fatalError.call_args[0][0] + + +class TestConfigStorageClassesNoDetectionInteractive: + """Path 7 — no provider detected, interactive → manual prompt for both classes.""" + + def test_prompts_for_rwo_and_rwx_when_no_provider_detected(self): + app = _make_app(no_confirm=False) + sc = MagicMock() + sc.provider = None + app.printDescription = Mock() + + mock_sc_entry = MagicMock() + mock_sc_entry.metadata.name = "thin" + + with patch("mas.cli.upgrade.app.getDefaultStorageClasses", return_value=sc): + with patch("mas.cli.upgrade.app.getStorageClasses", return_value=[mock_sc_entry]): + with patch("mas.cli.upgrade.app.print_formatted_text"): + with patch("mas.cli.upgrade.app.prompt", side_effect=["my-rwo", "my-rwx"]): + app.configStorageClasses() + + assert app.params["storage_class_rwo"] == "my-rwo" + assert app.params["storage_class_rwx"] == "my-rwx" + + +class TestArgParserStorageClassArgs: + """Verify --storage-class-rwo and --storage-class-rwx are accepted by the arg parser.""" + + def test_storage_class_args_parsed_correctly(self): + from mas.cli.upgrade.argParser import upgradeArgParser + + args = upgradeArgParser.parse_args(["--mas-instance-id", "test", "--storage-class-rwo", "my-block", "--storage-class-rwx", "my-file"]) + assert args.storage_class_rwo == "my-block" + assert args.storage_class_rwx == "my-file" + + def test_storage_class_args_default_to_none(self): + from mas.cli.upgrade.argParser import upgradeArgParser + + args = upgradeArgParser.parse_args(["--mas-instance-id", "test"]) + assert args.storage_class_rwo is None + assert args.storage_class_rwx is None + + def test_storage_class_args_independent_of_pipeline_args(self): + """--storage-class-rwo/rwx are independent from --storage-pipeline.""" + from mas.cli.upgrade.argParser import upgradeArgParser + + args = upgradeArgParser.parse_args( + [ + "--mas-instance-id", + "test", + "--storage-pipeline", + "ibmc-file-gold-gid", + "--storage-accessmode", + "ReadWriteMany", + "--storage-class-rwo", + "ibmc-block-gold", + "--storage-class-rwx", + "ibmc-file-gold-gid", + ] + ) + assert args.storage_pipeline == "ibmc-file-gold-gid" + assert args.storage_accessmode == "ReadWriteMany" + assert args.storage_class_rwo == "ibmc-block-gold" + assert args.storage_class_rwx == "ibmc-file-gold-gid" From e77734a76d8c72196d9412e3af65e0019b05f16d Mon Sep 17 00:00:00 2001 From: terc1997 <64480693+terc1997@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:55:34 -0300 Subject: [PATCH 2/4] [patch] allow partial storage class args in upgrade --- python/src/mas/cli/upgrade/app.py | 34 ++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/python/src/mas/cli/upgrade/app.py b/python/src/mas/cli/upgrade/app.py index cf306e0e11..181760b962 100644 --- a/python/src/mas/cli/upgrade/app.py +++ b/python/src/mas/cli/upgrade/app.py @@ -139,12 +139,18 @@ def configStorageClasses(self) -> None: 2. Auto-detected from getDefaultStorageClasses() 3. Interactive prompt (falls back to fatalError in --no-confirm mode) """ - # 1. Both already provided via CLI args — validate and return - if self.getParam("storage_class_rwo") and self.getParam("storage_class_rwx"): + # 1. Validate any CLI args already provided (independently — partial args are allowed) + if self.getParam("storage_class_rwo"): if getStorageClass(self.dynamicClient, self.getParam("storage_class_rwo")) is None: self.fatalError(f"Storage class '{self.getParam('storage_class_rwo')}' specified via --storage-class-rwo is not available on this cluster.") + return + if self.getParam("storage_class_rwx"): if getStorageClass(self.dynamicClient, self.getParam("storage_class_rwx")) is None: self.fatalError(f"Storage class '{self.getParam('storage_class_rwx')}' specified via --storage-class-rwx is not available on this cluster.") + return + + # Both provided and valid — nothing more to do + if self.getParam("storage_class_rwo") and self.getParam("storage_class_rwx"): logger.debug(f"Using storage classes from CLI args: RWO={self.getParam('storage_class_rwo')} RWX={self.getParam('storage_class_rwx')}") return @@ -171,22 +177,26 @@ def configStorageClasses(self) -> None: "No storage classes could be detected for Manage Foundation Db2 instances and --no-confirm is set. " "Please re-run with --storage-class-rwo and --storage-class-rwx." ) + return self.printDescription(["Select the ReadWriteOnce and ReadWriteMany storage classes to use from the list below:"]) for storageClass in getStorageClasses(self.dynamicClient): print_formatted_text(HTML(f" - {storageClass.metadata.name}")) print() - self.params["storage_class_rwo"] = prompt( - HTML("ReadWriteOnce (RWO) storage class "), - validator=StorageClassValidator(), - validate_while_typing=False, - ) - self.params["storage_class_rwx"] = prompt( - HTML("ReadWriteMany (RWX) storage class "), - validator=StorageClassValidator(), - validate_while_typing=False, - ) + validator = StorageClassValidator(dynamic_client=self.dynamicClient) + if not self.getParam("storage_class_rwo"): + self.params["storage_class_rwo"] = prompt( + HTML("ReadWriteOnce (RWO) storage class "), + validator=validator, + validate_while_typing=False, + ) + if not self.getParam("storage_class_rwx"): + self.params["storage_class_rwx"] = prompt( + HTML("ReadWriteMany (RWX) storage class "), + validator=validator, + validate_while_typing=False, + ) def computeMonitorInstallOrderForUpgrade(self, instanceId): """ From f312bef83f83fee2b41dafab8917ddcc939b9188 Mon Sep 17 00:00:00 2001 From: terc1997 <64480693+terc1997@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:02:44 -0300 Subject: [PATCH 3/4] [patch] remove unused dynamic_client param from StorageClassValidator --- python/src/mas/cli/upgrade/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/src/mas/cli/upgrade/app.py b/python/src/mas/cli/upgrade/app.py index 181760b962..5417bfb17f 100644 --- a/python/src/mas/cli/upgrade/app.py +++ b/python/src/mas/cli/upgrade/app.py @@ -184,7 +184,7 @@ def configStorageClasses(self) -> None: print_formatted_text(HTML(f" - {storageClass.metadata.name}")) print() - validator = StorageClassValidator(dynamic_client=self.dynamicClient) + validator = StorageClassValidator() if not self.getParam("storage_class_rwo"): self.params["storage_class_rwo"] = prompt( HTML("ReadWriteOnce (RWO) storage class "), From 1fe5ebff5714efda3a32c048256cae713951a1cc Mon Sep 17 00:00:00 2001 From: terc1997 <64480693+terc1997@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:01:00 -0300 Subject: [PATCH 4/4] [patch] trigger build --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 02b3357442..f7b7f5d526 100644 --- a/Makefile +++ b/Makefile @@ -82,3 +82,4 @@ endif .PHONY: run run: podman run --rm -it -e IBM_ENTITLEMENT_KEY --pull Always quay.io/ibmmas/cli:master bash +