diff --git a/image/cli/app-root/src/.bashrc b/image/cli/app-root/src/.bashrc index 3bacaf4273..6a0f7ac43d 100644 --- a/image/cli/app-root/src/.bashrc +++ b/image/cli/app-root/src/.bashrc @@ -32,6 +32,7 @@ echo " - ${TEXT_BOLD}${COLOR_GREEN}mas must-gather${TEXT_RESET} to perform must echo " - ${TEXT_BOLD}${COLOR_GREEN}mas uninstall${TEXT_RESET} to uninstall a MAS instance" echo " - ${TEXT_BOLD}${COLOR_GREEN}mas backup${TEXT_RESET} to backup a MAS instance" echo " - ${TEXT_BOLD}${COLOR_GREEN}mas restore${TEXT_RESET} to restore a MAS instance" +echo " - ${TEXT_BOLD}${COLOR_GREEN}mas db2ucluster-migration${TEXT_RESET} to migrate Db2uCluster to Db2uInstance" # None of these functions are tested/supported on s390x /ppc64le yet if [ $arch != "s390x" ] && [ $arch != "ppc64le" ]; then diff --git a/image/cli/mascli/mas b/image/cli/mascli/mas index 9bbe495e9c..d353827734 100755 --- a/image/cli/mascli/mas +++ b/image/cli/mascli/mas @@ -372,6 +372,16 @@ case $1 in mas-cli restore "$@" ;; + db2ucluster-migration) + echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" >> $LOGFILE + echo "!! db2ucluster-migration !!" >> $LOGFILE + echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" >> $LOGFILE + echo "${TEXT_UNDERLINE}IBM Maximo Application Suite DB2 Migration Manager (v${VERSION})${TEXT_RESET}" + echo "Powered by ${COLOR_CYAN}${TEXT_UNDERLINE}https://github.com/ibm-mas/ansible-devops/${TEXT_RESET} and ${COLOR_CYAN}${TEXT_UNDERLINE}https://tekton.dev/${TEXT_RESET}" + shift + mas-cli db2ucluster-migration "$@" + ;; + gitops-bootstrap) echo "${TEXT_UNDERLINE}IBM Maximo Application Suite GitOps Manager (v${VERSION})${TEXT_RESET}" echo "Powered by ${COLOR_CYAN}${TEXT_UNDERLINE}https://github.com/ibm-mas/gitops/${TEXT_RESET}" diff --git a/python/src/mas/cli/__main__.py b/python/src/mas/cli/__main__.py index 5e495c2aef..65746ec8bf 100644 --- a/python/src/mas/cli/__main__.py +++ b/python/src/mas/cli/__main__.py @@ -50,6 +50,7 @@ def usage() -> None: + " - mas-cli must-gather Collect diagnostic information\n" + " - mas-cli setup-rbac Set up RBAC resources for MAS installation\n" + " - mas-cli pre-install Set up pre-install RBAC for MAS\n" + + " - mas-cli db2ucluster-migration Migrate Db2uCluster to Db2uInstance\n" ) ) print_formatted_text(HTML("For usage information run mas-cli [action] --help\n")) @@ -131,6 +132,12 @@ def main() -> None: app = MustGatherApp() app.mustGather(argv[2:]) return + if function == "db2ucluster-migration": + from mas.cli.db2_migration.app import Db2MigrationApp + + app = Db2MigrationApp() + app.migrate(argv[2:]) + return if function in ["-h", "--help"]: usage() raise SystemExit(0) diff --git a/python/src/mas/cli/db2_migration/__init__.py b/python/src/mas/cli/db2_migration/__init__.py new file mode 100644 index 0000000000..a8e4860b7e --- /dev/null +++ b/python/src/mas/cli/db2_migration/__init__.py @@ -0,0 +1,9 @@ +# ----------------------------------------------------------- +# Licensed Materials - Property of IBM +# 5737-M66 +# (C) Copyright IBM Corp. 2026 All Rights Reserved. +# US Government Users Restricted Rights - Use, duplication, or disclosure +# restricted by GSA ADP Schedule Contract with IBM Corp. +# ----------------------------------------------------------- + +from ..cli import BaseApp # noqa: F401 diff --git a/python/src/mas/cli/db2_migration/app.py b/python/src/mas/cli/db2_migration/app.py new file mode 100644 index 0000000000..7d270e33d9 --- /dev/null +++ b/python/src/mas/cli/db2_migration/app.py @@ -0,0 +1,220 @@ +# ----------------------------------------------------------- +# Licensed Materials - Property of IBM +# 5737-M66 +# (C) Copyright IBM Corp. 2026 All Rights Reserved. +# US Government Users Restricted Rights - Use, duplication, or disclosure +# restricted by GSA ADP Schedule Contract with IBM Corp. +# ----------------------------------------------------------- + +import logging + +from typing import List, Dict, Any +from halo import Halo +from prompt_toolkit import print_formatted_text, HTML +from openshift.dynamic.exceptions import NotFoundError + +from ..cli import BaseApp +from .argParser import db2MigrationArgParser +from mas.devops.ocp import createNamespace +from mas.devops.tekton import preparePipelinesNamespace, installOpenShiftPipelines, updateTektonDefinitions, launchDb2MigrationPipeline + +logger = logging.getLogger(__name__) + + +class Db2MigrationApp(BaseApp): + """Application class for Db2uCluster to Db2uInstance migration""" + + def detectDb2uClusters(self, namespace: str) -> List[Dict[str, Any]]: + """Detect all Db2uCluster instances in the specified namespace. + + Args: + namespace (str): Kubernetes namespace to search + + Returns: + List[Dict[str, Any]]: List of Db2uCluster resources found + """ + try: + db2ClusterAPI = self.dynamicClient.resources.get(api_version="db2u.databases.ibm.com/v1", kind="Db2uCluster") + db2_clusters = db2ClusterAPI.get(namespace=namespace) + return db2_clusters.items if db2_clusters else [] + except NotFoundError: + return [] + + def promptForDb2Cluster(self, db2_clusters: List[Dict[str, Any]]) -> Dict[str, Any]: + """Prompt user to select a Db2uCluster from the detected list. + + Args: + db2_clusters (List[Dict[str, Any]]): List of available Db2uCluster resources + + Returns: + Dict[str, Any]: Selected Db2uCluster resource + """ + if len(db2_clusters) == 1: + db2_cluster = db2_clusters[0] + db2ClusterName = db2_cluster.metadata.name + self.printHighlight(f"Found 1 Db2uCluster: {db2ClusterName}") + return db2_cluster + + # Multiple Db2uClusters - Prompt for selection + # self.printH2("Available Db2uClusters") + # options = [] + # for i, db2_cluster in enumerate(db2_clusters): + # name = db2_cluster.metadata.name + # version = db2_cluster.spec.version if hasattr(db2_cluster.spec, "version") else "unknown" + # status = db2_cluster.status.state if hasattr(db2_cluster, "status") and hasattr(db2_cluster.status, "state") else "unknown" + # options.append(f"{name} (version: {version}, status: {status})") + + # selectedIndex = self.promptForListSelect("Select Db2uCluster to migrate", options) + # return db2_clusters[selectedIndex] + + self.printH2("Available Db2uClusters") + for i, db2_cluster in enumerate(db2_clusters): + name = db2_cluster.metadata.name + version = db2_cluster.spec.version if hasattr(db2_cluster.spec, "version") else "unknown" + status = db2_cluster.status.state if hasattr(db2_cluster, "status") and hasattr(db2_cluster.status, "state") else "unknown" + print(f" {i+1}. {name} (version: {version}, status: {status})") + + selectedIndex = self.promptForInt("Select Db2uCluster to migrate", min=1, max=len(db2_clusters)) + return db2_clusters[selectedIndex - 1] + + def promptForBackup(self) -> bool: + """Prompt user whether to perform backup before migration. + + Returns: + bool: True if backup should be performed, False otherwise + """ + self.printH2("Backup Configuration") + print_formatted_text( + HTML( + "It is strongly recommended to backup before migration.\n" + "This will create a full database backup that can be used for rollback.\n" + ) + ) + return self.yesOrNo("Perform backup before migration") + + def migrate(self, argv: List[str]) -> None: + """Main entry point for DB2 migration command. + + Args: + argv (List[str]): Command line arguments + """ + args = db2MigrationArgParser.parse_args(argv) + self.noConfirm = args.no_confirm + + # Connect to cluster + self.connect() + + # Determine mode: interactive vs non-interactive + isInteractive = args.namespace is None + + if isInteractive: + # Interactive mode + self.printH1("Db2uCluster to Db2uInstance Migration") + + # List db2u namespaces + with Halo(text="Detecting db2u namespaces", spinner=self.spinner) as h: + try: + namespaceAPI = self.dynamicClient.resources.get(api_version="v1", kind="Namespace") + allNamespaces = namespaceAPI.get() + db2uNamespaces = [ns.metadata.name for ns in allNamespaces.items if ns.metadata.name.startswith("db2u")] + # v1 = client.CoreV1Api() + # allNamespaces = v1.list_namespace() + # db2uNamespaces = [ns.metadata.name for ns in allNamespaces.items if ns.metadata.name.startswith("db2u")] + + if db2uNamespaces: + h.succeed(f"Found {len(db2uNamespaces)} db2u namespace(s)") + print_formatted_text(HTML("Available db2u namespaces:")) + for ns in sorted(db2uNamespaces): + print(f" - {ns}") + print() + else: + h.info("No db2u namespaces found") + except Exception as e: + h.fail(f"Failed to list namespaces: {e}") + + # Prompt for namespace with default + namespace = self.promptForString("Enter namespace containing Db2uClusters", default="db2u") + + # Detect Db2uClusters + with Halo(text=f"Detecting Db2uClusters in namespace {namespace}", spinner=self.spinner) as h: + db2_clusters = self.detectDb2uClusters(namespace) + if not db2_clusters: + h.fail(f"No Db2uClusters found in namespace {namespace}") + self.fatalError(f"No Db2uClusters found in namespace {namespace}") + h.succeed(f"Found {len(db2_clusters)} Db2uCluster(s)") + + # Select Db2uCluster + selectedDb2Cluster = self.promptForDb2Cluster(db2_clusters) + db2ClusterName = selectedDb2Cluster.metadata.name + + # Prompt for backup + enableBackup = self.promptForBackup() + + else: + # Non-interactive mode + namespace = args.namespace + db2ClusterName = args.db2_cluster_name + enableBackup = args.backup == "true" if args.backup else True + + # Validate Db2uCluster exists if name provided + if db2ClusterName: + db2_clusters = self.detectDb2uClusters(namespace) + db2ClusterNames = [c.metadata.name for c in db2_clusters] + if db2ClusterName not in db2ClusterNames: + self.fatalError(f"Db2uCluster {db2ClusterName} not found in namespace {namespace}") + else: + # Auto-select if only one Db2uCluster + db2_clusters = self.detectDb2uClusters(namespace) + if len(db2_clusters) == 0: + self.fatalError(f"No Db2uClusters found in namespace {namespace}") + elif len(db2_clusters) == 1: + db2ClusterName = db2_clusters[0].metadata.name + else: + self.fatalError("Multiple Db2uClusters found. Please specify --db2-cluster-name") + + # Confirmation + if not self.noConfirm: + self.printH2("Migration Summary") + print_formatted_text( + HTML( + f"Namespace: {namespace}\n" + f"Db2uCluster: {db2ClusterName}\n" + f"Backup: {'Enabled' if enableBackup else 'Disabled'}\n" + ) + ) + if not self.yesOrNo("Proceed with migration"): + print_formatted_text(HTML("Migration cancelled")) + return + + # Set parameters + self.setParam("db2_migration_namespace", namespace) + self.setParam("db2_migration_db2_cluster_name", db2ClusterName) + self.setParam("db2_migration_backup_enabled", str(enableBackup).lower()) + + # Prepare pipeline namespace + pipelinesNamespace = "mas-pipelines" + + with Halo(text="Validating OpenShift Pipelines installation", spinner=self.spinner) as h: + if installOpenShiftPipelines(self.dynamicClient): + h.succeed("OpenShift Pipelines Operator is installed and ready") + else: + h.fail("OpenShift Pipelines Operator installation failed") + self.fatalError("Installation failed") + + with Halo(text=f"Preparing namespace ({pipelinesNamespace})", spinner=self.spinner) as h: + createNamespace(self.dynamicClient, pipelinesNamespace) + preparePipelinesNamespace(dynClient=self.dynamicClient) + h.succeed(f"Namespace {pipelinesNamespace} is ready") + + with Halo(text=f"Installing latest Tekton definitions (v{self.version})", spinner=self.spinner) as h: + updateTektonDefinitions(self.dynamicClient, pipelinesNamespace, self.tektonDefsPath) + h.succeed(f"Latest Tekton definitions are installed (v{self.version})") + + # Launch pipeline + with Halo(text="Submitting PipelineRun for DB2 migration", spinner=self.spinner) as h: + pipelineURL = launchDb2MigrationPipeline(dynClient=self.dynamicClient, params=self.params) + if pipelineURL: + h.succeed("PipelineRun for DB2 migration submitted") + print_formatted_text(HTML(f"\nView progress:\n {pipelineURL}\n")) + else: + h.fail("Failed to submit PipelineRun, see log file for details") diff --git a/python/src/mas/cli/db2_migration/argParser.py b/python/src/mas/cli/db2_migration/argParser.py new file mode 100644 index 0000000000..19360ee136 --- /dev/null +++ b/python/src/mas/cli/db2_migration/argParser.py @@ -0,0 +1,66 @@ +# ----------------------------------------------------------- +# Licensed Materials - Property of IBM +# 5737-M66 +# (C) Copyright IBM Corp. 2026 All Rights Reserved. +# US Government Users Restricted Rights - Use, duplication, or disclosure +# restricted by GSA ADP Schedule Contract with IBM Corp. +# ----------------------------------------------------------- + +import argparse +import sys + +from ..cli import getHelpFormatter + + +class Db2MigrationArgumentParser(argparse.ArgumentParser): + """Custom argument parser for DB2 migration command""" + + def format_usage(self): + prog = self.prog + return ( + f"Usage (non-interactive mode):\n" + f" {prog} --namespace NAMESPACE [--db2-cluster-name DB2_CLUSTER_NAME]\n" + f" [--backup {{true,false}}] [--no-confirm]\n" + f"\n" + f"Usage (interactive mode):\n" + f" {prog}\n" + f"\n" + f"Usage (help):\n" + f" {prog} -h\n" + ) + + def parse_args(self, args=None, namespace=None): + parsedArgs = super().parse_args(args, namespace) + + providedArgs = [] + if args is not None: + providedArgs = [arg for arg in args if arg.startswith("-")] + else: + providedArgs = [arg for arg in sys.argv[1:] if arg.startswith("-")] + + hasAnyArgs = len(providedArgs) > 0 + hasNamespace = parsedArgs.namespace is not None + helpOnly = "--help" in providedArgs or "-h" in providedArgs + + if hasAnyArgs and not hasNamespace and not helpOnly: + self.error("non-interactive mode requires --namespace parameter") + + return parsedArgs + + +db2MigrationArgParser = Db2MigrationArgumentParser( + prog="mas db2ucluster-migration", + description="Migrate Db2uCluster to Db2uInstance by launching the DB2 Migration Tekton Pipeline.", + epilog="Refer to the online documentation for more information: https://ibm-mas.github.io/cli/", + formatter_class=getHelpFormatter(), + add_help=False, +) + +migrationArgGroup = db2MigrationArgParser.add_argument_group("Migration Configuration", "Configure the DB2 migration parameters.") +migrationArgGroup.add_argument("--namespace", required=False, help="Namespace containing Db2uCluster instances") +migrationArgGroup.add_argument("--db2-cluster-name", required=False, help="Specific Db2uCluster name to migrate") +migrationArgGroup.add_argument("--backup", required=False, choices=["true", "false"], help="Enable or disable backup before migration") + +otherArgGroup = db2MigrationArgParser.add_argument_group("More", "Additional options.") +otherArgGroup.add_argument("--no-confirm", required=False, action="store_true", default=False, help="Launch without prompting for confirmation") +otherArgGroup.add_argument("-h", "--help", action="help", default=False, help="Show this help message and exit") diff --git a/python/src/mas/cli/must_gather/common/crd_processor.py b/python/src/mas/cli/must_gather/common/crd_processor.py index 546c7392f9..d8e2061e95 100644 --- a/python/src/mas/cli/must_gather/common/crd_processor.py +++ b/python/src/mas/cli/must_gather/common/crd_processor.py @@ -21,7 +21,7 @@ from dataclasses import dataclass from typing import Dict, List, Tuple, Optional from kubernetes.dynamic import DynamicClient -from jsonpath_ng.ext import parse +from jsonpath_ng.ext.parser import parse logger = logging.getLogger(__name__) diff --git a/tekton/generate-tekton-pipelines.yml b/tekton/generate-tekton-pipelines.yml index ceaa590a00..c597f245d3 100644 --- a/tekton/generate-tekton-pipelines.yml +++ b/tekton/generate-tekton-pipelines.yml @@ -22,6 +22,7 @@ - mas-update - mas-upgrade - mas-uninstall + - mas-devops-db2-migration # MAS FVT Pipelines - mas-fvt-assist - mas-fvt-core diff --git a/tekton/src/pipelines/mas-devops-db2-migration.yml.j2 b/tekton/src/pipelines/mas-devops-db2-migration.yml.j2 new file mode 100644 index 0000000000..060f149542 --- /dev/null +++ b/tekton/src/pipelines/mas-devops-db2-migration.yml.j2 @@ -0,0 +1,72 @@ +--- +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: mas-devops-db2-migration +spec: + workspaces: + - name: shared-configs + optional: true + + params: + # Tekton Pipeline Configuration + # ------------------------------------------------------------------------- + - name: image_pull_policy + type: string + default: IfNotPresent + + # DB2 Migration Parameters + # ------------------------------------------------------------------------- + - name: db2_action + type: string + default: "migrate" + description: Action for db2 role (must be 'migrate') + + - name: db2_migration_namespace + type: string + description: Namespace containing Db2uCluster to migrate + + - name: db2_migration_db2_cluster_name + type: string + description: Name of Db2uCluster to migrate + + - name: db2_migration_backup_enabled + type: string + default: "true" + description: Whether to backup before migration + + - name: db2_migration_cleanup_old_db2_cluster + type: string + default: "false" + description: Whether to delete old Db2uCluster CR after migration + + - name: ibm_entitlement_key + type: string + default: "" + description: IBM Entitlement Key + + tasks: + # Run DB2 Migration + # ------------------------------------------------------------------------- + - name: db2-migration + timeout: "0" + taskRef: + kind: Task + name: mas-devops-db2 + params: + {{ lookup('template', pipeline_src_dir ~ '/taskdefs/common/cli-params.yml.j2') | indent(8) }} + - name: devops_suite_name + value: db2-migration + - name: db2_action + value: $(params.db2_action) + - name: db2_migration_namespace + value: $(params.db2_migration_namespace) + - name: db2_migration_db2_cluster_name + value: $(params.db2_migration_db2_cluster_name) + - name: db2_migration_backup_enabled + value: $(params.db2_migration_backup_enabled) + - name: db2_migration_cleanup_old_db2_cluster + value: $(params.db2_migration_cleanup_old_db2_cluster) + workspaces: + - name: configs + workspace: shared-configs diff --git a/tekton/src/tasks/dependencies/db2.yml.j2 b/tekton/src/tasks/dependencies/db2.yml.j2 index 47a033a466..c6a5e78ec0 100644 --- a/tekton/src/tasks/dependencies/db2.yml.j2 +++ b/tekton/src/tasks/dependencies/db2.yml.j2 @@ -219,6 +219,24 @@ spec: description: S3 secret key for backups default: "" + # DB2 Migration Parameters + - name: db2_migration_namespace + type: string + description: Namespace containing Db2uCluster to migrate + default: "" + - name: db2_migration_db2_cluster_name + type: string + description: Name of Db2uCluster to migrate + default: "" + - name: db2_migration_backup_enabled + type: string + description: Whether to backup before migration + default: "true" + - name: db2_migration_cleanup_old_db2_cluster + type: string + description: Whether to delete old Db2uCluster CR after migration + default: "false" + stepTemplate: env: {{ lookup('template', task_src_dir ~ '/common/cli-env.yml.j2') | indent(6) }} @@ -362,6 +380,16 @@ spec: value: $(params.backup_s3_secret_key) - name: OVERRIDE_STORAGECLASS value: $(params.override_storageclass) + + # DB2 Migration specific + - name: DB2_MIGRATION_NAMESPACE + value: $(params.db2_migration_namespace) + - name: DB2_MIGRATION_DB2_CLUSTER_NAME + value: $(params.db2_migration_db2_cluster_name) + - name: DB2_MIGRATION_BACKUP_ENABLED + value: $(params.db2_migration_backup_enabled) + - name: DB2_MIGRATION_CLEANUP_OLD_DB2_CLUSTER + value: $(params.db2_migration_cleanup_old_db2_cluster) steps: - name: db2 command: