Skip to content
Merged
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
4 changes: 1 addition & 3 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,4 @@ endif
.PHONY: run
run:
podman run --rm -it -e IBM_ENTITLEMENT_KEY --pull Always quay.io/ibmmas/cli:master bash

77 changes: 76 additions & 1 deletion python/src/mas/cli/upgrade/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,75 @@ 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. 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

# 2. Try auto-detection
defaultStorageClasses = getDefaultStorageClasses(self.dynamicClient)
if defaultStorageClasses.provider is not None:
print_formatted_text(HTML(f"<MediumSeaGreen>Storage provider auto-detected: {defaultStorageClasses.providerName}</MediumSeaGreen>"))
print_formatted_text(HTML(f"<LightSlateGrey> - Storage class (ReadWriteOnce): {defaultStorageClasses.rwo}</LightSlateGrey>"))
print_formatted_text(HTML(f"<LightSlateGrey> - Storage class (ReadWriteMany): {defaultStorageClasses.rwx}</LightSlateGrey>"))

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."
)
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"<LightSlateGrey> - {storageClass.metadata.name}</LightSlateGrey>"))
print()

validator = StorageClassValidator()
if not self.getParam("storage_class_rwo"):
self.params["storage_class_rwo"] = prompt(
HTML("<Yellow>ReadWriteOnce (RWO) storage class</Yellow> "),
validator=validator,
validate_while_typing=False,
)
if not self.getParam("storage_class_rwx"):
self.params["storage_class_rwx"] = prompt(
HTML("<Yellow>ReadWriteMany (RWX) storage class</Yellow> "),
validator=validator,
validate_while_typing=False,
)

def computeMonitorInstallOrderForUpgrade(self, instanceId):
"""
Determine the installation order for Monitor relative to IoT based on TARGET Monitor version.
Expand Down Expand Up @@ -245,6 +314,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)
Expand Down Expand Up @@ -430,7 +505,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
Expand Down
16 changes: 15 additions & 1 deletion python/src/mas/cli/upgrade/argParser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
254 changes: 254 additions & 0 deletions python/tests/integration/upgrade/test_config_storage_classes.py
Original file line number Diff line number Diff line change
@@ -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"
Loading