diff --git a/image/cli/install/pre-install-rbac.sh b/image/cli/install/pre-install-rbac.sh index babeba1933c..415260a180e 100644 --- a/image/cli/install/pre-install-rbac.sh +++ b/image/cli/install/pre-install-rbac.sh @@ -44,9 +44,9 @@ else PREINSTALL_BRANCH="${GITHUB_REF_NAME}" echo "Attempting to clone matching branch: ${PREINSTALL_BRANCH}" else - # For tag builds, use main branch - PREINSTALL_BRANCH="main" - echo "Using main branch for tag build" + # For tag builds, use master branch + PREINSTALL_BRANCH="master" + echo "Using master branch for tag build" fi # Clone the repository @@ -54,8 +54,8 @@ else if git clone --depth 1 --branch "${PREINSTALL_BRANCH}" https://github.com/ibm-mas/pre-install.git 2>/dev/null; then echo "Successfully cloned pre-install repository (branch: ${PREINSTALL_BRANCH})" else - echo "Branch ${PREINSTALL_BRANCH} not found, falling back to main branch" - git clone --depth 1 --branch main https://github.com/ibm-mas/pre-install.git + echo "Branch ${PREINSTALL_BRANCH} not found, falling back to master branch" + git clone --depth 1 --branch master https://github.com/ibm-mas/pre-install.git fi PREINSTALL_SOURCE="/tmp/install/pre-install" diff --git a/python/src/mas/cli/aiservice/install/app.py b/python/src/mas/cli/aiservice/install/app.py index 4346904b480..92ccae34dde 100644 --- a/python/src/mas/cli/aiservice/install/app.py +++ b/python/src/mas/cli/aiservice/install/app.py @@ -215,6 +215,7 @@ def interactiveMode(self, simplified: bool, advanced: bool) -> None: self.storageClassProvider = "custom" self.slsLicenseFileLocal = None + self.db2LicenseFileLocal = None # Catalog self.configCatalog() @@ -243,6 +244,11 @@ def interactiveMode(self, simplified: bool, advanced: bool) -> None: self.configMongoDb() self.setDB2DefaultChannel() self.setDB2DefaultSettings() + self.printDescription([ + "Db2 Universal Operator for v12 onwards requires to add a License activation key", + "If you don't have a license press enter to continue." + ]) + self.db2LicenseFileLocal = self.promptForFile("Db2 License file", envVar="DB2_LICENSE_FILE", default="", mustExist=False) # Permission mode prompt (especially in dev mode) if isVersionEqualOrAfter('9.2.0', self.getParam("aiservice_channel")): self.configPermissionMode() @@ -258,6 +264,7 @@ def nonInteractiveMode(self) -> None: self.storageClassProvider = "custom" self.slsLicenseFileLocal = None + self.db2LicenseFileLocal = None self.aiserviceTenantSchedulingConfigFileLocal = None @@ -374,6 +381,9 @@ def nonInteractiveMode(self) -> None: if value is not None and value != "": self.slsLicenseFileLocal = value self.setParam("sls_action", "install") + elif key == "db2_license_file": + if value is not None and value != "": + self.db2LicenseFileLocal = value elif key == "dedicated_sls": if value: self.setParam("sls_namespace", f"mas-{self.args.aiservice_instance_id}-sls") @@ -533,8 +543,9 @@ def install(self, argv): self.evaluatePreInstallRBACAccess() - # Set up the sls license file + # Set up the sls and db2 license file self.slsLicenseFile() + self.db2LicenseFile() self.aiserviceConfig() @@ -582,6 +593,7 @@ def install(self, argv): dynClient=self.dynamicClient, namespace=pipelinesNamespace, slsLicenseFile=self.slsLicenseFileSecret, + db2LicenseFile=self.db2LicenseFileSecret, additionalConfigs=self.additionalConfigsSecret, podTemplates=self.podTemplatesSecret, certs=self.certsSecret, diff --git a/python/src/mas/cli/aiservice/install/argBuilder.py b/python/src/mas/cli/aiservice/install/argBuilder.py index 86f10ec0009..348f2fbdf6b 100644 --- a/python/src/mas/cli/aiservice/install/argBuilder.py +++ b/python/src/mas/cli/aiservice/install/argBuilder.py @@ -190,6 +190,8 @@ def buildCommand(self) -> str: if self.getParam('db2_channel') != "": command += f" --db2-channel \"{self.getParam('db2_channel')}\"{newline}" + if self.db2LicenseFileLocal: + command += f" --db2-license-file \"{self.db2LicenseFileLocal}\"" command += " --accept-license --no-confirm" return command diff --git a/python/src/mas/cli/aiservice/install/argParser.py b/python/src/mas/cli/aiservice/install/argParser.py index 3373147f12d..38e054aa4e4 100644 --- a/python/src/mas/cli/aiservice/install/argParser.py +++ b/python/src/mas/cli/aiservice/install/argParser.py @@ -481,7 +481,11 @@ def isValidFile(parser, arg) -> str: required=False, help="Subscription channel for Db2u" ) - +db2ArgGroup.add_argument( + "--db2-license-file", + required=False, + help="Db2 License File for Db2" +) # Development Mode # ----------------------------------------------------------------------------- diff --git a/python/src/mas/cli/aiservice/install/params.py b/python/src/mas/cli/aiservice/install/params.py index 93846bae812..30c3285f8ca 100644 --- a/python/src/mas/cli/aiservice/install/params.py +++ b/python/src/mas/cli/aiservice/install/params.py @@ -40,6 +40,7 @@ "db2_timezone", "db2_namespace", "db2_channel", + "db2_license_file", "db2_affinity_key", "db2_affinity_value", "db2_tolerate_key", diff --git a/python/src/mas/cli/install/app.py b/python/src/mas/cli/install/app.py index f0199c5cd64..94ceccb4a82 100644 --- a/python/src/mas/cli/install/app.py +++ b/python/src/mas/cli/install/app.py @@ -1749,6 +1749,7 @@ def nonInteractiveMode(self) -> None: self.db2SetTolerations = False self.installAIService = False self.slsLicenseFileLocal = None + self.db2LicenseFileLocal = None self.aiserviceTenantSchedulingConfigFileLocal = None self.approvals: Dict[str, Dict[str, Any]] = { @@ -1915,6 +1916,9 @@ def nonInteractiveMode(self) -> None: if value is not None and value != "": self.slsLicenseFileLocal = value self.setParam("sls_action", "install") + elif key == "db2_license_file": + if value is not None and value != "": + self.db2LicenseFileLocal = value elif key == "dedicated_sls": if value: self.setParam("sls_namespace", f"mas-{self.args.mas_instance_id}-sls") @@ -2210,10 +2214,11 @@ def install(self, argv): if self.deployCP4D: self.configCP4D() - # Set up the secrets for additional configs, podtemplates, sls license file and manual certificates + # Set up the secrets for additional configs, podtemplates, sls license file, db2 license file and manual certificates self.additionalConfigs() self.podTemplates() self.slsLicenseFile() + self.db2LicenseFile() self.manualCertificates() self.aiserviceConfig() @@ -2392,6 +2397,7 @@ def install(self, argv): dynClient=self.dynamicClient, namespace=pipelinesNamespace, slsLicenseFile=self.slsLicenseFileSecret, + db2LicenseFile=self.db2LicenseFileSecret, additionalConfigs=self.additionalConfigsSecret, podTemplates=self.podTemplatesSecret, certs=self.certsSecret, diff --git a/python/src/mas/cli/install/argBuilder.py b/python/src/mas/cli/install/argBuilder.py index 7cd61b733d5..3982a62b29c 100644 --- a/python/src/mas/cli/install/argBuilder.py +++ b/python/src/mas/cli/install/argBuilder.py @@ -462,6 +462,8 @@ def buildCommand(self) -> str: command += f" --db2-type \"{self.getParam('db2_type')}\"{newline}" if self.getParam('db2_timezone') != "": command += f" --db2-timezone \"{self.getParam('db2_timezone')}\"{newline}" + if self.db2LicenseFileLocal != "": + command += f" --db2-license-file \"{self.db2LicenseFileLocal}\"{newline}" if self.getParam('db2_affinity_key') != "": command += f" --db2-affinity-key \"{self.getParam('db2_affinity_key')}\"{newline}" diff --git a/python/src/mas/cli/install/argParser.py b/python/src/mas/cli/install/argParser.py index 79f2b472fb9..b2a7b0d5f5d 100644 --- a/python/src/mas/cli/install/argParser.py +++ b/python/src/mas/cli/install/argParser.py @@ -1225,6 +1225,11 @@ def isValidFile(parser: argparse.ArgumentParser, arg: str) -> str: required=False, help="Timezone for Db2 instance" ) +db2ArgGroup.add_argument( + "--db2-license-file", + required=False, + help="Db2 License File for Db2" +) db2ArgGroup.add_argument( "--db2-affinity-key", required=False, diff --git a/python/src/mas/cli/install/settings/additionalConfigs.py b/python/src/mas/cli/install/settings/additionalConfigs.py index 949861ebe7d..e200a9f78bc 100644 --- a/python/src/mas/cli/install/settings/additionalConfigs.py +++ b/python/src/mas/cli/install/settings/additionalConfigs.py @@ -32,12 +32,14 @@ class AdditionalConfigsMixin(): noConfirm: bool templatesDir: str slsLicenseFileLocal: str | None + db2LicenseFileLocal: str | None manualCertsDir: str | None showAdvancedOptions: bool aiserviceTenantSchedulingConfigFileLocal: str | None additionalConfigsSecret: Dict[str, Any] | None podTemplatesSecret: Dict[str, Any] | None slsLicenseFileSecret: Dict[str, Any] | None + db2LicenseFileSecret: Dict[str, Any] | None certsSecret: Dict[str, Any] | None aiserviceConfigSecret: Dict[str, Any] | None @@ -289,6 +291,21 @@ def aiserviceConfig(self) -> None: self.setParam("tenant_scheduling_config_file", f"/workspace/aiservice/{path.basename(self.aiserviceTenantSchedulingConfigFileLocal)}") self.aiserviceConfigSecret = self.addFilesToSecret(aiserviceConfigSecret, self.aiserviceTenantSchedulingConfigFileLocal, 'yaml') + def db2LicenseFile(self) -> None: + if self.db2LicenseFileLocal: + db2LicenseFileSecret = { + "apiVersion": "v1", + "kind": "Secret", + "type": "Opaque", + "metadata": { + "name": "pipeline-db2-license" + } + } + self.setParam("db2_license_file", f"/workspace/db2/{path.basename(self.db2LicenseFileLocal)}") + self.db2LicenseFileSecret = self.addFilesToSecret(db2LicenseFileSecret, self.db2LicenseFileLocal, '') + else: + self.db2LicenseFileSecret = None + def addFilesToSecret(self, secretDict: dict, configPath: str, extension: str, keyPrefix: str = '') -> dict: """ Add file (or files) to pipeline-additional-configs diff --git a/python/src/mas/cli/install/settings/db2Settings.py b/python/src/mas/cli/install/settings/db2Settings.py index 3a251125b5c..10630543688 100644 --- a/python/src/mas/cli/install/settings/db2Settings.py +++ b/python/src/mas/cli/install/settings/db2Settings.py @@ -78,6 +78,15 @@ def promptForListSelect( ) -> str: ... + def promptForFile( + self, + message: str, + mustExist: bool = True, + default: str = "", + envVar: str = "" + ) -> str: + ... + # Methods from ConfigGeneratorMixin or InstallSettingsMixin def selectLocalConfigDir(self) -> None: ... @@ -241,6 +250,11 @@ def configDb2(self, silentMode=False) -> None: # Do we need to configure Db2u? if self.getParam("db2_action_system") == "install" or self.getParam("db2_action_manage") == "install" or self.getParam("db2_action_facilities") == "install": + self.printDescription([ + "Db2 Universal Operator for v12 onwards requires to add a License activation key", + "If you don't have a license press enter to continue." + ]) + self.db2LicenseFileLocal = self.promptForFile("Db2 License file", envVar="DB2_LICENSE_FILE", default="", mustExist=False) if self.showAdvancedOptions: self.printH2("Installation Namespace") self.promptForString("Install namespace", "db2_namespace", default="db2u") diff --git a/python/src/mas/cli/update/app.py b/python/src/mas/cli/update/app.py index a887ef53c8b..70cbf3fa9d8 100644 --- a/python/src/mas/cli/update/app.py +++ b/python/src/mas/cli/update/app.py @@ -11,6 +11,7 @@ import logging import logging.handlers +import re from typing import Callable from halo import Halo from prompt_toolkit import print_formatted_text, HTML @@ -23,13 +24,14 @@ from mas.devops.ocp import createNamespace, getConsoleURL, getClusterVersion, isClusterVersionInRange from mas.devops.mas import listMasInstances, getCurrentCatalog from mas.devops.aiservice import listAiServiceInstances -from mas.devops.tekton import preparePipelinesNamespace, installOpenShiftPipelines, updateTektonDefinitions, launchUpdatePipeline, prepareUpdateSlackSecrets +from mas.devops.tekton import preparePipelinesNamespace, installOpenShiftPipelines, updateTektonDefinitions, launchUpdatePipeline, prepareUpdateSecrets +from ..install.settings import AdditionalConfigsMixin logger = logging.getLogger(__name__) -class UpdateApp(BaseApp): +class UpdateApp(BaseApp, AdditionalConfigsMixin): def update(self, argv): """ @@ -38,6 +40,7 @@ def update(self, argv): self.args = updateArgParser.parse_args(args=argv) self.noConfirm = self.args.no_confirm self.devMode = self.args.dev_mode + self.db2LicenseFileLocal = None if self.args.mas_catalog_version: # Non-interactive mode @@ -45,6 +48,7 @@ def update(self, argv): requiredParams = ["mas_catalog_version"] optionalParams = [ "db2_namespace", + "db2_v12_upgrade", "mongodb_namespace", "mongodb_v5_upgrade", "mongodb_v6_upgrade", @@ -82,6 +86,11 @@ def update(self, argv): elif key in ["no_confirm", "help"]: pass + # Db2 License file has special handling + elif key == "db2_license_file": + if value is not None and value != "": + self.db2LicenseFileLocal = value + # Fail if there's any arguments we don't know how to handle else: print(f"Unknown option: {key} {value}") @@ -139,8 +148,8 @@ def update(self, argv): self.detectGrafana4() self.detectODH() self.detectMongoDb() - self.detectDb2uOrKafka("db2") - self.detectDb2uOrKafka("kafka") + self.detectDb2u() + self.detectKafka() self.detectCP4D() print() @@ -188,6 +197,9 @@ def update(self, argv): continueWithUpdate = self.yesOrNo("Proceed with these settings") # Prepare the namespace and launch the installation pipeline if self.noConfirm or continueWithUpdate: + # Db2 workspace in update pipeline + self.db2LicenseFile() + self.createTektonFileWithDigest() self.printH1("Launch Update") @@ -203,17 +215,12 @@ def update(self, argv): with Halo(text=f'Preparing namespace ({pipelinesNamespace})', spinner=self.spinner) as h: createNamespace(self.dynamicClient, pipelinesNamespace) preparePipelinesNamespace(dynClient=self.dynamicClient) - h.stop_and_persist(symbol=self.successIcon, text=f"Namespace is ready ({pipelinesNamespace})") - - # Create slack secret if slack token and channel are provided - if self.getParam("slack_token") and self.getParam("slack_channel"): - with Halo(text='Creating Slack notification secret', spinner=self.spinner) as h: - prepareUpdateSlackSecrets( - dynClient=self.dynamicClient, - slack_token=self.getParam("slack_token"), - slack_channel=self.getParam("slack_channel") - ) - h.stop_and_persist(symbol=self.successIcon, text="Slack notification secret created") + prepareUpdateSecrets( + dynClient=self.dynamicClient, + slack_token=self.getParam("slack_token"), + slack_channel=self.getParam("slack_channel"), + db2LicenseFile=self.db2LicenseFileSecret, + ) with Halo(text=f'Installing latest Tekton definitions (v{self.version})', spinner=self.spinner) as h: updateTektonDefinitions(pipelinesNamespace, self.tektonDefsPath) @@ -627,19 +634,15 @@ def detectCpdService(self, kind: str, api: str, name: str, param: str) -> None: logger.debug(f"{name} is not included in CP4D update: {e}") self.setParam(param, "false") - def detectDb2uOrKafka(self, mode: str) -> bool: - if mode == "db2": - haloStartingMessage = "Checking for Db2uCluster instances to update" - apiVersion = "db2u.databases.ibm.com/v1" - kinds = ["Db2uCluster", "Db2uInstance"] - paramName = "db2_namespace" - elif mode == "kafka": - haloStartingMessage = "Checking for Kafka instances to update" - apiVersion = "kafka.strimzi.io/v1beta2" - kinds = ["Kafka"] - paramName = "kafka_namespace" - else: - self.fatalError("Unexpected error") + def detectDb2u(self) -> None: + """Detect Db2uCluster and Db2uInstance instances to update.""" + haloStartingMessage = "Checking for Db2uCluster instances to update" + apiVersion = "db2u.databases.ibm.com/v1" + kinds = ["Db2uCluster", "Db2uInstance"] + paramName = "db2_namespace" + mode = "db2" + # Get target Db2u version from catalog + targetDb2uVersion = self.chosenCatalog["db2_channel_default"] with Halo(text=haloStartingMessage, spinner=self.spinner) as h: try: @@ -678,6 +681,101 @@ def detectDb2uOrKafka(self, mode: str) -> bool: for index, ns in enumerate(sorted(namespaces), start=1): self.printDescription([f"{index}. {ns}"]) self.promptForListSelect("Select namespace", sorted(namespaces), paramName) + self.setParam("db2_channel", self.chosenCatalog["db2_channel_default"]) + + # Version comparison logic - check if Db2u needs major version upgrade + if len(instances) > 0: + + if not targetDb2uVersion: + logger.warning("Unable to determine target Db2u version from catalog") + else: + # Extract target major version (first two digits) + # Handle version formats like "11.5.8.0", "12.0.0.0", "v11.5", "v12.0", "s11.5.9.0-cn6" + try: + match = re.match(r'^[vs]?(\d{2})[\d.]*', targetDb2uVersion) + if match: + targetMajorVersion = int(match.group(1)) + else: + raise ValueError(f"Version format does not match expected pattern: {targetDb2uVersion}") + except (ValueError, AttributeError) as e: + h.stop_and_persist(symbol=self.failureIcon, text=f"Invalid Db2 channel version format: {targetDb2uVersion}") + logger.error(f"Unable to parse target Db2u version: {targetDb2uVersion} - {str(e)}") + targetMajorVersion = None + + if targetMajorVersion is not None: + # Check all instances to see if any need upgrade + needsUpgrade = False + instanceVersions = [] + + for instance in instances: + if not isinstance(instance, dict): + continue + + instanceName = instance["metadata"]["name"] + currentVersion = instance["spec"]["version"] + + if not currentVersion: + logger.warning(f"No version found for Db2u instance: {instanceName}") + continue + + logger.debug(f"Current Db2u version {instanceName}: {currentVersion}") + + # Extract major version from current instance + currentVersionStr = currentVersion.lstrip('s') + try: + currentMajorVersion = int(currentVersionStr.split('.')[0]) + instanceVersions.append((instanceName, currentMajorVersion, currentVersion)) + + # Check if this instance needs upgrade + if currentMajorVersion < targetMajorVersion: + needsUpgrade = True + logger.debug(f"Instance {instanceName} needs upgrade: {currentMajorVersion} -> {targetMajorVersion}") + except (ValueError, IndexError): + logger.warning(f"Unable to parse version for instance {instanceName}: {currentVersion}") + continue + + if not instanceVersions: + logger.warning("Unable to determine current Db2u version from any instance") + else: + logger.debug(f"Target Db2u version from catalog: {targetDb2uVersion} (major: {targetMajorVersion})") + + # Check if upgrade to version or higher is needed + if needsUpgrade: + # Get the minimum version across all instances for user messaging + minVersion = min(instanceVersions, key=lambda x: x[1]) + minMajorVersion = minVersion[1] + + if self.noConfirm and self.getParam(f"db2_v{targetMajorVersion}_upgrade") != "true": + h.stop_and_persist(symbol=self.failureIcon, text=f"Db2 {minMajorVersion} needs to be updated to {targetMajorVersion}") + self.fatalError(f"By choosing {self.getParam('mas_catalog_version')} you must confirm Db2 update to version {targetMajorVersion} using '--db2-v{targetMajorVersion}-upgrade' when using '--no-confirm'") + elif self.getParam(f"db2_v{targetMajorVersion}_upgrade") != "true": + h.stop_and_persist(symbol=self.successIcon, text=f"Db2 {minMajorVersion} needs to be updated to {targetMajorVersion}") + if not self.yesOrNo(f"Confirm update from Db2 {minMajorVersion} to {targetMajorVersion}", f"db2_v{targetMajorVersion}_upgrade"): + exit(1) + print() + else: + h.stop_and_persist(symbol=self.successIcon, text=f"Db2 will be updated from {minMajorVersion} to {targetMajorVersion}") + + # Db2 v11 to v12 upgrades require a customer-provided Db2 v12 license file. + # Enforce this here so the update is blocked before the Tekton pipeline is launched. + if minMajorVersion == 11 and targetMajorVersion == 12: + # In non-interactive mode we must fail fast because there is no safe way to recover. + if self.noConfirm and self.db2LicenseFileLocal is None: + self.fatalError("The Db2 v11 to v12 upgrade cannot proceed without a valid '--db2-license-file' argument when using '--no-confirm'") + elif self.db2LicenseFileLocal is None: + # In interactive mode, prompt for a valid license file before allowing the upgrade to continue. + self.printDescription([ + "Db2 v11 to v12 upgrades require a valid Db2 v12 activation license file.", + "If you cannot provide a valid file, the update must be aborted." + ]) + self.db2LicenseFileLocal = self.promptForFile("Path to a valid Db2 v12 license file", envVar="DB2_LICENSE_FILE", default="", mustExist=False) + + # Set db2_channel when upgrade is confirmed (either via flag or user prompt) + self.setParam("db2_channel", targetDb2uVersion) + logger.debug(f"Db2u major version upgrade required: {minMajorVersion} -> {targetMajorVersion}") + else: + self.setParam(f"db2_v{targetMajorVersion}_upgrade", "false") + logger.debug("No Db2u major version upgrade required") else: logger.debug(f"Found no instances of {kindString} to update") h.stop_and_persist(symbol=self.successIcon, text=f"Found no {kindString} ({apiVersion}) instances to update") @@ -686,8 +784,57 @@ def detectDb2uOrKafka(self, mode: str) -> bool: logger.debug(f"{'[' + kindString + ']'}.{apiVersion} is not available in the cluster") h.stop_and_persist(symbol=self.successIcon, text=f"{kindString}.{apiVersion} is not available in the cluster") + def detectKafka(self) -> None: + """Detect Kafka instances to update and determine the provider.""" + haloStartingMessage = "Checking for Kafka instances to update" + apiVersion = "kafka.strimzi.io/v1beta2" + kind = "Kafka" + paramName = "kafka_namespace" + mode = "kafka" + + with Halo(text=haloStartingMessage, spinner=self.spinner) as h: + try: + k8sAPI = self.dynamicClient.resources.get(api_version=apiVersion, kind=kind) + instances = k8sAPI.get().to_dict()["items"] + logger.debug(f"Found {len(instances)} {kind} instances on the cluster") + + if len(instances) > 0: + # If the user provided the namespace using --kafka-namespace then we don't have any work to do here + if self.getParam(paramName) == "": + namespaces = set() + for instance in instances: + namespaces.add(instance["metadata"]["namespace"]) + + if len(namespaces) == 1: + # If kafka is only in one namespace, we will update that + h.stop_and_persist(symbol=self.successIcon, text=f"{len(instances)} {kind}s ({apiVersion}) in namespace '{list(namespaces)[0]}' will be updated") + logger.debug(f"There is only one namespace containing {kind}s so we will target that one: {namespaces}") + self.setParam(paramName, list(namespaces)[0]) + elif self.noConfirm: + # If kafka is in multiple namespaces and user has disabled prompts then we must error + namespaceList = ", ".join(list(namespaces)) + h.stop_and_persist(symbol=self.failureIcon, text=f"{len(instances)} {kind}s ({apiVersion}) were found in multiple namespaces") + logger.warning(f"There are multiple namespaces containing {kind}s and user has enable --no-confirm without setting --{mode}-namespace: {namespaceList}") + self.fatalError(f"{kind}s are installed in multiple namespaces. You must instruct which one to update using the '--{mode}-namespace' argument") + else: + # Otherwise, provide user the list of namespaces we found and ask them to pick on + h.stop_and_persist(symbol=self.successIcon, text=f"{len(instances)} {kind}s ({apiVersion}) found in multiple namespaces") + logger.debug(f"There are multiple namespaces containing {kind}s, user must choose: {namespaces}") + self.printDescription([ + f"{kind}s were found in multiple namespaces, select the namespace to target from the list below:" + ]) + for index, ns in enumerate(sorted(namespaces), start=1): + self.printDescription([f"{index}. {ns}"]) + self.promptForListSelect("Select namespace", sorted(namespaces), paramName) + else: + logger.debug(f"Found no instances of {kind}s to update") + h.stop_and_persist(symbol=self.successIcon, text=f"Found no {kind}s ({apiVersion}) instances to update") + except (ResourceNotFoundError, NotFoundError): + logger.debug(f"{kind}.{apiVersion} is not available in the cluster") + h.stop_and_persist(symbol=self.successIcon, text=f"{kind}.{apiVersion} is not available in the cluster") + # With Kafka we also have to determine the provider (strimzi or redhat) - if mode == "kafka" and self.getParam("kafka_namespace") != "" and self.getParam("kafka_provider") == "": + if self.getParam("kafka_namespace") != "" and self.getParam("kafka_provider") == "": try: subAPI = self.dynamicClient.resources.get(api_version="operators.coreos.com/v1alpha1", kind="Subscription") subs = subAPI.get().to_dict()["items"] @@ -703,3 +850,7 @@ def detectDb2uOrKafka(self, mode: str) -> bool: # If the param is still undefined then there is a big problem if self.getParam("kafka_provider") == "": self.fatalError("Unable to determine whether the installed Kafka instance is managed by Strimzi or Red Hat AMQ Streams") + + # If the param is still undefined then there is a big problem + if self.getParam("kafka_provider") == "": + self.fatalError("Unable to determine whether the installed Kafka instance is managed by Strimzi or Red Hat AMQ Streams") diff --git a/python/src/mas/cli/update/argParser.py b/python/src/mas/cli/update/argParser.py index b7dfeacc535..f1084038c12 100644 --- a/python/src/mas/cli/update/argParser.py +++ b/python/src/mas/cli/update/argParser.py @@ -116,6 +116,20 @@ def parse_args(self, args=None, namespace=None): # type: ignore[override] help="Namespace where Db2u operator and instances will be updated", ) +depsArgGroup.add_argument( + '--db2-v12-upgrade', + required=False, + action="store_const", + const="true", + help="Required to confirm a major version update for Db2 to version 12", +) + +depsArgGroup.add_argument( + '--db2-license-file', + required=False, + help="Path to a valid Db2 v12 activation license file required for Db2 v11 to v12 upgrades", +) + depsArgGroup.add_argument( '--mongodb-namespace', required=False, diff --git a/python/test/aiservice/install/test_app.py b/python/test/aiservice/install/test_app.py index b53bac9f60f..fea867b99e1 100644 --- a/python/test/aiservice/install/test_app.py +++ b/python/test/aiservice/install/test_app.py @@ -176,8 +176,10 @@ def set_mixin_prompt_input(**kwargs): return 'nfs-client' if re.match('.*SLS Mode.*', message): return '1' - if re.match('.*License file.*', message): + if re.match('.*>License file<.*', message): return f'{tmpdir}/authorized_entitlement.lic' + if re.match('.*>Db2 License file<.*', message): + return '' if re.match('.*Instance ID.*', message): return 'apmdevops' if re.match('.*Scheduling constraints YAML file.*', message): @@ -304,8 +306,10 @@ def set_mixin_prompt_input(**kwargs): return 'nfs-client' if re.match('.*SLS Mode.*', message): return '1' - if re.match('.*License file.*', message): + if re.match('.*>License file<.*', message): return f'{tmpdir}/authorized_entitlement.lic' + if re.match('.*>Db2 License file<.*', message): + return '' if re.match('.*Instance ID.*', message): return 'apmdevops' if re.match('.*Operational Mode.*', message): diff --git a/python/test/aiservice/install/test_dev_mode.py b/python/test/aiservice/install/test_dev_mode.py index 8d3d99224b4..99f2f942ca8 100644 --- a/python/test/aiservice/install/test_dev_mode.py +++ b/python/test/aiservice/install/test_dev_mode.py @@ -66,7 +66,8 @@ def test_aiservice_install_master_dev_mode(tmpdir): # 4. Storage classes ".*Use the auto-detected storage classes.*": lambda msg: 'y', # 5. SLS configuration - '.*License file.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', + '.*>License file<.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', + '.*>Db2 License file<.*': lambda msg: '', # 6. DRO configuration ".*Contact e-mail address.*": lambda msg: 'maximo@ibm.com', ".*Contact first name.*": lambda msg: 'Test', @@ -137,7 +138,8 @@ def test_aiservice_install_master_dev_mode_existing_catalog(tmpdir): # 4. Storage classes ".*Use the auto-detected storage classes.*": lambda msg: 'y', # 5. SLS configuration - '.*License file.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', + '.*>License file<.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', + '.*>Db2 License file<.*': lambda msg: '', # 6. DRO configuration ".*Contact e-mail address.*": lambda msg: 'maximo@ibm.com', ".*Contact first name.*": lambda msg: 'Test', diff --git a/python/test/install/test_dev_mode.py b/python/test/install/test_dev_mode.py index 5e0d2e1aac0..80ea73d4a38 100644 --- a/python/test/install/test_dev_mode.py +++ b/python/test/install/test_dev_mode.py @@ -67,7 +67,8 @@ def test_install_master_dev_mode(tmpdir): ".*Use the auto-detected storage classes.*": lambda msg: 'y', # 5. SLS configuration '.*SLS channel.*': lambda msg: '1.x-stable', - '.*License file.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', + '.*>License file<.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', # SLS License (exact match with HTML tags) + '.*>Db2 License file<.*': lambda msg: '', # Db2 License (exact match with HTML tags) # 6. DRO configuration ".*Contact e-mail address.*": lambda msg: 'maximo@ibm.com', ".*Contact first name.*": lambda msg: 'Test', @@ -148,7 +149,8 @@ def test_install_master_dev_mode_existing_catalog(tmpdir): ".*Use the auto-detected storage classes.*": lambda msg: 'y', # 5. SLS configuration '.*SLS channel.*': lambda msg: '1.x-stable', - '.*License file.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', + '.*>License file<.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', # SLS License (exact match with HTML tags) + '.*>Db2 License file<.*': lambda msg: '', # Db2 License (exact match with HTML tags) # 6. DRO configuration ".*Contact e-mail address.*": lambda msg: 'maximo@ibm.com', ".*Contact first name.*": lambda msg: 'Test', @@ -255,7 +257,8 @@ def test_install_master_dev_mode_with_path_routing(tmpdir): # 6. SLS configuration '.*SLS Mode.*': lambda msg: '1', # SLS Mode prompt (appears with advanced options) '.*SLS channel.*': lambda msg: '1.x-stable', - '.*License file.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', + '.*>License file<.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', # SLS License (exact match with HTML tags) + '.*>Db2 License file<.*': lambda msg: '', # Db2 License (exact match with HTML tags) # 7. DRO configuration '.*DRO.*Namespace.*': lambda msg: '', # DRO Namespace prompt (appears with advanced options) ".*Contact e-mail address.*": lambda msg: 'maximo@ibm.com', diff --git a/python/test/install/test_existing_catalog.py b/python/test/install/test_existing_catalog.py index cfb66554493..6fa4ca580ae 100644 --- a/python/test/install/test_existing_catalog.py +++ b/python/test/install/test_existing_catalog.py @@ -35,7 +35,8 @@ def test_install_interactive_existing_catalog(tmpdir): # 5. Storage classes ".*Use the auto-detected storage classes.*": lambda msg: 'y', # 6. SLS configuration - '.*License file.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', + '.*>License file<.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', # SLS License (exact match with HTML tags) + '.*>Db2 License file<.*': lambda msg: '', # Db2 License (exact match with HTML tags) # 7. DRO configuration ".*Contact e-mail address.*": lambda msg: 'maximo@ibm.com', ".*Contact first name.*": lambda msg: 'Test', diff --git a/python/test/install/test_no_catalog.py b/python/test/install/test_no_catalog.py index 29fabe1adbb..c013ad4d40a 100644 --- a/python/test/install/test_no_catalog.py +++ b/python/test/install/test_no_catalog.py @@ -35,7 +35,8 @@ def test_install_interactive_no_catalog(tmpdir): # 5. Storage classes ".*Use the auto-detected storage classes.*": lambda msg: 'y', # 6. SLS configuration - '.*License file.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', + '.*>License file<.*': lambda msg: f'{tmpdir}/authorized_entitlement.lic', # SLS License (exact match with HTML tags) + '.*>Db2 License file<.*': lambda msg: '', # Db2 License (exact match with HTML tags) # 7. DRO configuration ".*Contact e-mail address.*": lambda msg: 'maximo@ibm.com', ".*Contact first name.*": lambda msg: 'Test', diff --git a/python/test/update/test_db2u_interactive.py b/python/test/update/test_db2u_interactive.py index 88f5a1b1de6..d9648d7f7ac 100644 --- a/python/test/update/test_db2u_interactive.py +++ b/python/test/update/test_db2u_interactive.py @@ -20,7 +20,14 @@ @pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) def test_db2u_one_namespace(tmpdir, resource_kind): - """Test interactive update when exactly one namespace contains Db2U resources.""" + """Test interactive update when exactly one namespace contains Db2U resources. + + Expected behavior: + - Automatically detects single namespace + - Sets db2_namespace parameter + - No namespace selection prompt needed + - Update proceeds successfully + """ prompt_handlers = { # Proceed with current cluster @@ -49,7 +56,15 @@ def test_db2u_one_namespace(tmpdir, resource_kind): @pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) def test_db2u_multiple_namespaces(tmpdir, resource_kind): - """Test interactive update when multiple namespaces contain Db2U resources.""" + """Test interactive update when multiple namespaces contain Db2U resources. + + Expected behavior: + - Detects resources in multiple namespaces + - Prompts user to select namespace + - User selects second namespace (db2u-ns2) + - Sets db2_namespace parameter to selected namespace + - Update proceeds successfully + """ prompt_handlers = { # Proceed with current cluster @@ -80,7 +95,14 @@ def test_db2u_multiple_namespaces(tmpdir, resource_kind): @pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) def test_db2u_none_found(tmpdir, resource_kind): - """Test interactive update when no Db2U resources exist.""" + """Test interactive update when no Db2U resources exist. + + Expected behavior: + - Detects no Db2U resources + - db2_namespace parameter remains empty + - No prompts for namespace selection + - Update continues without error (not a failure condition) + """ prompt_handlers = { # Proceed with current cluster @@ -107,4 +129,238 @@ def test_db2u_none_found(tmpdir, resource_kind): run_update_test(tmpdir, config) +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_major_version_upgrade_accepted(tmpdir, resource_kind): + """Test interactive update with Db2 major version upgrade - user accepts. + + Expected behavior: + - Detects Db2 v11 needs upgrade to v12 + - Prompts user to confirm major version upgrade + - User accepts the upgrade + - Sets db2_v12_upgrade parameter to true + - Update proceeds successfully + """ + + valid_license_file = os.path.join(str(tmpdir), "db2-license.lic") + with open(valid_license_file, "w") as handle: + handle.write("db2-license") + + prompt_handlers = { + # Proceed with current cluster + '.*Proceed with this cluster.*': lambda msg: 'y', + # Catalog selection + '.*Select catalog version.*': lambda msg: '1', + # Db2 version upgrade confirmation - match the exact format + '.*Confirm update from Db2 11 to 12.*': lambda msg: 'y', + # License prompt + '.*Path to a valid Db2 v12 license file.*': lambda msg: valid_license_file, + # Final confirmation + '.*Proceed with these settings.*': lambda msg: 'y', + } + + config = UpdateTestConfig( + prompt_handlers=prompt_handlers, + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-system"], + db2u_resource_kind=resource_kind, + db2u_version="11.5.9.0", # Current version + db2u_target_version="v12.0", # Target requires upgrade + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + timeout_seconds=60 + ) + + run_update_test(tmpdir, config) + + +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_major_version_upgrade_rejected(tmpdir, resource_kind): + """Test interactive update with Db2 major version upgrade - user rejects. + + Expected behavior: + - Detects Db2 v11 needs upgrade to v12 + - Prompts user to confirm major version upgrade + - User rejects the upgrade + - Raises SystemExit with exit code 1 + - Update does not proceed + """ + + prompt_handlers = { + # Proceed with current cluster + '.*Proceed with this cluster.*': lambda msg: 'y', + # Catalog selection + '.*Select catalog version.*': lambda msg: '1', + # Db2 version upgrade confirmation - user rejects - match exact format + '.*Confirm update from Db2 11 to 12.*': lambda msg: 'n', + } + + config = UpdateTestConfig( + prompt_handlers=prompt_handlers, + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-system"], + db2u_resource_kind=resource_kind, + db2u_version="11.5.9.0", # Current version + db2u_target_version="v12.0", # Target requires upgrade + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + expect_system_exit=True, + expected_exit_code=1, + timeout_seconds=60 + ) + + run_update_test(tmpdir, config) + + +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_major_version_upgrade_aborted_when_license_file_rejected(tmpdir, resource_kind): + """Test interactive Db2 v11 to v12 upgrade aborts when an invalid license file path is provided. + + Expected behavior: + - User confirms the major version upgrade + - User provides a path to a non-existent license file (mustExist=False, so prompt accepts it) + - User confirms the final settings + - db2LicenseFile() attempts to open the file and raises FileNotFoundError + - Update is aborted with an unhandled exception + """ + + invalid_license_file = os.path.join(str(tmpdir), "missing-db2-license.lic") + + prompt_handlers = { + '.*Proceed with this cluster.*': lambda msg: 'y', + '.*Select catalog version.*': lambda msg: '1', + '.*Confirm update from Db2 11 to 12.*': lambda msg: 'y', + '.*Path to a valid Db2 v12 license file.*': lambda msg: invalid_license_file, + '.*Proceed with these settings.*': lambda msg: 'y', + } + + config = UpdateTestConfig( + prompt_handlers=prompt_handlers, + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-system"], + db2u_resource_kind=resource_kind, + db2u_version="11.5.9.0", + db2u_target_version="v12.0", + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + expect_exception=FileNotFoundError, + timeout_seconds=60 + ) + + run_update_test(tmpdir, config) + + +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_minor_version_upgrade_no_prompt(tmpdir, resource_kind): + """Test interactive update with Db2 minor version upgrade - no prompt needed. + + Expected behavior: + - Detects Db2 v11.5.8.0 needs upgrade to v11.5.9.0 + - No prompt for minor version upgrade + - Update proceeds automatically + """ + + prompt_handlers = { + # Proceed with current cluster + '.*Proceed with this cluster?.*': lambda msg: 'y', + # Catalog selection + '.*Select catalog version.*': lambda msg: '1', + # Final confirmation + '.*Proceed with these settings.*': lambda msg: 'y', + } + + config = UpdateTestConfig( + prompt_handlers=prompt_handlers, + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-system"], + db2u_resource_kind=resource_kind, + db2u_version="11.5.8.0", # Current version + db2u_target_version="v11.5", # Same major version + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + timeout_seconds=30 + ) + + run_update_test(tmpdir, config) + + +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_multiple_namespaces_first_selection(tmpdir, resource_kind): + """Test interactive update - user selects first namespace from multiple. + + Expected behavior: + - Detects resources in multiple namespaces + - User selects first namespace (db2u-ns1) + - Sets db2_namespace parameter to first namespace + - Update proceeds successfully + """ + + prompt_handlers = { + '.*Proceed with this cluster?.*': lambda msg: 'y', + '.*Select catalog version.*': lambda msg: '1', + '.*Select namespace.*': lambda msg: '1', # Select first + '.*Proceed with these settings.*': lambda msg: 'y', + } + + config = UpdateTestConfig( + prompt_handlers=prompt_handlers, + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-ns1", "db2u-ns2"], + db2u_resource_kind=resource_kind, + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + timeout_seconds=30 + ) + + run_update_test(tmpdir, config) + + +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_multiple_namespaces_last_selection(tmpdir, resource_kind): + """Test interactive update - user selects last namespace from multiple. + + Expected behavior: + - Detects resources in multiple namespaces + - User selects last namespace (db2u-ns3) + - Sets db2_namespace parameter to last namespace + - Update proceeds successfully + """ + + prompt_handlers = { + '.*Proceed with this cluster?.*': lambda msg: 'y', + '.*Select catalog version.*': lambda msg: '1', + '.*Select namespace.*': lambda msg: '3', # Select last + '.*Proceed with these settings.*': lambda msg: 'y', + } + + config = UpdateTestConfig( + prompt_handlers=prompt_handlers, + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-ns1", "db2u-ns2", "db2u-ns3"], + db2u_resource_kind=resource_kind, + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + timeout_seconds=30 + ) + + run_update_test(tmpdir, config) + + # Made with Bob diff --git a/python/test/update/test_db2u_non_interactive.py b/python/test/update/test_db2u_non_interactive.py index 29c292332b1..005e5ea6b8c 100644 --- a/python/test/update/test_db2u_non_interactive.py +++ b/python/test/update/test_db2u_non_interactive.py @@ -170,4 +170,228 @@ def test_db2u_no_namespaces(tmpdir, resource_kind, with_arg): run_update_test(tmpdir, config) +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_major_version_upgrade_without_flag(tmpdir, resource_kind): + """Test non-interactive update with Db2 major version upgrade but no flag - should fail. + + Expected behavior: + - Detects Db2 v11 needs upgrade to v12 + - No --db2-v12-upgrade flag provided + - Raises SystemExit with non-zero exit code + - Error message indicates --db2-v12-upgrade flag is required + """ + + config = UpdateTestConfig( + prompt_handlers={}, # No prompts in non-interactive mode + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-system"], + db2u_resource_kind=resource_kind, + db2u_version="11.5.9.0", # Current version + db2u_target_version="v12.0", # Target requires upgrade + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + argv=['--catalog', 'v9-260129-amd64', '--no-confirm'], + expect_system_exit=True, # Expect failure + timeout_seconds=30 + ) + + run_update_test(tmpdir, config) + + +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_major_version_upgrade_with_flag(tmpdir, resource_kind): + """Test non-interactive update with Db2 major version upgrade and flag. + + Expected behavior: + - Detects Db2 v11 needs upgrade to v12 + - --db2-v12-upgrade flag provided + - Valid --db2-license-file path provided + - Update proceeds successfully + """ + + valid_license_file = os.path.join(str(tmpdir), "db2-license.lic") + with open(valid_license_file, "w") as handle: + handle.write("db2-license") + + config = UpdateTestConfig( + prompt_handlers={}, # No prompts in non-interactive mode + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-system"], + db2u_resource_kind=resource_kind, + db2u_version="11.5.9.0", # Current version + db2u_target_version="v12.0", # Target requires upgrade + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + argv=[ + '--catalog', 'v9-260129-amd64', + '--db2-v12-upgrade', + '--db2-license-file', valid_license_file, + '--no-confirm' + ], + timeout_seconds=30 + ) + + run_update_test(tmpdir, config) + + +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_major_version_upgrade_with_flag_without_license_file(tmpdir, resource_kind): + """Test non-interactive Db2 v11 to v12 upgrade without license file - should fail.""" + + config = UpdateTestConfig( + prompt_handlers={}, + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-system"], + db2u_resource_kind=resource_kind, + db2u_version="11.5.9.0", + db2u_target_version="v12.0", + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + argv=['--catalog', 'v9-260129-amd64', '--db2-v12-upgrade', '--no-confirm'], + expect_system_exit=True, + timeout_seconds=30 + ) + + run_update_test(tmpdir, config) + + +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_major_version_upgrade_with_flag_invalid_license_file(tmpdir, resource_kind): + """Test non-interactive Db2 v11 to v12 upgrade with invalid license file path - should fail. + + Expected behavior: + - --db2-v12-upgrade flag is provided + - --db2-license-file points to a non-existent file + - db2LicenseFile() attempts to open the file and raises FileNotFoundError + - Update is aborted with FileNotFoundError + """ + + invalid_license_file = os.path.join(str(tmpdir), "missing-db2-license.lic") + + config = UpdateTestConfig( + prompt_handlers={}, + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-system"], + db2u_resource_kind=resource_kind, + db2u_version="11.5.9.0", + db2u_target_version="v12.0", + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + argv=[ + '--catalog', 'v9-260129-amd64', + '--db2-v12-upgrade', + '--db2-license-file', invalid_license_file, + '--no-confirm' + ], + expect_exception=FileNotFoundError, + timeout_seconds=30 + ) + + run_update_test(tmpdir, config) + + +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_minor_version_upgrade_no_flag_needed(tmpdir, resource_kind): + """Test non-interactive update with Db2 minor version upgrade - no flag needed. + + Expected behavior: + - Detects Db2 v11.5.8.0 needs upgrade to v11.5.9.0 + - No flag required for minor version upgrade + - Update proceeds successfully + """ + + config = UpdateTestConfig( + prompt_handlers={}, # No prompts in non-interactive mode + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-system"], + db2u_resource_kind=resource_kind, + db2u_version="11.5.8.0", # Current version + db2u_target_version="v11.5", # Same major version + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + argv=['--catalog', 'v9-260129-amd64', '--no-confirm'], + timeout_seconds=30 + ) + + run_update_test(tmpdir, config) + + +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_same_version_no_upgrade(tmpdir, resource_kind): + """Test non-interactive update when Db2 is already at target version. + + Expected behavior: + - Detects Db2 is already at target version + - No upgrade needed + - Update proceeds successfully + """ + + config = UpdateTestConfig( + prompt_handlers={}, # No prompts in non-interactive mode + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-system"], + db2u_resource_kind=resource_kind, + db2u_version="11.5.9.0", # Current version + db2u_target_version="v11.5", # Same version + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + argv=['--catalog', 'v9-260129-amd64', '--no-confirm'], + timeout_seconds=30 + ) + + run_update_test(tmpdir, config) + + +@pytest.mark.parametrize("resource_kind", ["Db2uCluster", "Db2uInstance"]) +def test_db2u_combined_namespace_and_version_upgrade(tmpdir, resource_kind): + """Test non-interactive update with namespace arg, version flag, and license file.""" + + valid_license_file = os.path.join(str(tmpdir), "db2-license-combined.lic") + with open(valid_license_file, "w") as handle: + handle.write("db2-license") + + config = UpdateTestConfig( + prompt_handlers={}, # No prompts in non-interactive mode + installed_catalog_id="v9-251231-amd64", + target_catalog_version="v9-260129-amd64", + db2u_namespaces=["db2u-ns1", "db2u-ns2"], # Multiple namespaces + db2u_resource_kind=resource_kind, + db2u_namespace_arg="db2u-ns1", # Explicit namespace + db2u_version="11.5.9.0", # Current version + db2u_target_version="v12.0", # Target requires upgrade + mas_instances=[{ + "metadata": {"name": "inst1"}, + "status": {"versions": {"reconciled": "9.1.7"}} + }], + argv=[ + '--catalog', 'v9-260129-amd64', + '--db2-namespace', 'db2u-ns1', + '--db2-v12-upgrade', + '--db2-license-file', valid_license_file, + '--no-confirm' + ], + timeout_seconds=30 + ) + + run_update_test(tmpdir, config) + + # Made with Bob diff --git a/python/test/utils/update_test_helper.py b/python/test/utils/update_test_helper.py index ecc5c47dc60..941efd0216d 100644 --- a/python/test/utils/update_test_helper.py +++ b/python/test/utils/update_test_helper.py @@ -32,6 +32,8 @@ def __init__( db2u_namespaces: Optional[List[str]] = None, db2u_resource_kind: str = "Db2uCluster", db2u_namespace_arg: Optional[str] = None, + db2u_version: Optional[str] = None, + db2u_target_version: Optional[str] = None, kafka_namespaces: Optional[List[str]] = None, kafka_namespace_arg: Optional[str] = None, kafka_provider: Optional[str] = None, @@ -43,6 +45,7 @@ def __init__( timeout_seconds: int = 30, expect_system_exit: bool = False, expected_exit_code: Optional[int] = None, + expect_exception: Optional[type] = None, argv: Optional[list] = None ): """ @@ -56,6 +59,8 @@ def __init__( db2u_namespaces: List of namespaces containing Db2U resources (None = no resources) db2u_resource_kind: Type of Db2U resource ("Db2uCluster" or "Db2uInstance") db2u_namespace_arg: Value for --db2-namespace CLI argument + db2u_version: Current Db2u version (e.g., "11.5.9.0") + db2u_target_version: Target Db2u version from catalog (e.g., "v12.0") kafka_namespaces: List of namespaces containing Kafka resources kafka_namespace_arg: Value for --kafka-namespace CLI argument kafka_provider: Kafka provider type ("strimzi" or "redhat") @@ -67,6 +72,7 @@ def __init__( timeout_seconds: Timeout for watchdog (default 30s) expect_system_exit: Whether to expect SystemExit to be raised expected_exit_code: Expected exit code if SystemExit is raised + expect_exception: Expect a specific exception type to be raised (e.g. FileNotFoundError) argv: Command line arguments to pass to app.update() (default: []) """ self.prompt_handlers = prompt_handlers @@ -76,6 +82,8 @@ def __init__( self.db2u_namespaces = db2u_namespaces if db2u_namespaces is not None else [] self.db2u_resource_kind = db2u_resource_kind self.db2u_namespace_arg = db2u_namespace_arg + self.db2u_version = db2u_version if db2u_version is not None else "11.5.9.0" + self.db2u_target_version = db2u_target_version if db2u_target_version is not None else "v11.5" self.kafka_namespaces = kafka_namespaces if kafka_namespaces is not None else [] self.kafka_namespace_arg = kafka_namespace_arg self.kafka_provider = kafka_provider @@ -87,6 +95,7 @@ def __init__( self.timeout_seconds = timeout_seconds self.expect_system_exit = expect_system_exit self.expected_exit_code = expected_exit_code + self.expect_exception = expect_exception self.argv = argv if argv is not None else [] @@ -140,7 +149,7 @@ def create_db2u_resource(self, kind: str, name: str, namespace: str) -> Dict: "namespace": namespace }, "spec": { - "version": "11.5.9.0", + "version": self.config.db2u_version, "license": {"accept": True} }, "status": { @@ -455,6 +464,7 @@ def run_update_test(self): ('install_pipelines', mock.patch('mas.cli.update.app.installOpenShiftPipelines')), ('create_namespace', mock.patch('mas.cli.update.app.createNamespace')), ('prepare_pipelines_namespace', mock.patch('mas.cli.update.app.preparePipelinesNamespace')), + ('prepare_update_secrets', mock.patch('mas.cli.update.app.prepareUpdateSecrets')), ('update_tekton_definitions', mock.patch('mas.cli.update.app.updateTektonDefinitions')), ('launch_update_pipeline', mock.patch('mas.cli.update.app.launchUpdatePipeline')), ('mixins_prompt', mock.patch('mas.cli.displayMixins.prompt')), @@ -495,7 +505,8 @@ def run_update_test(self): mocks['get_catalog'].return_value = { 'ocp_compatibility': ['4.16', '4.17', '4.18'], 'mongo_extras_version_default': '6.0.5', - 'cpd_product_version_default': '5.2.0' + 'cpd_product_version_default': '5.2.0', + 'db2_channel_default': self.config.db2u_target_version } # Pipeline setup @@ -513,6 +524,8 @@ def run_update_test(self): # Setup prompt handler self.setup_prompt_handler(mocks['mixins_prompt'], prompt_session_instance) + exception_raised = None + try: self.app = UpdateApp() self.app.update(argv=self.config.argv) @@ -521,6 +534,10 @@ def run_update_test(self): exit_code = e.code if not self.config.expect_system_exit: raise + except Exception as e: + exception_raised = e + if self.config.expect_exception is None or not isinstance(e, self.config.expect_exception): + raise finally: self.stop_watchdog() @@ -528,6 +545,10 @@ def run_update_test(self): if self.test_failed['message']: raise TimeoutError(self.test_failed['message']) + # Verify specific exception was raised if expected + if self.config.expect_exception is not None and exception_raised is None: + raise AssertionError(f"Expected {self.config.expect_exception.__name__} to be raised but it was not") + # Verify SystemExit was raised if expected if self.config.expect_system_exit and not system_exit_raised: raise AssertionError("Expected SystemExit to be raised but it was not") diff --git a/tekton/src/params/install-db2.yml.j2 b/tekton/src/params/install-db2.yml.j2 index e28f08fafd8..a0e80431f0f 100644 --- a/tekton/src/params/install-db2.yml.j2 +++ b/tekton/src/params/install-db2.yml.j2 @@ -40,6 +40,10 @@ type: string description: Db2 Openshift Custom Resource (db2ucluster or db2uinstance). Defaults to db2ucluster if not specified. default: "" +- name: db2_license_file + type: string + description: Db2 activation license file + default: "" # Dependences - Db2 - Node scheduling # ------------------------------------------------------------------------- diff --git a/tekton/src/pipelines/mas-install.yml.j2 b/tekton/src/pipelines/mas-install.yml.j2 index 7857caab965..68db89fe6a5 100644 --- a/tekton/src/pipelines/mas-install.yml.j2 +++ b/tekton/src/pipelines/mas-install.yml.j2 @@ -9,12 +9,14 @@ spec: - name: shared-configs # Any pre-generated configs that will be copied into the shared-configs workspace during suite-install - name: shared-additional-configs - # The SLS entitlement key file that will be installed during install-sls. + # The SLS entitlement key and Db2 license file that will be installed during install-sls and db2. - name: shared-entitlement # Pre-generated certificates that will be copied into certs folder of shared-configs workspace to be used by suite-certs task - name: shared-certificates # PodTemplates configurations - name: shared-pod-templates + # Db2 License File + - name: shared-db2 # AIService configurations. Contains Scheduling config file for AI workloads for tenant. - name: shared-aiservice-config @@ -116,18 +118,18 @@ spec: # 2.3 Db2 # 2.3.1 System Db2 - {{ lookup('template', pipeline_src_dir ~ '/taskdefs/dependencies/db2.yml.j2', template_vars={'suffix': 'system'}) | indent(4) }} + {{ lookup('template', pipeline_src_dir ~ '/taskdefs/dependencies/db2.yml.j2', template_vars={'suffix': 'system', 'db2_license_workspace': 'true'}) | indent(4) }} runAfter: - cert-manager # 2.3.2 Dedicated Manage Db2 - {{ lookup('template', pipeline_src_dir ~ '/taskdefs/dependencies/db2.yml.j2', template_vars={'suffix': 'manage'}) | indent(4) }} + {{ lookup('template', pipeline_src_dir ~ '/taskdefs/dependencies/db2.yml.j2', template_vars={'suffix': 'manage', 'db2_license_workspace': 'true'}) | indent(4) }} runAfter: - db2-system # 2.3.3 Install Dedicated Db2 for AI Service # ------------------------------------------------------------------------- - {{ lookup('template', pipeline_src_dir ~ '/taskdefs/dependencies/db2.yml.j2', template_vars={'suffix': 'aiservice'}) | indent(4) }} + {{ lookup('template', pipeline_src_dir ~ '/taskdefs/dependencies/db2.yml.j2', template_vars={'suffix': 'aiservice', 'db2_license_workspace': 'true'}) | indent(4) }} runAfter: - cert-manager diff --git a/tekton/src/pipelines/mas-update.yml.j2 b/tekton/src/pipelines/mas-update.yml.j2 index ce20b8eaf40..30a3ad94084 100644 --- a/tekton/src/pipelines/mas-update.yml.j2 +++ b/tekton/src/pipelines/mas-update.yml.j2 @@ -4,6 +4,10 @@ kind: Pipeline metadata: name: mas-update spec: + workspaces: + # Db2 License File + - name: shared-db2 + optional: true params: # Tekton Pipeline Configuration # ------------------------------------------------------------------------- @@ -47,6 +51,18 @@ spec: type: string default: "db2u" description: Namespace where db2 instances will be updated + - name: db2_v12_upgrade + type: string + description: Approves the Db2 upgrade to version 12 if needed + default: "" + - name: db2_license_file + type: string + description: Optional path to a Db2 v12 activation license file; required at runtime for Db2 v11 to v12 upgrades + default: "" + - name: db2_channel + type: string + description: Db2 channel to be upgraded + default: "" # mongodb update # ------------------------------------------------------------------------- @@ -285,6 +301,15 @@ spec: value: $(params.db2_action) - name: db2_namespace value: $(params.db2_namespace) + - name: db2_v12_upgrade + value: $(params.db2_v12_upgrade) + - name: db2_license_file + value: $(params.db2_license_file) + - name: db2_channel + value: $(params.db2_channel) + workspaces: + - name: db2 + workspace: shared-db2 - name: update-mongodb timeout: "0" @@ -410,3 +435,4 @@ spec: # An aggregate status of all the pipelineTasks under the tasks section (excluding the finally section). # This variable is only available in the finally tasks and can have any one of the values (Succeeded, Failed, Completed, or None) value: $(tasks.status) + diff --git a/tekton/src/pipelines/taskdefs/apps/db2-setup-facilities.yml.j2 b/tekton/src/pipelines/taskdefs/apps/db2-setup-facilities.yml.j2 index 59a45be78b1..00c86f12d29 100644 --- a/tekton/src/pipelines/taskdefs/apps/db2-setup-facilities.yml.j2 +++ b/tekton/src/pipelines/taskdefs/apps/db2-setup-facilities.yml.j2 @@ -42,6 +42,8 @@ value: $(params.db2_table_org) - name: db2u_kind value: $(params.db2u_kind) + - name: db2_license_file + value: $(params.db2_license_file) # Node Scheduling - name: db2_affinity_key @@ -127,4 +129,6 @@ kind: Task workspaces: - name: configs - workspace: shared-configs \ No newline at end of file + workspace: shared-configs + - name: db2 + workspace: shared-db2 diff --git a/tekton/src/pipelines/taskdefs/dependencies/db2.yml.j2 b/tekton/src/pipelines/taskdefs/dependencies/db2.yml.j2 index 2ffb7f7a82b..7234e6ae74f 100644 --- a/tekton/src/pipelines/taskdefs/dependencies/db2.yml.j2 +++ b/tekton/src/pipelines/taskdefs/dependencies/db2.yml.j2 @@ -54,6 +54,8 @@ value: $(params.db2_table_org) - name: db2u_kind value: $(params.db2u_kind) + - name: db2_license_file + value: $(params.db2_license_file) # Node Scheduling - name: db2_affinity_key @@ -144,3 +146,7 @@ workspaces: - name: configs workspace: shared-configs +{% if db2_license_workspace is defined and db2_license_workspace == 'true' %} + - name: db2 + workspace: shared-db2 +{% endif %} \ No newline at end of file diff --git a/tekton/src/tasks/dependencies/db2.yml.j2 b/tekton/src/tasks/dependencies/db2.yml.j2 index 35997b9c737..1c245910a19 100644 --- a/tekton/src/tasks/dependencies/db2.yml.j2 +++ b/tekton/src/tasks/dependencies/db2.yml.j2 @@ -11,6 +11,10 @@ spec: - name: ibm_entitlement_key type: string default: "" + - name: db2_license_file + type: string + description: Db2 activation license file + default: "" # Db2u Operator - name: db2_channel @@ -172,6 +176,11 @@ spec: description: Optional MAS custom labels, comma separated list of key=value pairs default: "" + # Other Db2 parameters + - name: db2_v12_upgrade + type: string + description: Approves the Db2 upgrade to version 12 if needed + default: "" # Backup/Restore specific parameters - name: db2_backup_version type: string @@ -217,6 +226,8 @@ spec: # Entitlement - name: IBM_ENTITLEMENT_KEY value: $(params.ibm_entitlement_key) + - name: DB2_LICENSE_FILE + value: $(params.db2_license_file) # Db2u Operator - name: DB2_CHANNEL @@ -327,6 +338,10 @@ spec: # Custom labels support - name: CUSTOM_LABELS value: $(params.custom_labels) + + # Other parameters + - name: DB2_V12_UPGRADE + value: $(params.db2_v12_upgrade) # Backup/Restore specific - name: MAS_BACKUP_DIR @@ -361,3 +376,5 @@ spec: optional: true - name: backups optional: true + - name: db2 + optional: true diff --git a/tekton/src/tasks/suite-db2-setup-for-facilities.yml.j2 b/tekton/src/tasks/suite-db2-setup-for-facilities.yml.j2 index d242a3e04c5..300c3996c92 100644 --- a/tekton/src/tasks/suite-db2-setup-for-facilities.yml.j2 +++ b/tekton/src/tasks/suite-db2-setup-for-facilities.yml.j2 @@ -59,6 +59,10 @@ spec: - name: db2u_kind type: string default: "" + - name: db2_license_file + type: string + default: "" + description: Db2 activation license file # Db2 - Node scheduling - name: db2_affinity_key @@ -208,6 +212,8 @@ spec: value: $(params.db2_table_org) - name: DB2U_KIND value: $(params.db2u_kind) + - name: DB2_LICENSE_FILE + value: $(params.db2_license_file) # Db2 - Node Scheduling - name: DB2_AFFINITY_KEY @@ -303,3 +309,5 @@ spec: workspaces: - name: configs optional: true + - name: db2 + optional: true